DEV Community

Remdore
Remdore

Posted on AI-assisted

The hostname you visit over HTTPS is sent in plain text

I had assumed, without ever checking, that the padlock meant nobody on the network could tell which site I was visiting. The connection is encrypted end to end, so surely the destination is part of what gets hidden. It is not. The name of the site you are asking for travels across the network in plain readable text, at the very start of every HTTPS connection, and I wanted to see it with my own eyes rather than take anyone's word for it.

So I captured the first thing my machine sends when it opens an HTTPS connection, the TLS ClientHello, and went looking for the hostname in the bytes. It was not hard to find.

The hostname, sitting in the packet

Here is the ClientHello openssl sent when I asked it to connect with the server name secret.wikipedia.example. This is the raw handshake, dumped as hex before any key exchange has happened:

01 00 00 d7 03 03 dd 4f 36 06 ...
... 00 00 1d 00 1b 00 00 18 73 65 63 72 65 74 2e 77
69 6b 69 70 65 64 69 61 2e 65 78 61 6d 70 6c 65 ...
Enter fullscreen mode Exit fullscreen mode

Those bytes from 73 onward are ASCII. 73 65 63 72 65 74 is "secret", and it runs straight through to "secret.wikipedia.example". The hostname is not hashed, not encrypted, not obscured. It is the literal string, sitting in the clear inside a structure called the Server Name Indication extension.

The handful of bytes in front of the name are just the wrapping, and once you have read them once the whole thing stops being mysterious. The pair 00 00 is announcing an extension of type zero, which is the number TLS assigns to server_name. After that comes a length, 00 1d, twenty-nine bytes, then another length for the list inside it, then a single 00 marking the entry as a host_name rather than anything else, then the length of the name itself, 00 18, which is twenty-four. Count twenty-four bytes on and you have run exactly to the end of "secret.wikipedia.example". I wrote a few lines to do that walk so I would not be counting nibbles by hand, and it landed where the hex said it would:

server_name extension found at offset 104
host_name length 24 -> 'secret.wikipedia.example'
Enter fullscreen mode Exit fullscreen mode

Notice what did not happen there. Nothing was decrypted, and nothing could have been, because the key exchange is still several messages away when this is sent. The name goes first, and it goes in the open, and that is the protocol working as intended rather than failing.

Why it is sent in the clear

This is not a bug or an oversight, which is the part that took me a moment to accept. The server needs to know which certificate to present, and a single IP address today serves hundreds or thousands of different sites behind one TLS terminator. The server has to be told which site you want before it can hand you the right certificate and set up encryption for it. The SNI is how it is told. The chicken-and-egg problem, that you cannot encrypt the request for a site with that site's key before you have asked for the site, is real, and for twenty-odd years the answer has simply been to send the name unprotected.

A passive listener needs no key and no handshake

To make sure I was not being fooled by openssl showing me its own internal state, I wrote a listener that does nothing but read. It accepts a connection, reads the first packet, and pulls the hostname out. It never completes the handshake, never has a private key, never decrypts anything. It is about fifteen lines. Pointed a client at it asking for login.mybank.example.com:

read 1566 bytes, first byte 0x16 (0x16 = TLS handshake)
hostname the client is asking for: 'login.mybank.example.com'
bytes decrypted to get it: 0
Enter fullscreen mode Exit fullscreen mode

That is the entire capability a corporate proxy, a national firewall, or your ISP needs to know every site you visit: read the first packet, skip to offset-something, read the length-prefixed string. The connection contents stay perfectly encrypted and completely beside the point. This is exactly how filtering by domain works without anyone breaking the encryption, and it is why "but it's HTTPS" is not the privacy argument people think it is. They are not reading your traffic. They are reading the label on the envelope, and you wrote it in block capitals.

TLS 1.3 does not fix it

I genuinely expected TLS 1.3 to have closed this, because 1.3 encrypts far more of the handshake than 1.2 did, the certificate included. So I ran the same listener against a 1.3-only client asking for tls13.secret.example:

read 1489 bytes, first byte 0x16
hostname the client is asking for: 'tls13.secret.example'
bytes decrypted to get it: 0
Enter fullscreen mode Exit fullscreen mode

Still there, still readable, still zero decryption. TLS 1.3 encrypts the server's certificate so a watcher cannot see which cert came back, but the ClientHello, the client's opening message, is by definition sent before any keys exist, and the SNI lives in the ClientHello. Moving it was left to a separate mechanism.

The actual fix, and where it stands

That separate mechanism is Encrypted Client Hello, ECH, and it works by solving the chicken-and-egg problem with DNS. The server publishes a public key ahead of time, in a DNS record, and the client fetches it before connecting and uses it to encrypt the real ClientHello, hostname and all, inside an outer one that carries only a generic cover name.

This is not theoretical, there are real ECH configs published right now. I pulled the DNS HTTPS record for crypto.cloudflare.com and decoded the ech= blob in it:

ECHConfig version : 0xfe0d  (the ECH standard)
config_id         : 192
KEM id            : 0x0020  (X25519)
public key        : 32 bytes, 06dbbb64d9e36721...
public_name (seen on the wire instead): 'cloudflare-ech.com'
Enter fullscreen mode Exit fullscreen mode

That is the whole idea in one record. The 32-byte X25519 key is what the client encrypts the true hostname with, and cloudflare-ech.com is the public_name, the decoy that a passive listener would see in the SNI instead of wherever you were actually going. Everyone connecting to an ECH-protected Cloudflare site looks, from the network, like they are visiting the same single cover name.

Two things stopped me short of showing a working encrypted handshake, and both are the honest state of ECH rather than a limitation of my afternoon. The openssl I have, 3.5, has no ECH support in its s_client at all, so I could decode the published config but could not make a client use it. And adoption is patchy even at the vendor that invented it: the same DNS lookup for cloudflare.com itself, the apex, returned an HTTPS record with no ech= field. The test host has it, the flagship domain does not.

There is also a deeper catch worth naming. ECH moves the secret out of the TLS handshake, but it moves it into DNS, because the client has to look up that public key by name first. If that DNS query goes out in plaintext, which is still the default on most machines, the watcher just reads the hostname from the DNS request instead and nothing has been gained. ECH only buys you privacy when your DNS is also encrypted, so it is really one piece of a set, not a switch you flip.

What I got wrong on the way

My first plan for proving the hostname was on the wire, and not just inside openssl, was to capture the packet with tcpdump and grep the raw bytes for it. That failed immediately, because tcpdump needs privileges to put the interface in promiscuous mode and this machine had no passwordless way to give it them.

The fix turned out to be better than the plan. Instead of sniffing my own traffic from above, I wrote the fifteen-line listener and pointed the client at it, so the "attacker" was just an ordinary TCP server reading its input. That is not a weaker demonstration than a packet capture, it is a truer one, because a real SNI-based filter is not doing exotic packet capture either. It is a box in the path reading the first bytes of the stream, which is precisely what my little server was. The privilege I could not get turned out to be privilege the demonstration never needed.

See it yourself

There is nothing to install for this. If you have openssl you can watch your own machine announce its destination, right now:

printf 'Q\n' | openssl s_client -connect example.com:443 \
  -servername anything.you.like -msg 2>&1 | sed -n '/ClientHello/,/</p'
Enter fullscreen mode Exit fullscreen mode

Look for the 00 00 extension in the hex, step past the length bytes the same way I did above, and whatever name you passed will be sitting there in ASCII. And if you want to know whether a given site has bothered to publish an ECH config yet, the answer is in its DNS HTTPS record:

curl -s -H 'accept: application/dns-json' \
  'https://cloudflare-dns.com/dns-query?name=crypto.cloudflare.com&type=HTTPS' \
  | grep -o 'ech=[A-Za-z0-9+/=]*'
Enter fullscreen mode Exit fullscreen mode

An ech= blob coming back means that host is able to hide its name from the network. Try it on a few of the sites you actually use, and most of them will hand you back nothing at all.

What to take away

The encryption in HTTPS is real and it is doing its job: the contents of your connection are private. But privacy of contents and privacy of destination are two different promises, and only the first one has been kept for most of the web's history. The name of every site you visit is still, today, sent in a form any box on the path can read without doing any work. ECH is the fix, it exists, and the config is sitting in DNS waiting, but it needs encrypted DNS beside it and it is not yet switched on for most of the places you go. Until it is, assume the network knows the guest list even though it cannot hear the conversation.


Follow-up: I turned this into a tool you can run against any domain, and surveyed the top 1000 sites with it. Only 10 hide the hostname.

Top comments (0)