Encrypted Client Hello: Privacy vs. Enterprise Security Inspection
The Problem
For the entire history of TLS, the hostname a client is connecting to has been visible in plaintext on the wire. The TLS Server Name Indication extension — SNI, defined in RFC 6066 — was added precisely to allow virtual hosting: a single IP address can serve multiple TLS certificates, and the client advertises which hostname it wants before the handshake completes so the server can select the right certificate. This is necessary and correct from a routing perspective. It also means that every network observer — your ISP, a corporate firewall, a surveillance device, any on-path adversary — can read the exact hostname of every HTTPS request you make, despite the fact that the connection contents are encrypted.
For the connection user.example.com:443:
- IP address: visible (routing requires it)
- Port: visible (routing requires it)
- TLS SNI in the ClientHello: visible in plaintext —
user.example.com - HTTP request path, headers, and body: encrypted (not visible)
This means network-level traffic analysis can correlate every HTTPS connection to a specific hostname with no decryption required. For a user browsing a medical information site, a domestic abuse resource, or a political opposition organisation, the SNI leak is functionally equivalent to logging the full URL. A corporate firewall that never breaks TLS encryption can still build a complete browsing history by reading SNI from the wire.
Encrypted Client Hello (ECH), standardised as RFC 9258, closes this gap. ECH encrypts the TLS ClientHello — including the SNI — using the server’s public key before the handshake begins. The public key is distributed via DNS, in an HTTPS resource record. The result is that the actual target hostname is visible only to the client and the server. Every network observer between them sees a generic cover hostname (typically a CDN’s own domain) and an encrypted blob they cannot read without the server’s private key.
Cloudflare enabled ECH for all its customers by default in late 2023. Firefox and Chrome both enable ECH by default when an HTTPS DNS record with ECH configuration is present. A substantial fraction of internet HTTPS traffic now uses ECH. Security teams who have not updated their monitoring architecture are operating with a monitoring gap that grows every month as ECH adoption increases.
How ECH Works
The ECH mechanism has two protocol components: a DNS-distributed public key and a modified TLS ClientHello structure.
DNS distribution of the ECH public key. Before the client initiates a TLS connection, it queries DNS for an HTTPS record (type 65) for the target domain. Cloudflare has published these since 2023; the record for crypto.cloudflare.com returns something like:
crypto.cloudflare.com. 300 IN HTTPS 1 . ech=AEn+DQBFQgAAgACAAQABAANAIQAg...
kem=0x0020 (X25519)
kdf=0x0001 (HKDF-SHA256)
aead=0x0001 (AES-128-GCM)
The ech= value is a base64-encoded ECHConfigList structure containing the server’s ECH public key (a Diffie-Hellman public key using X25519), the key encapsulation mechanism (KEM), key derivation function (KDF), and AEAD cipher suite identifiers. The client uses this public key to encrypt the real ClientHello before sending anything to the server.
Split ClientHello structure. With ECH enabled, the client sends two ClientHello messages: an outer and an inner.
The outer ClientHello is the one visible on the wire. It contains:
- An outer SNI set to the CDN’s cover domain (e.g.,
cloudflare-ech.com), not the target hostname - A standard TLS extension list that hides nothing sensitive
- An
encrypted_client_helloextension (IANA type0xfe0d) containing the encrypted inner ClientHello
The inner ClientHello is encrypted inside the encrypted_client_hello extension using HPKE (Hybrid Public Key Encryption, RFC 9180). It contains:
- The real target SNI (
user.example.com) - The client’s actual supported cipher suites and extensions
- Any other sensitive negotiation parameters
DNS lookup for user.example.com:
HTTPS record → ECHConfigList
KEM: X25519 (kem_id=0x0020)
KDF: HKDF-SHA256 (kdf_id=0x0001)
AEAD: AES-128-GCM (aead_id=0x0001)
public_key: [32 bytes X25519 public key]
TLS ClientHello on the wire (visible to network observer):
outer SNI: cloudflare-ech.com
supported_versions: TLS 1.3
extension 0xfe0d: [HPKE-encrypted inner ClientHello blob]
HPKE encapsulated key (32 bytes)
HPKE ciphertext (inner ClientHello + AEAD tag)
TLS ClientHello after server decryption (only server sees this):
inner SNI: user.example.com
supported_groups: X25519, P-256
signature_algs: ECDSA-P256-SHA256, RSA-PSS-SHA256
[client's real extension list]
The server decrypts the encrypted_client_hello extension using its ECH private key (the counterpart to the public key published in DNS). It then processes the inner ClientHello as the authoritative one and responds with the real origin certificate for user.example.com.
If the server does not support ECH or the ECH configuration has changed (key rotation), it responds with an alert containing a fresh retry_configs field — a new ECHConfigList the client should use. The client re-establishes the connection with updated parameters. This is the ECH rejection flow and it happens transparently from the user’s perspective.
What’s Still Visible to a Network Observer
ECH is not full traffic anonymisation. A network observer with access to the wire still sees:
- IP address of the CDN/server: reveals the CDN provider (Cloudflare, Fastly, Google) but not which origin among the millions hosted there. Cloudflare’s IP ranges are public; knowing a connection went to
104.21.0.0/16tells you “Cloudflare customer” but nothing more specific. - Port 443: unchanged.
- TLS version and cipher suite: negotiated in the outer ClientHello, visible.
- The outer certificate: the CDN cover domain’s certificate (
cloudflare-ech.com), not the origin’s certificate. The origin certificate is delivered after the inner ClientHello is decrypted, inside the now-established encrypted session. - Traffic volume and timing: packet sizes, inter-packet timing, session duration — all visible. Traffic fingerprinting based on these signals is partially still possible.
- JA4 fingerprint: the client’s TLS behaviour pattern (cipher suite list, extension ordering, supported groups) is partially visible in the outer ClientHello. JA4 identifies the TLS client implementation (browser version, library).
- DNS HTTPS record queries: if DNS is not encrypted (DoH/DoT), the query for the HTTPS record of
user.example.comis visible — which partially defeats ECH’s purpose. ECH is most effective when combined with DNS-over-HTTPS or DNS-over-TLS.
What’s hidden from a network observer:
- The target hostname (SNI) — the primary protection ECH provides
- Which specific website among all CDN-hosted sites the client is accessing
- The origin server’s TLS certificate (carried in the encrypted inner session)
- The client’s real cipher suite preferences and extension list (in the inner ClientHello)
- Any extension values that would identify the specific client-server relationship
Threat Model
ECH as a C2 channel protection mechanism. Malware operators have recognised that SNI-based network detection is a significant obstacle. C2 frameworks like Cobalt Strike, Sliver, and custom implants increasingly route traffic through CDN services. Before ECH, this still left the SNI visible — a SOC analyst could write a detection rule for suspicious-c2-domain.com appearing in SNI logs and catch implant communications. With ECH, the visible SNI is always the CDN’s cover domain (cloudflare-ech.com). The malware’s actual C2 hostname is invisible to the network. IDS/IPS signatures matching on SNI fields miss all of this traffic. The only network-level signal remaining is the IP address of the CDN and traffic volume patterns.
Data exfiltration through ECH-enabled destinations. Enterprise DLP deployments frequently rely on SNI inspection as a lightweight alternative to full TLS MITM. A DLP rule that blocks uploads to mega.nz or wetransfer.com by matching the SNI in outbound TLS connections fails entirely when those services implement ECH. From the DLP sensor’s perspective, the connection goes to a Cloudflare IP on port 443 — indistinguishable from accessing any other Cloudflare-hosted service. The exfiltration completes undetected.
Monitoring blindspot for insider threat investigations. Security teams conducting insider threat investigations frequently pull SNI logs to reconstruct what sites a user accessed. With ECH, connections to Cloudflare-hosted sites (which includes a significant fraction of the public internet) produce only CDN IP addresses in the logs. “User accessed cloudflare-ech.com 47 times between 14:00 and 15:30” is not a useful artefact for an investigation into whether a departing employee exfiltrated intellectual property.
ECH breaks “split intelligence” monitoring architectures. Many enterprise security architectures use a tiered inspection model: full TLS MITM for a defined subset of high-risk categories (financial sites, cloud storage, known risk destinations), and SNI-only inspection for everything else. The SNI-only tier provides coverage without the performance overhead and certificate trust issues of full MITM. ECH makes the SNI-only tier completely ineffective.
ECH adoption is not gradual — it’s a step change. When Cloudflare enabled ECH for all customers by default, every Cloudflare-hosted site became opaque to SNI-based monitoring simultaneously. This is not a slow migration where teams have years to adapt. It is a flag being flipped in Cloudflare’s infrastructure. If your security tooling assumes SNI is always present, it silently degraded the moment that change was deployed.
Hardening Configuration
1. Detect ECH Usage in TLS Traffic
The first step is confirming how much ECH traffic your network is already carrying. The ECH extension has IANA-assigned type 0xfe0d (65037 decimal). Its presence in a ClientHello identifies an ECH-capable connection attempt.
# tls_ech_detect.py — parse raw TLS records for ECH extension presence
import struct
from dataclasses import dataclass
from typing import Optional
ECH_EXTENSION_TYPE = 0xfe0d # RFC 9258 IANA assignment
@dataclass
class TLSExtension:
ext_type: int
data: bytes
def parse_extensions(data: bytes) -> list[TLSExtension]:
"""Parse TLS extension list from a byte buffer."""
extensions = []
pos = 0
while pos + 4 <= len(data):
ext_type = struct.unpack_from('>H', data, pos)[0]
ext_len = struct.unpack_from('>H', data, pos + 2)[0]
pos += 4
if pos + ext_len > len(data):
break
extensions.append(TLSExtension(ext_type, data[pos:pos + ext_len]))
pos += ext_len
return extensions
def detect_ech_in_clienthello(packet_bytes: bytes) -> tuple[bool, Optional[str]]:
"""
Detect ECH extension in a TLS ClientHello record.
Returns (ech_present, outer_sni_or_None).
packet_bytes: raw bytes starting at the TLS record layer.
"""
try:
if len(packet_bytes) < 5:
return False, None
# TLS record header: ContentType(1) + LegacyVersion(2) + Length(2)
content_type = packet_bytes[0]
if content_type != 0x16: # Handshake
return False, None
record_length = struct.unpack_from('>H', packet_bytes, 3)[0]
if len(packet_bytes) < 5 + record_length:
return False, None
handshake = packet_bytes[5:5 + record_length]
# Handshake header: HandshakeType(1) + Length(3)
if len(handshake) < 4 or handshake[0] != 0x01: # ClientHello
return False, None
msg_len = struct.unpack_from('>I', b'\x00' + handshake[1:4])[0]
ch = handshake[4:4 + msg_len]
# ClientHello body:
# LegacyVersion(2) + Random(32) + SessionIDLen(1) + SessionID(var)
# + CipherSuitesLen(2) + CipherSuites(var)
# + CompressionMethodsLen(1) + CompressionMethods(var)
# + ExtensionsLen(2) + Extensions(var)
pos = 0
pos += 2 + 32 # version + random
if pos >= len(ch):
return False, None
session_id_len = ch[pos]
pos += 1 + session_id_len
if pos + 2 > len(ch):
return False, None
cs_len = struct.unpack_from('>H', ch, pos)[0]
pos += 2 + cs_len
if pos >= len(ch):
return False, None
comp_len = ch[pos]
pos += 1 + comp_len
if pos + 2 > len(ch):
return False, None
ext_total_len = struct.unpack_from('>H', ch, pos)[0]
pos += 2
extensions = parse_extensions(ch[pos:pos + ext_total_len])
ech_present = False
outer_sni = None
for ext in extensions:
if ext.ext_type == ECH_EXTENSION_TYPE:
ech_present = True
elif ext.ext_type == 0x0000: # SNI extension
# SNI: ListLen(2) + Type(1) + NameLen(2) + Name
if len(ext.data) > 5:
name_len = struct.unpack_from('>H', ext.data, 3)[0]
outer_sni = ext.data[5:5 + name_len].decode('ascii', errors='replace')
return ech_present, outer_sni
except Exception:
return False, None
# Live packet capture using scapy:
from scapy.all import sniff, TCP, IP
import logging
logging.basicConfig(level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s')
def process_packet(pkt):
if not (pkt.haslayer(TCP) and pkt.haslayer(IP)):
return
if pkt[TCP].dport != 443:
return
payload = bytes(pkt[TCP].payload)
if not payload:
return
ech_present, outer_sni = detect_ech_in_clienthello(payload)
if ech_present:
logging.info(
"ECH ClientHello src=%s:%d outer_sni=%s",
pkt[IP].src, pkt[TCP].sport,
outer_sni or "<none>"
)
# Run with: sudo python3 tls_ech_detect.py
sniff(filter="tcp dst port 443 and tcp[tcpflags] & tcp-push != 0",
prn=process_packet,
store=False)
This parser follows the actual TLS 1.3 ClientHello wire format from RFC 8446 §4.1.2 rather than doing a naive byte search. A naive search for \xfe\x0d in the packet will produce false positives wherever those bytes appear in cipher suite lists or other extension data. The structured parser skips to the extension list before scanning extension type codes.
2. Zeek: Log and Alert on ECH Traffic
Zeek’s SSL analyser dissects TLS extensions by type code. The following script logs every connection where the ECH extension is observed in the ClientHello, writing records to a dedicated ech.log stream and optionally raising a notice for SIEM ingestion.
# detect-ech.zeek
# Deploy: zeek -i eth0 detect-ech.zeek
# Or add to /usr/share/zeek/site/local.zeek for persistent deployment
@load base/protocols/ssl
@load base/frameworks/notice
module ECH;
export {
redef enum Notice::Type += {
ECH_Extension_Observed,
};
# Dedicated log stream for ECH events
redef enum Log::ID += { LOG };
type Info: record {
ts: time &log;
uid: string &log;
src_ip: addr &log;
src_port: port &log;
dst_ip: addr &log;
dst_port: port &log;
outer_sni: string &log &optional;
ech_ext: bool &log &default=F;
};
}
global ech_log: table[string] of Info;
event zeek_init() {
Log::create_stream(ECH::LOG, [$columns=Info, $path="ech"]);
}
# Extension type 65037 = 0xfe0d — ECH (RFC 9258)
event ssl_extension(c: connection, is_orig: bool, code: count, val: string)
{
if (!is_orig) return; # Only process client-side extensions
if (code == 65037) {
local rec: ECH::Info = [
$ts = network_time(),
$uid = c$uid,
$src_ip = c$id$orig_h,
$src_port = c$id$orig_p,
$dst_ip = c$id$resp_h,
$dst_port = c$id$resp_p,
$ech_ext = T,
];
if (c?$ssl && c$ssl?$server_name)
rec$outer_sni = c$ssl$server_name;
Log::write(ECH::LOG, rec);
NOTICE([$note=ECH::ECH_Extension_Observed,
$conn=c,
$msg=fmt("ECH ClientHello from %s outer_sni=%s",
c$id$orig_h,
c?$ssl && c$ssl?$server_name
? c$ssl$server_name
: "<none>"),
$identifier=cat(c$id$orig_h)]);
}
}
The ech.log output produced for an ECH connection to a Cloudflare-hosted site looks like:
#fields ts uid src_ip src_port dst_ip dst_port outer_sni ech_ext
1746748800.123 CXWqTh3abc12 192.168.1.45 52341 104.21.18.62 443 cloudflare-ech.com T
1746748812.441 CXWqTh3def34 192.168.1.91 49201 104.21.44.201 443 cloudflare-ech.com T
1746748820.001 CXWqTh3ghi56 192.168.1.12 60012 142.250.80.46 443 <none> T
The outer SNI cloudflare-ech.com is Cloudflare’s cover domain — it appears for every Cloudflare ECH connection regardless of which origin the client is actually accessing. The actual target hostname is inside the encrypted blob and is not recoverable from the wire without the server’s ECH private key.
To quantify ECH adoption across your network, aggregate on a per-source basis:
zeek-cut src_ip outer_sni ech_ext < ech.log \
| awk '$3=="T"' \
| sort | uniq -c | sort -rn | head -20
3. DNS Monitoring: Detect HTTPS Record Queries
ECH requires the client to successfully retrieve an HTTPS DNS record containing the ECH public key before it can encrypt the ClientHello. Monitoring for HTTPS record queries (DNS type 65) provides an upstream signal of ECH intent — before the TLS connection is established — and can identify which hostnames clients are requesting ECH configuration for, even when the TLS layer itself is opaque.
# For Unbound resolvers: enable query logging in unbound.conf
cat >> /etc/unbound/unbound.conf << 'EOF'
server:
# Log all queries with type information
log-queries: yes
log-tag-queryreply: yes
verbosity: 2
EOF
# HTTPS records appear in unbound logs as type HTTPS or type TYPE65
# Monitor the query log in real time:
tail -f /var/log/unbound.log \
| grep -E 'type HTTPS|TYPE65' \
| awk '{print $1, $2, $(NF-1), $NF}'
# Example output:
# May 08 14:23:01 unbound[1234]: [1234:0] info: 192.168.1.45 user.example.com. TYPE65 IN
# May 08 14:23:01 unbound[1234]: [1234:0] info: 192.168.1.91 cloudflare.com. TYPE65 IN
For BIND 9 resolvers, enable query logging and filter for type 65:
# named.conf
logging {
channel query_log {
file "/var/log/named/queries.log" versions 10 size 100m;
severity dynamic;
print-time yes;
print-severity yes;
print-category yes;
};
category queries { query_log; };
};
# Parse for HTTPS record queries and count by client:
grep 'QTYPE=HTTPS\|QTYPE=65\| IN HTTPS' /var/log/named/queries.log \
| awk '{print $NF, $(NF-2)}' \
| sort | uniq -c | sort -rn | head -20
For Pi-hole (which uses dnsmasq), HTTPS queries appear as type 65:
grep 'query\[HTTPS\]\|query\[TYPE65\]' /var/log/pihole.log \
| awk '{print $6, $8}' \
| sort | uniq -c | sort -rn
This DNS-layer visibility is only available if clients use your internal resolver. Clients configured to use DoH (DNS-over-HTTPS) to Cloudflare’s 1.1.1.1 or Google’s 8.8.8.8 bypass your resolver entirely, and HTTPS record queries are invisible. DNS visibility depends on resolver control.
4. Suricata: Detect and Alert on ECH ClientHello
Suricata’s TLS keyword tls.client_hello matches against the raw ClientHello byte content. The ECH extension type 0xfe0d (bytes \xfe\x0d) will appear in the extension list of any ECH ClientHello. The following rules detect ECH and log it as a policy violation for SIEM correlation:
# /etc/suricata/rules/tls-ech.rules
#
# Rule 1: Detect ECH extension in TLS ClientHello
# ECH extension type 0xfe0d appears as bytes fe 0d in the extension list
alert tls any any -> any 443 (
msg:"TLS ECH Extension Detected - Hostname Hidden from Network Inspection";
flow:established,to_server;
tls.client_hello;
content:"|fe 0d|";
depth:512;
threshold: type limit, track by_src, count 1, seconds 300;
classtype:policy-violation;
metadata:created_at 2026-05-09, updated_at 2026-05-09;
sid:9100001;
rev:1;
)
# Rule 2: Alert on ECH from internal hosts to any destination
# Increase severity for source IPs in sensitive network segments
alert tls $SENSITIVE_NETS any -> any 443 (
msg:"TLS ECH from Sensitive Network Segment - DLP Bypass Risk";
flow:established,to_server;
tls.client_hello;
content:"|fe 0d|";
depth:512;
threshold: type limit, track by_src, count 3, seconds 60;
classtype:policy-violation;
priority:1;
metadata:created_at 2026-05-09, updated_at 2026-05-09;
sid:9100002;
rev:1;
)
# Rule 3: High-volume ECH — potential automated exfiltration
alert tls any any -> any 443 (
msg:"TLS ECH High Volume - Potential Automated Exfiltration";
flow:established,to_server;
tls.client_hello;
content:"|fe 0d|";
depth:512;
threshold: type threshold, track by_src, count 50, seconds 60;
classtype:policy-violation;
priority:1;
metadata:created_at 2026-05-09, updated_at 2026-05-09;
sid:9100003;
rev:1;
)
Add $SENSITIVE_NETS to your Suricata suricata.yaml under vars: address-groups::
vars:
address-groups:
SENSITIVE_NETS: "[10.10.50.0/24,10.10.51.0/24]" # Finance, HR segments
HOME_NET: "[10.0.0.0/8,172.16.0.0/12,192.168.0.0/16]"
Load the new rules and validate:
suricata --test -c /etc/suricata/suricata.yaml \
-S /etc/suricata/rules/tls-ech.rules
# Reload rules without restart:
suricatasc -c reload-rules
The depth:512 constraint is important. ECH’s extension type bytes appear within the first few hundred bytes of the ClientHello. Setting depth prevents matching against application data in later packets of the same flow. The flow:established,to_server matcher ensures Suricata only checks server-bound packets in an established TCP session — specifically the ClientHello — rather than triggering on arbitrary data.
A Suricata alert for an ECH connection looks like:
{
"timestamp": "2026-05-09T14:23:01.442Z",
"event_type": "alert",
"src_ip": "192.168.1.45",
"src_port": 52341,
"dest_ip": "104.21.18.62",
"dest_port": 443,
"proto": "TCP",
"alert": {
"action": "alert",
"gid": 1,
"signature_id": 9100001,
"rev": 1,
"signature": "TLS ECH Extension Detected - Hostname Hidden from Network Inspection",
"category": "Policy Violation",
"severity": 2
},
"tls": {
"version": "TLS 1.3",
"sni": "cloudflare-ech.com"
}
}
Note that the tls.sni field in the Suricata log contains the outer SNI — cloudflare-ech.com — not the actual destination. This is the correct behaviour: Suricata can only report what’s visible on the wire.
5. Update Security Monitoring to Work Without SNI
Rather than relying on SNI as the primary signal for traffic classification and threat detection, monitoring systems need to be rebuilt around signals that survive ECH. This is not optional — SNI-based monitoring is already degraded for Cloudflare-hosted traffic.
# ech_aware_classifier.py — threat detection that works with or without SNI
from dataclasses import dataclass, field
from typing import Optional
import ipaddress
# IP reputation feed interface (implement with your threat intel provider)
def check_ip_reputation(ip: str) -> dict:
"""Query IP reputation from threat intelligence feed."""
# Integrate with: GreyNoise, Shodan, CIRCL, internal IOC feeds
# Returns: {'score': 0-100, 'tags': [...], 'asn': '...', 'country': '...'}
raise NotImplementedError("Wire up to your threat intel provider")
# JA4 fingerprint database (TLS client fingerprinting)
KNOWN_MALWARE_JA4 = {
"t13d1516h2_8daaf6152771_b0da82dd1658", # Cobalt Strike Beacon (TLS 1.3)
"t13d1517h2_8daaf6152771_02713d6af862", # Sliver C2
# Add from: https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4.md
}
@dataclass
class ConnectionSignals:
src_ip: str
dst_ip: str
dst_port: int
tls_version: Optional[str]
outer_sni: Optional[str] # CDN cover domain if ECH; real hostname if no ECH
ech_detected: bool
ja4_hash: Optional[str] # TLS fingerprint visible in outer ClientHello
cert_issuer: Optional[str] # Outer/cover cert issuer
cert_cn: Optional[str] # Outer/cover cert CN
bytes_sent: int = 0
bytes_received: int = 0
duration_seconds: float = 0.0
dns_https_query_seen: bool = False # Whether HTTPS record was queried first
def classify_connection(conn: ConnectionSignals) -> dict:
"""
Classify a TLS connection for security monitoring.
ECH-aware: works regardless of SNI availability.
"""
findings = {
"hostname": conn.outer_sni if not conn.ech_detected else None,
"hostname_hidden": conn.ech_detected,
"risk_indicators": [],
"recommended_action": "allow",
}
# Signal 1: IP reputation — always available regardless of ECH
try:
rep = check_ip_reputation(conn.dst_ip)
if rep.get('score', 0) > 75:
findings['risk_indicators'].append(
f"high_ip_reputation_score:{rep['score']}"
)
findings['recommended_action'] = 'block'
if 'tor-exit' in rep.get('tags', []):
findings['risk_indicators'].append('tor_exit_node')
findings['recommended_action'] = 'block'
except NotImplementedError:
pass # No threat intel configured
# Signal 2: JA4 fingerprint — visible in outer ClientHello even with ECH
if conn.ja4_hash and conn.ja4_hash in KNOWN_MALWARE_JA4:
findings['risk_indicators'].append(
f"malware_ja4_fingerprint:{conn.ja4_hash}"
)
findings['recommended_action'] = 'block'
# Signal 3: ECH without prior HTTPS DNS query
# Legitimate browsers always query HTTPS record before using ECH.
# ECH without a preceding HTTPS query may indicate crafted traffic.
if conn.ech_detected and not conn.dns_https_query_seen:
findings['risk_indicators'].append('ech_without_https_dns_query')
# Signal 4: Anomalous traffic volume
# Data exfiltration typically involves high bytes_sent relative to bytes_received
if conn.bytes_sent > 10_000_000 and conn.ech_detected:
ratio = conn.bytes_sent / max(conn.bytes_received, 1)
if ratio > 10:
findings['risk_indicators'].append(
f"high_upload_ratio:{ratio:.1f}x_with_ech"
)
findings['recommended_action'] = 'investigate'
# Signal 5: Known CDN cover SNI with ECH (expected pattern)
# Flag anomalous outer SNIs — ECH cover domains should be CDN-owned
known_ech_cover_domains = {
'cloudflare-ech.com',
'h3.shared.global.fastly.net',
# Add your CDN providers' cover domains here
}
if conn.ech_detected and conn.outer_sni:
if conn.outer_sni not in known_ech_cover_domains:
findings['risk_indicators'].append(
f"unknown_ech_cover_domain:{conn.outer_sni}"
)
return findings
6. Handling ECH in MITM Proxy Deployments
Full TLS MITM inspection (approach 1) still works with ECH, but requires explicit handling. When a client sends an ECH ClientHello to a transparent proxy, the proxy receives the outer ClientHello with the CDN’s ECH public key embedded. The proxy cannot decrypt the ECH extension because it does not hold the CDN’s ECH private key.
The proxy has two options:
Option A: Strip ECH and reconnect. The proxy terminates the ECH ClientHello from the client by presenting its own CA-signed certificate for the outer SNI, then re-establishes a connection to the actual upstream without ECH. The client’s ECH privacy is broken but the proxy can inspect traffic. This requires the proxy CA certificate to be trusted by the client — standard for corporate MITM proxies.
Option B: Pass through ECH and rely on IP and certificate signals only. The proxy allows the ECH connection to flow without decryption, and relies on IP reputation, JA4 fingerprinting, and traffic volume for classification. This preserves user privacy but limits inspection capability.
For Squid configured as a bump-and-inspect proxy, ECH connections appear as CONNECT tunnels to the CDN IP. Squid 6.x introduced ssl_bump actions that can be conditioned on SSL extension presence:
# squid.conf — handle ECH connections
acl tls_ech ssl::server_name_regex cloudflare-ech\.com
acl tls_ech ssl::server_name_regex .*\.fastly\.net
# Option A: Bump (MITM) all ECH connections to force SNI visibility
ssl_bump bump tls_ech
# Option B: Splice ECH connections (pass through without decryption)
# ssl_bump splice tls_ech
# Log ECH connections for audit regardless of handling choice
access_log /var/log/squid/ech_connections.log squid tls_ech
When bumping ECH connections, Squid presents its own certificate to the client. If the client has certificate pinning for the actual origin, this will fail. Most browsers do not pin certificates for arbitrary websites, but some applications do.
Expected Behaviour
In Zeek logs. Every ECH connection produces a record in ech.log with ech_ext=T and outer_sni set to the CDN cover domain. The SSL log (ssl.log) shows server_name: cloudflare-ech.com and established: true. The subject and issuer fields in the certificate log reflect the CDN cover domain’s certificate, not the origin’s. The actual origin hostname does not appear anywhere in Zeek’s logs.
In DNS query logs. Before each ECH-capable TLS connection, the client issues a DNS query for the HTTPS record of the target hostname. In unbound query logs: info: 192.168.1.45 user.example.com. TYPE65 IN. If your resolver does not support HTTPS records and returns SERVFAIL or NXDOMAIN for type 65, the client falls back to non-ECH TLS and the SNI is visible — this is the ECH fallback mechanism.
In Suricata eve.json. Alert events with signature_id: 9100001 appear for each new source IP making an ECH connection (rate-limited by the threshold rule to one alert per source per 5 minutes). The tls.sni field in the Suricata event contains the outer SNI — the CDN cover domain — which is correct.
ECH failure and retry. If the server’s ECH configuration has changed since the client fetched the HTTPS DNS record (e.g., the server rotated its ECH private key), the server returns a TLS alert with ech_required and a retry_configs extension containing the new ECHConfigList. The client retries the connection with the updated configuration. This appears on the wire as two separate TLS handshakes to the same server in rapid succession. In Zeek logs, you will see two ssl.log entries for the same connection tuple within milliseconds.
Trade-offs
Blocking ECH at the network perimeter. Technically possible by writing a Suricata rule with drop action instead of alert. In practice, this blocks access to every Cloudflare-hosted site, every Google service using ECH, and every other major CDN property. Cloudflare alone serves roughly 20% of the web. Blocking ECH is equivalent to blocking access to a significant fraction of the internet. Additionally, blocking ECH does not cause the connection to fail cleanly — it causes a TLS handshake failure that appears to the user as a generic connection error, with no explanation that ECH policy is the cause. Users route around this by using VPNs or mobile data, which defeats the monitoring objective entirely.
Monitoring without blocking. You lose SNI-based DLP and SNI-based threat detection for ECH traffic. You retain IP reputation, JA4 fingerprinting, traffic volume analysis, DNS query monitoring (when resolver is controlled), and full inspection via MITM proxy. The monitoring gap is real but bounded: if you control the TLS inspection proxy, you retain full visibility. If you rely on SNI-only inspection, you have lost visibility for Cloudflare-hosted traffic since 2023 and probably did not know it.
MITM proxy for ECH. Full TLS inspection survives ECH because the proxy terminates the client-side TLS connection before ECH encryption would protect it. The proxy sees the decrypted inner ClientHello and the full HTTP traffic. The cost: proxy performance under increased TLS termination load; potential breakage for applications that use certificate pinning; and the organisational overhead of maintaining a trusted CA and distributing it to managed devices.
Failure Modes
DLP rules matching on SNI. Any DLP rule of the form “if SNI contains dropbox.com, block the connection” fails silently for ECH connections to Dropbox. The connection appears to go to a CDN IP; the SNI is the cover domain; the rule never fires; the data is exfiltrated. This is not a hypothetical — Dropbox is Cloudflare-hosted. If your DLP architecture depends on SNI matching for any Cloudflare-hosted service, it has been ineffective since Cloudflare enabled ECH by default.
IDS/IPS signatures matching on SNI or certificate CN. Threat intelligence feeds frequently include domains as IOCs. If an IDS rule matches on tls.sni == "malware-c2.example.com" or tls.cert.cn contains "malware-c2", and the malware operator has registered their C2 domain on Cloudflare and enabled ECH, neither the SNI nor the origin certificate is visible on the wire. The rule produces no alerts. The only network-level IOC available is the Cloudflare IP range, which cannot be blocked without collateral damage.
Monitoring coverage gaps not surfaced during audits. A SOC that has historically relied on SNI-based coverage will not notice the gap unless they specifically measure ECH traffic volume and compare their detection coverage against it. The logs still fill up — Suricata still logs connections, Zeek still writes ssl.log records — but the server_name field contains the cover domain instead of the origin. Existing dashboards and alerts continue to appear functional while silently missing a growing fraction of traffic. Quarterly security monitoring audits need an explicit check: “what fraction of outbound TLS traffic carries an ECH extension, and do our detection rules handle it correctly?”
Assuming ECH adoption will be gradual. It was not. Cloudflare’s 2023 default-on rollout changed the threat landscape for every enterprise that uses SNI-based monitoring without a coordinated notice to security teams. The assumption that “we’ll update our tools when ECH becomes common” was overtaken by events before most teams made it to the top of their backlog.