← pybrws

Impersonating Chrome, which never sends the same thing twice

Part two: the profile format that worked perfectly for one browser and fell apart on the second.

Ernesto Pérez Costa · October 2026 · ~10 min

This continues the previous post. If you have not read it, the short version is: a server can identify your HTTP client from the shape of its TLS handshake before any HTTP happens, and I spent a few days reproducing Firefox 157's handshake in Python until it matched a capture byte for byte.

Firefox was the easy one, although I did not know that at the time. The reason I moved on to Chrome is boring and practical: Firefox is a few percent of the web. If a site decides to get strict about Firefox-shaped clients, a scraper pretending to be one has nowhere to go. Most of the world looks like Chrome, so a Chrome profile is the one that actually gets used.

TL;DR

Chrome draws a new TLS extension order on every single connection, with one GREASE extension pinned at each end. My Firefox profile was a list of constants, which is the wrong shape for that, so the design had to change.

It now matches a real Chrome 155 on every fingerprint I can measure. And one site still refuses it, which cost me a day and turned out not to be about fingerprints at all.

Six captures, six different hellos

Same procedure as before: local TLS server, self-signed certificate, open it in Chrome, dump the raw bytes. I did it six times because I had done it five times for Firefox and five had been enough.

Then I diffed capture one against capture two expecting the handful of fields that have to change, and got a wall of differences instead.

Not the payloads. The extensions were in a different order.

Six captures, six orders, no repeats. There is no "Chrome's extension order" to copy. The thing I had built the entire Firefox profile around — a fixed sequence you can assert byte-identity against — does not exist here.

Except for two of them. Positions one and twenty never move, and the extensions sitting in them are not real extensions at all.

The two that never move

They are GREASE, and if you have not run into it: GREASE is RFC 8701, "Generate Random Extensions And Sustain Extensibility". There are sixteen reserved values following the pattern 0x0A0A, 0x1A1A, up to 0xFAFA, and clients send them on purpose, as garbage, scattered through their cipher lists and extension lists.

The reason is ossification. If some middlebox hardcodes "I know every valid extension" and falls over on an unknown one, you want to discover that continuously, on ordinary traffic, instead of on the day you ship a real new extension and a chunk of the internet stops working. Chrome invented this and Chrome uses it the most, which is why it has one at each end: the first and last positions are exactly where a lazy parser is most likely to be special-cased.

Firefox, for what it is worth, sends none at all in either list. Two browsers, opposite policies, both perfectly conformant. This is the whole reason fingerprinting works.

So Chrome's hello is: GREASE, eighteen real extensions in a drawn order, GREASE. And GREASE has to be stripped before computing JA3 or JA4, or the fingerprint would change on every connection and be useless.

A bug that happens one time in sixteen

My first version drew each of the two pinned values independently. Sixteen possible values, two draws. You can see it coming; I did not.

Fifteen times out of sixteen it is fine. On the sixteenth, both draws return the same value and the hello now contains two extensions of the same type, which RFC 8446 says outright must not happen. A strict server can reject it. Worse, a fingerprinting service will not reject it — it will just record a client that no browser produces.

It has a second tell that is quieter and that I only noticed because of the byte-identity test: a duplicate makes the hello one byte shorter than any real capture. One byte. That is the kind of thing you ship forever if you are eyeballing diffs instead of asserting lengths.

The fix is to draw the second value from the fifteen that are not the first. My first attempt at that was a redraw loop, which worked fine until a test pinned the random number generator to a constant and the loop span forever. It is bounded rejection sampling now, 64 tries, then fail loudly.

What this did to the design

Firefox's profile holds EXTENSIONS: a list of (type, bytes). The emitter walks it. Done.

Chrome needs extension_order(ctx), a function, called once per connection with a context that holds that connection's random draws. So the field had to become "either a list or a callable". Then the cipher suites needed the same treatment, because Chrome puts a GREASE value at the front of those too. Then several payload builders.

The part I would tell a younger me

I thought I had designed a profile format. What I had actually done was write down one browser and mistake it for a format. There was no way to know that from one browser, and no amount of care would have revealed it — a second example is the only thing that finds this class of mistake.

Nothing above TLS matched either

Having fixed the handshake I assumed HTTP/2 would be free. It is the same RFC. How different can two implementations be?

Captured side by side:

HTTP/2 openingFirefox 157Chrome 155
settings sent1:65536 2:0 4:131072 5:163841:65536 2:0 4:6291456 6:262144
window update12,517,37715,663,105
first stream id31
HEADERS priorityweight 42weight 256, exclusive
pseudo-headers:method :path :authority :scheme:method :authority :scheme :path

Not one field is the same. They do not even send the same settings — Firefox sends number 5 where Chrome sends number 6 instead. There is a fingerprint built from exactly this, Akamai's, and it is as distinctive as the TLS one and cheaper to collect.

Checking it against a real Chrome

At this point I stopped trusting my own eye and compared values. A real Chrome 155 and my client, same service, field by field:

ja4                  identical
ja4_r                identical
peetprint_hash       identical
akamai_fingerprint   identical
session id length    identical
HTTP/2 header order  identical
HEADERS priority     identical   weight 256, exclusive, depends_on 0

ja4_r is the one I care about: it is the raw form, with every cipher, extension and signature algorithm spelled out instead of hashed, so a match is not a hash collision, it is the actual lists agreeing.

Something I did that was simply wrong

Chrome sends an extension called trust_anchors with a 186-byte payload. I wrote that payload from my own idea of what the structure ought to contain. Not a wrong detail — invented, 186 bytes of plausible-looking nonsense that no browser has ever sent.

It passed every test I had, because every test I had was about length. It is extracted from the capture now, with an assertion that the two are equal. If a value is in the profile and it did not come off the wire, it is decoration.

And then there was yelp

With everything matching, one target still refused: yelp.com served my Firefox profile 5 times out of 5 and refused my Chrome profile 5 out of 5. Same machine, same proxy, same code paths, interleaved rounds so a bad minute on the network would hit both.

That is a clean signal and I was quite pleased with it, because it meant there was something real to find. Here is everything I tried, in order.

The reload header

All six captures were taken on page reloads, because the little page I serve reloads itself to trigger the capture. Chrome sends Cache-Control: max-age=0 on a reload and Firefox does not, so my Chrome profile was sending it on every request — describing a client that reloads every page it has ever visited. A genuine bug.

Removed it. No change, either way.

Crossing the layers

Next idea: run the Chrome TLS layer with the Firefox HTTP layer and vice versa, and see which combination fails. Two by two, four cells, the answer falls out.

It cannot. ALPN is negotiated inside the handshake, so "Chrome's TLS" and "Chrome's HTTP/2" are not independent variables. The experiment was confounded by construction and I had designed it without noticing.

The frame sequence

Maybe a real Chrome sends some frame we do not, or in a different order. I taught the capture tool to log every frame a client sends, in arrival order, instead of only the ones it had a branch for.

Real Chrome sends exactly our three frames, in our order.

The locale

One value in the profile was deliberately not the captured one: the capture came from a Mexican address and said en-MX,en-GB;q=0.9,…, and I had changed it to en-US,en;q=0.9 as a sensible default.

Sent the captured locale instead. 0 of 3 either way.

The shuffle

This was my best idea and I was fairly confident. Both ja4_r and peetprint sort the extension list before hashing it. Which means the wire order is the one property of the hello that none of my matching fingerprints can see. If anything was going to be the invisible difference, it was the order: I draw uniformly, and maybe Chrome does not.

I checked the distribution first, recording where each of the eighteen shuffled extensions landed across the six captures:

psk_key_exchange_modes   [10, 6, 5, 10, 5, 6]     span 5-10
server_name              [ 8, 10, 15, 8, 12, 11]  span 8-15
signature_algorithms     [17, 0, 17, 11, 13, 17]  span 0-17

Wide spans, nothing suspicious, though six samples cannot prove much. So I tested it directly instead: replay three real captured orders verbatim, no drawing at all.

0 of 2 each. The order is not it.

The answer was in a sentence I had already been given

Before any of that, I had asked whether the page loaded in a real Chrome on the same machine. The answer came back, in Spanish: "the page comes up in Chrome, it verifies and loads."

I read that as no challenge, it just loads, and spent the rest of the day on the difference between a browser that sails through and clients that do not.

"It verifies."

I went back and asked. Yes: a verification screen appears before the page loads, in his own Chrome, as an ordinary visitor. So a real Chrome is being challenged too, and gets through by running the JavaScript. Everything fits and there is nothing left to explain:

real Chrome          challenged, solves it, loads
our Chrome           challenged, cannot solve it, 403
curl_cffi Chromium   challenged, cannot solve it, 403
our Firefox          not challenged, 200

There is no tell. The profile is fine. yelp challenges Chromium-shaped clients and serves Firefox-shaped ones, and a JavaScript challenge is the wall every fingerprint hits — a browser passes it with a capability I do not have, not with a better disguise.

The eliminations were not wasted. The profile is now verified against a real Chrome field by field, which it was not before, and five things that could have been wrong are known not to be. But the question they answered was one I invented by misreading one word, and the guard against that was a single clarifying question I did not ask. That was the most expensive mistake of the whole project and it was not a technical one.

A database of other people's Chromes

Everything above compares my client to one browser on one machine, mine. thumbprint.me publishes a catalogue of captures it takes itself — 849 sessions, client and version and OS recorded, with the raw cipher and extension lists, not just hashes. 110 of them are Chrome, across 40 versions, and the newest is 155.0.8059.39: the exact version this profile models.

My Chrome's fingerprint appears in none of them.

My first conclusion was that the profile was broken and I had been measuring it against myself this whole time. It is a reasonable thing to think at that point. It was also wrong, and what settled it was the cheapest possible test: I opened thumbprint.me in a Chrome window on the same machine.

my Chrome, in a window   t13d1518h2_8daaf6152771_4980c97edce0
this client              t13d1518h2_8daaf6152771_4980c97edce0
their 110 captures       t13d1517h2_8daaf6152771_cb7bf5808d99

So the profile reproduces a real Chrome exactly. What it does not reproduce is their real Chrome, and the difference is one extension: 0x12e0, which occurs zero times in 5.2 MB of their catalogue against 2,346 occurrences of application_settings. Take it out of my list and the hash lands on their value precisely.

Two wrong turns, which is the interesting part

Their captures are all headless, run in containers. So: headless Chrome omits the extension, and its absence is a tell you can read at the ClientHello with no JavaScript anywhere. That would be a genuinely useful thing to know, and I wrote a script, and the script agreed with me.

The script launched Chrome with --user-data-dir on a fresh temporary folder, the way every script-launched browser does. My hand measurement used my normal profile. Two things differed between them, not one, and the second one is where Chrome keeps its field trial state.

Holding that constant — same temporary profile, this install's four seed files copied in, still headless:

fresh profile, headless      q13d0312h3_…_178839b6cec1   no 0x12e0
seeded profile, headless     q13d0313h3_…_eb028bd37c08   0x12e0
my Chrome in a window        q13d0313h3_…_eb028bd37c08   0x12e0

The window was never the variable. 0x12e0 is gated behind a randomised field trial that my install joined and a new profile does not. And the second wrong turn was mine too: I had said a fingerprint nobody else presents is worse than a stale one. It is absent from their 849 sessions because a container on a fresh profile never joins a trial. Their Chrome is systematically the control arm. The absence described their dataset, not the world.

What is actually worth taking from this

Chrome's field trials change the ClientHello. So "Chrome 155's JA4" is not a value, it is a distribution over trial arms, and the seed rotates on Google's schedule rather than Chrome's release schedule.

If you are detecting: an allowlist of known-good Chrome fingerprints cannot be maintained, because the valid set moves without a version bump. If you are imitating: there is no single Chrome hello to copy, only an arm to pick. And if you are building a fingerprint dataset out of containers, as every published capture set I have seen does, you are collecting one arm and every real install in another will read as unknown.

Both arms are real Chrome, and both have populations — 647 observations against 156 over TCP, 488 against 46 over QUIC, in the service's own traffic. The common one is more common. It is also what every container and every freshly installed Chrome produces, while the rarer one says this install has been running long enough to pick up a seed. Which of those a given detector would rather see is not something I can measure from out here, so the profile keeps what was captured and the choice stays explicit.

From the repository