Articles / Security

  • tls
  • traffic-analysis
  • privacy
  • networking
  • encryption
  • security

TLS Hides Your Data, Not Your Behaviour

You cannot read a single byte of someone else's HTTPS traffic, and you often don't need to. Sit on a coffee shop's wifi with Wireshark open and watch another laptop talk to the internet. The TLS handshake goes past in the clear, and you can see the server name, count the packets, measure their sizes and time the gaps between them.

Do that for a few minutes and you will often know which website they are on and roughly what they are doing on it. Sometimes you can even tell how long the password was that someone just typed into an SSH session two tables over. None of this requires breaking TLS. It requires not needing to.

This is traffic analysis: inferring content from the shape of a conversation rather than its words. TLS was designed to stop the first kind of eavesdropping. It was never designed to stop the second, and for a long time nobody particularly worried, because the shape of traffic seemed too coarse to matter. That turned out to be wrong.

What TLS 1.3 still leaves in the clear

A TLS 1.3 connection encrypts the HTTP request and response, headers included. But several things sit outside that envelope, either by protocol necessity or by common deployment choice:

  • The destination IP address, always, because routers need it to deliver packets.
  • The Server Name Indication (SNI) field in the ClientHello, sent unencrypted so the server knows which certificate to present before the handshake has established any shared secret. If you connect to a shared-hosting edge like Cloudflare's, the SNI is frequently the only way anyone can tell which of the thousands of sites behind that IP you actually wanted.
  • The TLS version, cipher suites offered and extension list, which together form a fingerprint (commonly called JA3 or JA4) that identifies the client software, sometimes down to the exact library and version, regardless of what the user agent header claims.
  • Packet sizes, direction and timing. TLS records get padded to a small degree in TLS 1.3, but not enough to erase the underlying shape of a request-response exchange.

Individually these look like scraps. Together they are a surprisingly rich side channel, because the shape of a network conversation correlates with what generated it far more than intuition suggests.

A page load has a shape, and the shape is a fingerprint

Load a specific Wikipedia article and your browser fetches the HTML, then a fairly deterministic set of images, stylesheets and scripts. Each one has a size that varies little between requests. The sequence of packet sizes and directions from that page load is close to a fingerprint of the page itself, even though every byte was encrypted end to end.

This is the basis of website fingerprinting attacks, a research area that has been particularly active around Tor. That's no accident: Tor hides destination IPs and SNI but cannot hide packet timing and size without paying a serious performance cost.

Given a closed set of candidate sites, a classifier trained on packet-size sequences can identify which one a user visited. The accuracy is good enough to be a genuine deanonymisation risk, not just an academic curiosity. The open-world case, where the visited site might be anything, is harder, but not nearly as hard as you would want if you were relying on encryption alone.

The idea in miniature

You do not need machine learning to get a feel for why this works. Bucket outgoing and incoming packet sizes from a capture into a small feature vector, and compare it against known fingerprints:

package main

import "fmt"

// bucket assigns a packet to a coarse size band, direction-signed.
// Positive length means outbound, negative means inbound.
func bucket(length int) int {
	switch {
	case length < 100:
		return 0
	case length < 500:
		return 1
	case length < 1200:
		return 2
	default:
		return 3
	}
}

// fingerprint turns a sequence of signed packet lengths into a
// histogram of (direction, size-band) counts, a crude but real
// feature vector used as a first step in traffic classification.
func fingerprint(packets []int) map[[2]int]int {
	counts := make(map[[2]int]int)
	for _, p := range packets {
		dir := 0
		if p < 0 {
			dir = 1
			p = -p
		}
		key := [2]int{dir, bucket(p)}
		counts[key]++
	}
	return counts
}

func main() {
	capture := []int{517, -1400, -1400, 220, -890, 517, -1400}
	fmt.Println(fingerprint(capture))
}

Real fingerprinting classifiers use richer features (ordering, timing gaps, cumulative sums) and proper machine learning rather than a histogram. The principle is the same one your eye would notice on a packet size graph: distinct pages have distinct shapes, and shapes survive encryption.

Streaming video gives away which title you're watching

Adaptive bitrate streaming makes this worse in an interesting way. Netflix, YouTube and similar services fetch video in short segments, each encoded at a bitrate the player chooses based on measured throughput. A scene with a lot of motion needs more bits than a static shot, so the sequence of segment sizes is closely tied to the actual content.

Researchers have shown that this segment-size sequence is often distinctive enough to identify which specific title, and even which point in it, someone is watching. That comes purely from encrypted traffic metadata, without decrypting anything or ever seeing the video.

Actually, this bit is the one I find properly delightful. Nobody typed that into a form. It leaked out of the video compressor's own reaction to the content, via the wrapper of packet sizes TLS could not smooth away. The codec snitched.

DNS leaks the hostname before TLS even starts

Most connections start with a DNS lookup, and plain DNS is unencrypted UDP. Even if SNI were perfectly hidden, an observer anywhere between you and your resolver sees exactly which hostname you resolved, seconds before you connect to it.

DNS-over-HTTPS and DNS-over-TLS close that specific leak, but they bring their own traffic-analysis surface. DoH queries have their own size and timing patterns, and a handful of studies have shown that individual DoH resolvers, and sometimes individual query types, can be fingerprinted from the encrypted DoH stream itself. Moving a leak behind TLS reduces it. It does not automatically eliminate it.

Encrypted Client Hello fixes SNI, where it's deployed

The direct fix for SNI leakage is Encrypted Client Hello (ECH). It wraps the real ClientHello, including the SNI, inside an outer, encrypted one, using a public key fetched via DNS beforehand. Where it is deployed, an observer sees only that a connection was made to a shared front-end IP address, not which of the sites behind it was requested.

Deployment is the catch. Cloudflare has offered ECH support for some time, and both Firefox and Chromium-based browsers have shipped support in various forms. But adoption depends on two things:

  • the server operator publishing the right DNS records
  • the client and server agreeing on the mechanism

So plenty of ordinary web traffic still sends SNI in the clear. It's also worth being honest about what ECH does not fix. It hides which of several co-hosted sites you visited, but the destination IP is still visible. If a site is not behind shared infrastructure, the IP alone tells the observer as much as the SNI would have.

QUIC changed the wrapper, not the physics

HTTP/3 over QUIC encrypts more of the transport header than TCP-based TLS ever could, including things like sequence numbers that used to be visible in the clear. That is a genuine improvement. But QUIC packets still have observable sizes, directions and timing.

Quick detour on the connection ID, because it's a sneaky one. QUIC uses it to route packets during a migration, and it's a fixed value that persists across a session unless it is rotated deliberately. An implementation that doesn't rotate it has handed observers a decent tracking token.

Back to the main point: moving to a newer transport tends to close specific leaks rather than the underlying class of attack. Packet-level metadata is a property of moving bits over a network, not of any particular protocol's design mistakes.

Padding works, but someone has to pay for it

The honest defence against traffic analysis is to make the shape of your traffic boring. That means a request for a tiny API response has to look the same on the wire as a request for a large one. The tools are:

  • pad packets to fixed sizes
  • insert chaff traffic during idle periods
  • normalise timing

Tor does some of this with fixed-size cells. Some VPN and messaging protocols pad to a small set of bucket sizes for exactly this reason. It works, but it is not free: padding wastes bandwidth, and normalising timing adds latency.

So most mainstream protocols do it partially or not at all. The people deploying them are optimising for speed and cost, not resistance to a statistical classifier nobody using the service will ever hear about.

Metadata was never "just metadata"

Which is really the point. TLS was a genuinely enormous improvement in what an on-path observer (an ISP, a coffee shop, a state-level network operator) can extract from your traffic. It just was never the whole answer.

Metadata retained under something like the UK's data retention framework was never "just metadata" in the reassuring sense that phrase implies. If the shape of a connection can tell you which Wikipedia article someone read or which episode they watched, the shape was never really separate from the content. It was just content in a format that took a bit more work to read.