Syllabus Lesson 105 of 156 · Network fingerprints
Network fingerprints

HTTP/2 and header fingerprints

Why it matters Even with an identical TLS fingerprint, the very first HTTP/2 messages a browser sends, and the order of its headers, differ between browsers. Detection systems use this to check whether a client really is the browser its user agent claims. You will tell three simulated browsers apart, and catch a script pretending to be one.

Two people can wear the same uniform and still be told apart by how they fill in a form: the order they write things, which boxes they tick first. HTTP/2 openings are a form every browser fills in its own way.

HTTP/2: a newer version of HTTP that browsers use for most HTTPS sites today, alongside the even newer HTTP/3. It sends requests as small binary messages called frames over one connection, and needs ALPN h2 in the handshake.

Frame: one HTTP/2 message. A SETTINGS frame announces limits, a WINDOW_UPDATE frame says how much data the browser is ready to receive, and a HEADERS frame carries a request's headers.

What the opening reveals

  • SETTINGS. A list of (id, value) pairs, such as (1, 32768) for a table size or (4, 4194304) for a window size. Which settings appear, their values and their order are chosen by the browser's code.
  • WINDOW_UPDATE. One number: how much extra data the browser is willing to receive.
  • Priority. How the browser says which requests matter most. Browsers do this differently.
  • Pseudo-header order. Every HTTP/2 request starts with four special headers: :method, :authority (the site), :scheme and :path. The protocol does not fix their order, so each browser picks one.
  • Header order. The ordinary headers, such as user-agent and accept, also come in a browser-specific order.

One line to compare

A common way to write this down is one text line with four parts separated by |:

1:32768;3:250;4:4194304;6:131072|10420225|0|m,s,a,p
 settings (id:value;...)         window  prio pseudo-header initials

The initials are the first letter after the colon: :method becomes m.

Header order and casing: hiding in plain sight

In HTTP/2, header names must be sent in lower case. In the older HTTP/1.1, the case was up to the sender, so User-Agent versus user-agent could itself tell clients apart. Order works in both. In our world, Brook and Cobalt are built on the same fictional engine: their HTTP/2 settings line is identical, and only their header order differs. So you need both to tell them apart.

User agent claim: the user-agent header is just text the client writes about itself. Any program can copy it. The handshake and HTTP/2 details are harder to fake by accident, so detectors compare them with the claim.

The practice world gives you h2_connection(name, seed) for Brook, Cobalt and Fennel, plus "Toolkit": a fictional script that copies Brook's user agent but opens HTTP/2 its own way. Each connection is a dict with settings, window_update, priority, pseudo_headers, headers (a list of (name, value) pairs) and user_agent. All values are fictional.

To join pairs into text: ";".join("%d:%d" % (k, v) for k, v in pairs). To take the name before the slash in "Brook/31.0": ua.split("/")[0].

In other languages

Building the settings part of the line elsewhere:

// JavaScript
const settings = conn.settings.map(([k, v]) => `${k}:${v}`).join(";");
const pseudo = conn.pseudo_headers.map(h => h[1]).join(",");
const line = [settings, conn.window_update, conn.priority, pseudo].join("|");
// Go
parts := []string{}
for _, s := range conn.Settings {
    parts = append(parts, fmt.Sprintf("%d:%d", s.ID, s.Value))
}
settings := strings.Join(parts, ";")
// C#
var settings = string.Join(";", conn.Settings.Select(s => $"{s.Id}:{s.Value}"));

The defence

For ordinary users, the protection is the same as for TLS: use a common, current browser, so your HTTP/2 opening matches millions of others. For site builders: if you use these signals to fight fraud, use them to add friction (such as a second check), never as proof of who a person is, and never to profile people.

Recap

  • A browser's HTTP/2 opening (settings, window size, priority, pseudo-header order) is a fingerprint.
  • Header order, and in HTTP/1.1 header casing, adds more.
  • Two browsers on the same engine can share settings and differ only in header order.
  • Detectors compare these signals with the user agent claim to spot clients that are not what they say.
Your turn

Write six short functions:

  1. h2_fingerprint(conn): the settings as id:value joined by ;, then |, the window_update, |, the priority, |, and the pseudo-header initials (the character after the colon) joined by ,.
  2. header_order(conn): the header names, in order, joined by ,.
  3. full_fingerprint(conn): h2_fingerprint(conn) + "|" + header_order(conn).
  4. learn(training): from a list of (name, conn) pairs, return a dict mapping each full_fingerprint to its name.
  5. identify(conn, known): the name in known for this connection's full fingerprint, or "unknown".
  6. claim_matches(conn, known): True if identify gives the same name as the user agent claims (the text before / in conn["user_agent"]), otherwise False.
The report at the bottom is written for you.

Spotted a problem in this lesson? Report it

Code · runs in your browser
Output