Android 17 Encrypted Client Hello Still Leaves Hostnames on the Wire
Open a pcap of a TLS 1.3 handshake from almost any year since 2018 and the hostname is sitting in the ClientHello in cleartext. Server Name Indication was never a secret. It was a routing hint that every middlebox on the path could read, log, and filter. Android 17 (API 37) now turns Encrypted Client Hello on by default for apps that use a compatible networking stack. The OS change shipped. The privacy guarantee did not.
ECH, standardized as RFC 9849 in March 2026, encrypts the inner ClientHello, including the true SNI, under a public key the client learned from DNS. The network still sees an outer ClientHello. The network still sees an IP address. If that IP belongs to one origin, the hostname you just hid is a reverse lookup away.
Two ClientHellos and a type 65 record
ECH does not encrypt the whole handshake. It splits ClientHello into an outer message and an inner message. The outer message is what a passive observer on the wire can parse. It carries a public name, often a CDN fronting name such as cloudflare-ech.com, plus HPKE parameters. The inner message holds the real SNI, ALPN, and the rest of the client’s offer. Only the server that holds the matching private key can decrypt it.
The public key does not come from the TLS server during the handshake. It comes from DNS, in an HTTPS record (RR type 65), inside the ech= parameter. If that record is missing, a client in enabled mode does not fail closed. Android’s documented default is to enforce ECH when a config is present and to send ECH GREASE otherwise: a dummy extension that looks like ECH so middleboxes cannot fingerprint the real thing by absence. GREASE is camouflage. It is not encryption.
You can inspect the config without a browser. Against Cloudflare’s crypto endpoint the HTTPS record currently publishes an ECH blob whose public name is visible even before you decode the rest of the HPKE key:
dig +short HTTPS crypto.cloudflare.com
python3 -c "import base64,re,sys; d=base64.b64decode(sys.argv[1]+'=='); print(re.findall(rb'[a-z0-9.-]{6,}\.[a-z]{2,}', d))" \
AEX+DQBBUQAgACB1f6K6NOmG6VOalBR3tD3OwnueuspFMPY4fH5EZTmeTwAEAAEAAQASY2xvdWRmbGFyZS1lY2guY29tAAA=
That public name is the outer SNI. A resolver, a corporate proxy, or a national filter that only wanted to know this handset talked to Cloudflare already has the answer. Sites that are not fronted by a large shared anycast pool leak more. A dedicated origin IP plus a successful TCP handshake is a hostname with extra steps.
ECH encrypts the name inside the handshake. It does not encrypt the address on the wire.
What a packet capture still tells you
Treat ECH as a SNI confidentiality control, then write down the remaining signals before you change a threat model. Operators hit these in production.
- The destination IP, and whether that IP is shared by thousands of names or by one.
- The outer public name in ClientHelloOuter, which RFC 9849 requires the client-facing server to present a certificate for.
- The HTTPS DNS lookup that fetched the ECH config. Strip or forge that record and the client falls back to GREASE plus cleartext SNI, or it retries with a server-supplied config list.
- Application traffic after the handshake. SNI encryption does not hide a unique TLS fingerprint or packet sizes on a quiet link.
Retry is the part operators miss. If the client-facing server cannot decrypt the inner hello, RFC 9849 lets it return retry configurations in EncryptedExtensions. The client is then expected to accept a fresh ECHConfigList and try again. That is how key rotation works without a flag day. It is also how a path that can interfere with DNS, or with the outer handshake, can keep a client from ever installing a working config. ECH assumes the client obtained an authentic HTTPS record. Lie in DNS and the inner hello is encrypted to the wrong key, which looks like the server does not speak ECH until the retry path says otherwise.
Android 17 exposes this as a Network Security Config choice, not as a user toggle in Settings. Apps targeting API 37 inherit enabled mode. An app can set the mode to disabled per domain, which drops both real ECH and GREASE. Pinning, custom TLS stacks, and older WebView builds ignore the platform default. Shipping a privacy claim in the Play listing because the OS version is 17 is cargo cult. Check the library.
If you run an origin, publish an HTTPS record with a current ECH config before you tell clients the name is gone from the wire. Rotate the HPKE key on a schedule and keep the previous config in the record long enough for caches to die. If you terminate TLS on a single VPS with a unique IPv4, ECH still helps against a classifier that keys only on SNI strings. It does nothing against a classifier that keys on your address. Shared-edge deployments are the topology ECH was designed around. A lonely A record is not that topology.
Before you quote Android 17 in a privacy review, run the dig against your own names. If there is no ech parameter, the platform will GREASE and then send the real SNI the way it always has. The handshake looks modern. The hostname is still in the pcap.