Firefox has two TLS fingerprints, and they disagree
Part three: what I found when I pointed a packet capture at my own laptop and read everyone's QUIC handshakes.
This is the third of three. The first was about copying a Firefox ClientHello byte for byte; the second about Chrome, which draws a new extension order every connection.
I had a working client at this point and I would happily have stopped. The reason I did not is that a growing share of real browsing is HTTP/3, and a client that speaks HTTP/2 while claiming to be a browser that prefers HTTP/3 is announcing itself just as loudly as one with the wrong cipher list. If I was going to claim the profile was Firefox, it had to be Firefox on the transport Firefox actually picks.
QUIC carries the TLS handshake inside packets encrypted with a key derived from a salt printed in the RFC, so anyone can read anyone's ClientHello. I wrote a decoder and pointed it at my own traffic.
Firefox keeps a fixed extension order over TCP and shuffles it over QUIC. Same browser, same version. The two transports are different codebases and they have different habits. I also got three things wrong reading the capture, which is most of what this post is about.
First problem: I could not see anything
Everything in part one came from running a TLS server myself and letting the browser connect to it. That does not work for QUIC. It is UDP, the handshake is encrypted from the first byte, and standing up a QUIC server with a certificate a browser will accept is a much bigger errand than standing up a TLS one.
Then I read RFC 9001 and found this, which I still think is the most surprising thing in the whole protocol: the keys protecting an Initial packet are derived from a salt published in the RFC itself. It is a fixed constant. Anyone can compute them.
That is deliberate, not a flaw. Those keys exist so that off-path attackers cannot inject packets and so middleboxes cannot parse the handshake fields and ossify them, not to keep the handshake secret — a ClientHello is public in TLS over TCP too. But it has a consequence that took a second to land: I do not need a server at all. I can read the handshakes already flying past.
Reading an Initial packet
It is about an afternoon of work, in this order:
- Take the connection's destination id out of the clear part of the header, mix it with the published salt, derive the initial secrets.
- Undo header protection. The packet number is masked with a mask derived from a sample of the ciphertext — and you need the packet number to know where the ciphertext starts. It unwinds in a fixed order, but it is the one genuinely fiddly part.
- Decrypt the payload, walk the frames, find the CRYPTO frames and reassemble them by offset, because the hello is usually split across several packets.
- Out comes an ordinary ClientHello, the same structure as part one.
I deliberately wrote this against nothing but a crypto primitives library. There is a QUIC library in the project for the sending side, and there is a test that asserts the decoder still imports with that library hidden. A measuring instrument that depends on an implementation inherits that implementation's opinions, and I wanted to be able to catch the library being wrong.
What I actually captured
Here is the setup, because it matters for everything below. I ran a capture filtered to UDP port 443 on my own Windows laptop and browsed normally for a few minutes with Firefox. Not a lab, not a clean room — my actual machine, with whatever else it had open.
231 UDP datagrams
12 connections with long headers
7 ClientHellos recovered
Seven hellos. I printed their extension lists and the orders were all different. Seven connections, seven orders, no repeats. I wrote down that Firefox shuffles over QUIC and very nearly wrote down something stronger — that the extension set varies too, because the sets did not all match either.
The capture was two browsers
The set not matching is what saved me. One of the seven, to
www.bing.com, had eleven extensions where the others had
fifteen. I went to look at why, expecting a truncated reassembly, and found
something else: it was missing every marker I had been using since part one
to tell Firefox's TLS stack from Chrome's —
renegotiation_info, delegated_credentials,
status_request, extended_master_secret,
record_size_limit. Its QUIC transport parameters were
google_connection_options, initial_rtt,
google_version.
That is not Firefox. It is something Chromium-shaped, and on that machine it was the Chrome I had left open in another window.
So "7 orders across 7 connections" was 6 from one browser and 1 from another, and I had been one step from publishing it.
Obvious in hindsight. A capture of UDP 443 contains whatever the machine was doing, and a machine is always doing several things. It was not obvious while staring at the output, because the output was a list of orders and the thing that distinguished the clients was in a column I had not printed.
The tool now groups connections by extension set before it says anything about order, so a second client shows up as a second client rather than as noise inside the first one's measurement.
Six orders from one browser
With the stray connection removed, the six remaining ones all carry an identical set of fifteen extensions:
0 5 10 13 16 23 27 28 34 43 45 51 57 65037 65281
…in a different order every time. Six connections, six orders, no repeats.
Two never move: quic_transport_parameters then
encrypted_client_hello, pinned at the end, in that order, in
all six.
And one of the six goes to incoming.telemetry.mozilla.org,
which nothing but Firefox contacts, while the other five share its exact
transport parameter sequence. So this is one client, it is Firefox, and it
shuffles.
Which is the opposite of what it does over TCP, where the order is fixed enough that I built an entire byte-identity test suite on it. The explanation is unglamorous: Firefox's TLS-over-TCP comes from NSS, written in C, and its QUIC comes from neqo, written in Rust. They are different programs by different people. There is no reason they should agree, and they do not.
The fixture I had captured an hour earlier was one sample of a distribution, not a reference, and no test may assert its order. What a test can assert is the relationship between samples: same set every time, different order every time, exactly two pinned at the end. That is what it holds now.
Three other things QUIC changes
Three cipher suites instead of fifteen.
0x1301 0x1303 0x1302, the TLS 1.3 ones only, because QUIC
forbids TLS 1.2 outright. This is the cheapest QUIC tell there is. A client
offering the TLS 1.2 suites over QUIC has not thought about it for one
second, and it costs a server nothing to check.
Three extensions disappear.
ec_point_formats, session_ticket and
signed_certificate_timestamp. The first two follow from TLS
1.2 being unavailable. The third does not obviously follow from anything
and I have it written down as an observation rather than an explanation.
The transport parameters are not in ascending order.
reset_stream_at (0x1d) and a GREASE id come before
max_datagram_frame_size (0x20). The QUIC library I was using
as a reference sorts them; Firefox does not. Which means
quic_transport_parameters is a whole second fingerprint
surface that only exists on this transport: which parameters you send, in
what order, with what values.
Then I made the same mistake again, one hour later
I wrote that Firefox draws its GREASE transport parameter per connection,
"with an eight-byte one observed". It does not. 0xff02de1a
appears in all six Firefox connections. The eight-byte one belonged to the
Chrome connection.
I had mixed the two clients together again, in exactly the way I had just finished modifying the tool to prevent.
The constant turned out to be more interesting than the variation would
have been. A value that never changes is not greasing anything, and this
one carries the varint 1000, which is a plausible
min_ack_delay. The registered codepoint for that is
0xff04de1b, one of a family of 0xff0?de1? draft
codepoints — so this is most likely an old draft of it that happens
to satisfy QUIC's reserved pattern by coincidence. The tool still labels it
GREASE, because the formula says so, and labels it unidentified, because it
is.
I should add that I wrote the transport parameter table from memory first,
and it was wrong where it mattered: 0x1d is
reset_stream_at, not unknown, and I had
min_ack_delay at the wrong codepoint entirely. One fetch of
the IANA registry settled it. The only reason I checked at all is that the
arithmetic on the GREASE pattern was cheap enough to do, and it disagreed
with me.
The theory that was too good
One host in the capture, cloudflare-ech.com, has Firefox's
exact extension set and Firefox's pinned tail, but sends
three transport parameters where the other five send
fourteen. No flow control credit at all. That is not a working set; a
connection configured like that should not function.
I had a lovely explanation ready within about a minute. The host publishes a real ECH configuration. What I captured is therefore the outer decoy hello, and the real parameters are encrypted in the inner one. It fitted the hostname, it fitted the missing parameters, and it would have been the first real ECH I had seen in the whole project.
Its ECH extension is 282 bytes. The same greased length as every other connection in the capture. There is no real ECH anywhere in there.
That guess was plausible, fitted the evidence I had, and was simply wrong. It is the argument for printing the column rather than reasoning about what it would contain — the number that killed it took no thought at all, it just had to be on screen.
What held up instead was arithmetic. Three parameters measure about 26 bytes on the wire against 86 for fourteen, a difference of 60. Two of the hosts have server names of equal length and hellos of 1,900 and 1,840 bytes. Sixty, predicted from the parameter count alone and confirmed by a measurement that does not depend on the decoder that produced the count. So the three parameters are real and the decoding is fine.
What Firefox is doing differently with that one host, I do not know. It is one connection in six and nothing rests on it. If anyone recognises this behaviour, I would like to hear about it.
Asking a stranger
Everything above was read off my own capture with my own parser, and checked against my own QUIC server. Which is the weakness of this whole post: I wrote the thing, I wrote the ruler, and I wrote the examiner.
thumbprint.me serves HTTP/3 and
reflects the caller's fingerprints back as JSON, which makes it the first
outside party that could be asked the QUIC question at all. Over TCP it
reports t13d1517h2_…. Over QUIC, from the same client, the
same machine, the same minute:
q13d0315h3_55b375c5d22e_dc5437974b47
ciphers: 1301,1302,1303
Read the prefix: q for QUIC where TCP said t,
TLS 1.3, SNI present, three cipher suites where the TCP
arm sends fifteen, fifteen extensions, ALPN h3. The three are
exactly the TLS 1.3 suites. Until this run, "QUIC forbids TLS 1.2, so the
list collapses to three" was a claim resting on my own parser reading my
own bytes. Now a stranger says it.
It also reports how many of its own recorded Firefox sessions share each
of our fingerprints: 103 for the QUIC ClientHello, 18 for the transport
parameters. Which is the number I would have least expected to be
non-zero, because quic_transport_parameters is the surface I
spent the most of this post being wrong about.
One honest residual, since it is the kind of thing this post has been
about. We send Alt-Used over HTTP/3 because that is what the
capture contained — but Firefox sends it precisely because it
reached h3 the long way, via Alt-Svc on an earlier TCP
connection. This client dials QUIC directly, so it announces having used
an alternative service that was never offered to it. The server knows
whether it ever advertised h3 to you. Every individual header correct, the
combination describing something that did not happen.
A server that cares which transport you used
Fingerprint services tell you what you look like. They do not tell you
whether it matters. For that you need somebody who did not agree to be
measured, so: indeed.com, four interleaved rounds, three arms
on the same address within the same minutes.
pybrws over TCP ...... 0/4 served
pybrws over QUIC ..... 4/4 served
httpx over TCP ....... 0/4 served
Same library, same profile family, same machine. The transport flipped the outcome.
My first instinct was that the QUIC arm had simply been redirected
somewhere unprotected, which would make the whole thing a bookkeeping
error. So, one direct fetch of mx.indeed.com, no redirect
following, bodies kept:
over TCP 403 "Security Check" 27,708 bytes
over QUIC 200 the actual page 591,241 bytes
Same URL. One of them is a refusal, the other is half a megabyte of job listings. That killed the alternative explanation and the finding survived.
What it does not establish is that QUIC is the reason. The QUIC profile differs from the TCP one in far more than transport: three cipher suites instead of fifteen, a drawn extension order instead of a fixed one, three extensions missing, its own header set. "The transport" here means the entire presentation that comes with it, and I cannot decompose that from outside.
Nor do I know whose doing the TCP refusal is. The test that would settle
it needs no code at all: a real Firefox with
network.http.http3.enable set to false, pointed at indeed. If
a genuine browser over TCP also gets the Security Check, then indeed
challenges TCP broadly and my fingerprint was never the reason. I have not
run it. It is the most valuable thing left on the list and it is one
checkbox.
What I would take from this
If you are building an imitation: a browser is not one fingerprint, it is one per transport, and the transports do not share code so they do not share habits. Test your HTTP/3 path separately or you will ship a client whose two halves describe two different programs. Mine did, for about a day.
If you are on the detection side: QUIC hands you a second independent
signal, readable without any key, and
quic_transport_parameters is a surface most imitation tools
have not reached yet. The TLS 1.2 cipher suite check alone catches anything
careless.
And for me, the real lesson of this post is smaller and duller than the finding. Every mistake above was caught by data that was already in the capture and was not being displayed. Not by cleverness, not by a better theory. By printing one more column.
From the repository
pybrws/quic.py— Initial decryption, header unprotection, CRYPTO reassembly, the IANA parameter tablepybrws/quic_impersonate.py— the sending sidefixtures/reference-firefox-157-quic.hex— one sample of the distribution, explicitly not a reference ordertests/test_read_quic.py— asserts the relationship between samples, not the ordertests/test_quic.py— includes the import test with the QUIC library hidden