Skip to content

GREASE values in the ClientHello and why they break naive fingerprinting

· 19 min read
Copyright: MIT
The word GREASE in monospace with one byte value 0x0a0a highlighted in orange

Capture a Chrome ClientHello and look at the very first cipher suite in the list. There is a decent chance it reads 0x1a1a, or 0x6a6a, or some other two-byte value where both bytes share the pattern _a. It is not a real cipher suite. No server will ever negotiate it, and Chrome would reject it if a server tried. The same odd values turn up in the extension list, in the supported groups, in the supported versions. They move around between connections. Reload the page, open a fresh connection, and the value that was 0x1a1a is now 0x8a8a, sitting in a different slot.

If your fingerprinting code hashes the ClientHello byte-for-byte without knowing what those values are, you have a problem. The same browser, the same build, the same machine produces a different fingerprint each time it connects. This is GREASE doing exactly what it was designed to do, and it is the first thing any serious TLS fingerprint has to handle before it can claim to identify anything.

This post is about that mechanism. Where GREASE came from and the ossification problem it was built to fight, the exact set of reserved values and the rules a client follows when it injects them, how those random values land in a real ClientHello, and the specific way a fingerprint that does not normalize them comes apart. The through-line is simple: GREASE is noise on purpose, and a fingerprint is only as good as its ability to tell the noise from the signal.

The rusted joint: why GREASE exists at all

Protocols that mean to evolve leave room for it. TLS has version numbers, a cipher suite list, an extensions block, named groups, signature algorithms. Each of those is an extension point, a place where new values can be added later and old implementations are supposed to ignore what they do not recognize. That is the contract. A client offers something a server has never heard of, the server skips it, the handshake continues.

The contract held in theory and rotted in practice. Between the release of TLS 1.2 in 2008 and the deployment of TLS 1.3 roughly a decade later, a large number of server and middlebox implementations were written, shipped, and never tested against anything newer than what existed the day they were built. Some of them did not skip unknown values. They rejected them. A server that has only ever seen TLS 1.0 through 1.2 in the version field, written by someone who treated the field as an enumeration rather than a range, would drop a connection the moment it saw a higher number. Because no client was sending higher numbers yet, the bug was invisible. It interoperated perfectly with every client in existence. The mistake spread through the ecosystem unnoticed, one shipped appliance at a time.

The draft that became GREASE put it plainly. Bugs may cause a server to reject unknown values; those broken servers interoperate with existing clients, so the mistake spreads unnoticed; and later, when new values are defined, updated clients discover that “the metaphorical joint in the protocol has rusted shut and that the new values cannot be deployed without interoperability failures.” That is from David Benjamin’s first GREASE draft, dated July 2016, two years before TLS 1.3 shipped as RFC 8446.

When browsers actually tried to deploy TLS 1.3, the rusted joint was waiting. Version intolerance was widespread enough that the working group could not simply bump the version number in the ClientHello. A meaningful fraction of servers rejected the handshake outright on seeing an unfamiliar version. The fix was ugly: TLS 1.3 freezes the legacy version field at 1.2 and moves the real version negotiation into a new supported_versions extension, precisely so that the intolerant servers never see a number they will choke on. The infamous F5 load balancer bug ran in the same vein, where certain devices refused any ClientHello with a total length between 256 and 512 bytes, which is why TLS gained a padding extension whose only job is to pad the handshake out of that dead zone.

2008 2016 2018 2020 TLS 1.2 ships GREASE draft; intolerance found TLS 1.3 (RFC 8446): version moves to supported_versions RFC 8701: GREASE A decade of untested extension points rusts shut *The gap between TLS 1.2 and 1.3 is where version intolerance accumulated, because no client exercised the higher version numbers. GREASE was the proposed cure for the next decade.*

The cure came from Adam Langley’s observation about cryptographic agility, which he summarized as: have one joint and keep it well oiled. The argument is that extension points fail not because they are complex but because they go unused. An unexercised code path is a code path nobody tests, on either side. So instead of waiting for a new feature to expose the broken servers years after they shipped, you exercise the extension points constantly, from day one, with values that mean nothing. GREASE is the oil.

The mechanism: meaningless values, sent on purpose

GREASE stands for Generate Random Extensions And Sustain Extensibility, and the expansion is a backronym that tells you the whole story. The client generates values that no registry has assigned, advertises them inside the normal protocol fields, and a correct server ignores them and proceeds. A broken server fails immediately, in front of the implementer who is testing against live traffic, rather than years later in front of a user trying to load a page. The failure surfaces while it is still cheap to fix.

RFC 8701, authored by David Benjamin of Google and published in January 2020, reserves the values and writes down the rules. The reserved values are not arbitrary. They follow a deliberate pattern so that anyone reading a packet can recognize them on sight, and so the IANA registries can keep the slots permanently parked. For cipher suites and ALPN identifiers, which are two-byte values, GREASE reserves sixteen of them: {0x0A,0x0A}, {0x1A,0x1A}, {0x2A,0x2A}, on up through {0xFA,0xFA}. Every one has both bytes equal, and every byte ends in the nibble A. For extension types, named groups, signature algorithms, and the supported versions, the reserved set is the two-byte values 0x0A0A, 0x1A1A, 0x2A2A through 0xFAFA, with one wrinkle at the high end where the spec uses 0xBABA, 0xCACA, 0xDADA, 0xEAEA, 0xFAFA rather than a clean doubling. The PskKeyExchangeModes field, being a single byte, gets its own short list: 0x0B, 0x2A, 0x49, 0x68, 0x87, 0xA6, 0xC5, 0xE4.

Cipher suite / ALPN GREASE values (two bytes, both equal) 0x0a0a 0x1a1a 0x2a2a 0x3a3a ... 0x4a4a 0x5a5a 0x6a6a 0x7a7a 0xfafa Every byte ends in nibble A sparse, predictable, and permanently reserved so no real value collides *The reserved set is sparse by design. The spec spreads the values out to discourage servers from special-casing them, and the shared nibble makes them trivial to spot in a capture.*

The pattern was chosen for two reasons that the RFC states directly. The values were allocated sparsely to discourage server implementations from conditioning on them, meaning a server author who tried to enumerate and special-case every GREASE value would face a moving target spread thinly across the space. And the pattern keeps a consistent shape while avoiding collisions with any existing applicable registry, so the slots can be parked in IANA forever without ever clashing with a real assignment. The shared A nibble is the recognizable signature; the sparsity is the defense against the special-casing that would defeat the whole exercise.

The client-side rules are short. A client may select one or more GREASE cipher suite values and place them in the cipher_suites field. It may select one or more GREASE extension values and send them as extensions with varying length and contents, including empty ones. It may advertise GREASE values inside supported_groups, signature_algorithms, and signature_algorithms_cert when those extensions are present, and inside supported_versions. The constraint that matters for anyone parsing the bytes is that when an implementation sends multiple GREASE extensions in a single block, it must ensure the same value is not selected twice. No duplicates within one field. And on the server side, the obligation is symmetrical and absolute: a server must reject any GREASE value if the client somehow negotiates one, treating it the way it would treat any other value it does not understand.

One rule is easy to miss and important for the ossification story. RFC 8701 says implementations should not retry with GREASE disabled on connection failure. If a handshake breaks and the client quietly drops GREASE and tries again, the broken server is rewarded for being broken and the bug stays hidden. The whole point is that GREASE failures are loud. A fallback that silences them would re-rust the joint.

What it looks like in a real ClientHello

Chrome is the reference implementation. It has sent GREASE since 2016, and its behavior is what most fingerprinting systems were forced to confront first. In a current Chrome ClientHello, GREASE shows up in several places at once. The first entry in the cipher suite list is a GREASE value. The first and last entries in the extension list tend to be GREASE extensions, one of them frequently the empty padding-style GREASE extension. There is a GREASE value at the head of supported_versions, one in supported_groups, and one in the key share. They are not the same value in each location, and they change from one connection to the next.

The selection is random per connection within the reserved set, which is the property that wrecks naive fingerprinting. Across two consecutive handshakes from the identical browser, the cipher list goes from starting with 0x3a3a to starting with 0xaaaa, the extension list swaps its GREASE markers for different ones, and every field that carries a GREASE slot rolls fresh. Nothing about the browser changed. The negotiable content, the real ciphers and real extensions and their supported parameters, is byte-for-byte identical. Only the noise moved.

Connection 1 Connection 2 same browser cipher_suites: 0x3a3a 0x1301, 0x1302, 0x1303 0xc02b, 0xc02f ... extensions: 0x8a8a server_name, ALPN ... 0xfafa (padding-style) supported_versions: 0x5a5a 0x0304, 0x0303 cipher_suites: 0xaaaa 0x1301, 0x1302, 0x1303 0xc02b, 0xc02f ... extensions: 0x2a2a server_name, ALPN ... 0x1a1a (padding-style) supported_versions: 0xbaba 0x0304, 0x0303 *Only the orange lines moved between the two connections. The real ciphers, extensions, and versions are identical. A byte-level hash sees two different clients; they are the same browser one second apart.*

The values shown above are illustrative rather than copied from a specific capture; the exact slot a given Chrome build lands GREASE in, and how it derives the per-connection random choice, are implementation details that shift between versions. What is stable and documented is the shape: a GREASE value at the front of the cipher list, GREASE extensions bracketing the extension block, GREASE entries in the parameter lists, all drawn from the reserved set, all rerolled per connection.

GREASE is not unique to TLS, which is worth keeping in view because the fingerprinting consequences rhyme across protocols. The same idea was carried into HTTP/2, where reserved settings identifiers and frame types serve the same oiling function, and into QUIC, which reserves transport parameters and a class of version numbers for exactly this purpose. ALPN carries its own GREASE identifiers. Anywhere a protocol has a registry and wants to keep it from rusting, GREASE-style reserved values tend to follow, and anywhere they appear, a fingerprint built on that protocol has the same normalization problem to solve. The JA4+ suite was designed across several of these layers in part to handle that consistently.

How a naive fingerprint comes apart

The original TLS fingerprint that ran into this was JA3, the Salesforce scheme that hashes a ClientHello into a single MD5. JA3 builds a string from five fields in order: the TLS version, the list of cipher suites, the list of extensions, the list of elliptic curves (named groups), and the elliptic curve point formats. It joins them with commas and dashes and hashes the result. A given client build produces a given string, the string produces a given hash, and that hash is the fingerprint.

Drop a fresh GREASE value into the cipher list and the extension list, and the string changes. Change the string and the MD5 changes completely, because that is what a hash does. So a JA3 implementation that feeds the raw cipher and extension lists into the digest gets a different fingerprint on every single connection from a GREASE-sending browser. The fingerprint is no longer identifying the browser. It is identifying the dice roll.

The fix JA3 settled on is to strip the GREASE values before hashing. Salesforce’s own implementation ignores them so that, in its words, programs using GREASE can still be identified with a single JA3 hash. Recognizing GREASE is cheap precisely because of the pattern. Any value matching the reserved set, both bytes equal and ending in A for the two-byte case, gets dropped from the cipher list, the extension list, and the curve list before the string is assembled. With GREASE removed, the same browser produces the same string again, and the hash is stable. This is the single most important normalization step in any TLS fingerprint, and a JA3 that skips it is broken on Chrome out of the box.

Same browser, two connections, into two pipelines

Naive (no normalization) hash(raw bytes) cd08e3… 9f41b2… two hashes, one browser

Normalized (strip GREASE first) drop 0x_a_a, hash 3a1f0c… 3a1f0c… one stable hash The only difference between the pipelines is the first step. Strip the reserved values before hashing and the same browser collapses to one fingerprint; skip it and you are hashing entropy.

That gets you past the first hurdle. It does not get you past the second. In early 2023, Chrome added a separate feature that randomizes the order of the extensions in the ClientHello on every connection. This is a different mechanism from GREASE and aimed at a related but distinct kind of ossification, the assumption that extensions arrive in a fixed sequence. Fastly’s measurement of the rollout, published in February 2023, put numbers on it: the feature reached users in Chrome 108 and 109 ahead of its scheduled 110 release, it produces on the order of 15 factorial permutations of the extension set, and the single most common Chrome JA3 fingerprint at the time, cd08e31494f9531f560d64c695473da9, fell off sharply after the change began rolling out around January 20, 2023. JA3 hashes the extension list in order, so a randomized order means a randomized hash, and stripping GREASE does nothing to help. The randomization and the GREASE problem stack. JA3 survived the first and not the second, and that is the story of why a TLS fingerprint moved from JA3 to JA4.

How JA4 handles it, and why sorting matters

JA4, from FoxIO, was built with both problems in mind. Its structure is three sections joined by underscores, conventionally written like t13d1516h2_8daaf6152771_e5627efa2ab1. The first section is human-readable metadata: the transport (t for TCP, q for QUIC), the TLS version, whether SNI is present, a two-digit count of cipher suites, a two-digit count of extensions, and the first two characters of the first ALPN value. The second section is a truncated SHA-256 of the cipher suite list. The third is a truncated SHA-256 of the extension list, with the signature algorithms appended after another underscore.

GREASE is handled by the rule that the program ignores GREASE values anywhere it sees them. Wherever a reserved value appears, in the ciphers, the extensions, the groups, it is dropped before anything else happens. So far this matches the JA3 fix. The change that defeats Chrome’s extension randomization is that JA4 sorts. The cipher suites are sorted into hex order before hashing, and the extensions are sorted into hex order before hashing. Once the list is sorted, the order Chrome happened to send it in does not matter. The randomized permutation collapses back to a single canonical sequence, and the hash is stable again. FoxIO’s stated rationale for sorting the ciphers is that applications and libraries tend to choose a unique set of ciphers more often than a unique order, so hashing the set rather than the sequence keeps the signal and discards the noise, and as a bonus it neutralizes cipher stunting, the older trick of shuffling cipher order to dodge JA3.

There is one subtlety in JA4 that is easy to get wrong and worth stating precisely. The two-digit counts in the readable first section count the real values, not the GREASE ones. The cipher and extension counts exclude GREASE. JA4 also removes the SNI extension (0x0000) and the ALPN extension (0x0010) from the sorted extension hash, because those are already captured in the first section, with the explicit goal that the same application produces the same third section whether it connects to a domain or an IP and regardless of which ALPN it negotiates. So GREASE removal, SNI removal, ALPN removal, and sorting all happen before the hash. The result is a fingerprint that does not move when GREASE rerolls and does not move when Chrome shuffles its extensions. That is the whole reason JA4 exists, and it is why a fingerprint reference that does not normalize the ClientHello field by field will misidentify modern browsers.

raw ClientHello drop GREASE, SNI, ALPN sort by hex SHA-256, truncate

The two orange steps are what kill GREASE noise and order randomization Counts in the readable prefix exclude GREASE; the hash is over the canonical sorted set JA4’s pipeline. GREASE removal and hex sorting are the two steps that make the fingerprint survive both per-connection randomization mechanisms.

GREASE as a tell, not just noise

There is an inversion worth sitting with. GREASE is meant to be ignored, but the way a client implements it is itself a signal. Real Chrome sends GREASE in a particular set of locations, with a particular shape, following the RFC’s rules about no duplicates and the empty padding-style extension. A client that is trying to look like Chrome has to reproduce that, and getting it subtly wrong is a fingerprint of its own.

This is the territory of uTLS, the Go library that exposes the ClientHello so a non-browser client can mimic a browser’s TLS handshake. To pass as Chrome, uTLS has to emit GREASE in the right places, which the project describes as wanting to look like Chrome with all the GREASE and stuff. The hard part is the details. A real Chrome GREASE extension carries certain contents; a real Chrome key share places GREASE in a specific slot; and when Chrome added GREASE to its Encrypted Client Hello support, it picked the GREASE cipher suite to match its hardware-preferred AES-or-ChaCha choice, so a mimic that hardcodes one value diverges from a real client on machines with different hardware acceleration. uTLS had to patch exactly that to keep its Chrome fingerprint honest. The point generalizes: because GREASE is randomized but structured, an imitation that randomizes differently, or fails to randomize at all, or sends a GREASE value where Chrome would not, is detectable by a fingerprint that knows what correct GREASE looks like. The noise has structure, and structure can be checked.

The same logic applies to detecting the absence of GREASE. A client claiming a recent Chrome user-agent that sends no GREASE values at all is making a claim its TLS handshake contradicts, because every recent Chrome sends GREASE. That mismatch is a cheap, reliable signal, and it is one reason second-order tells matter as much as the primary fingerprint. Normalizing GREASE away gives you a stable identifier for honest clients; checking whether GREASE is present and correctly shaped gives you a lie detector for dishonest ones. A serious fingerprinting stack does both, on the same handshake, for different purposes.

Closing: the joint stays oiled, the fingerprint stays normalized

GREASE worked. The mechanism Adam Langley argued for and David Benjamin specified did what it set out to do: it kept TLS’s extension points exercised, surfaced intolerant implementations while they were still cheap to fix, and made the deployment of TLS 1.3 and everything after it possible without a fresh round of the version-intolerance disaster. The reserved values will keep showing up in ClientHellos for as long as TLS evolves, because the moment clients stop sending them the joint starts rusting again. The RFC’s instruction not to retry with GREASE disabled is the load-bearing rule; loud failures are the entire value.

For anyone reading those handshakes, the lesson is narrower and permanent. The first thing a TLS fingerprint must do is decide which bytes are content and which are noise, and GREASE is the canonical noise. Strip it and the same browser collapses to one identity; keep it and you are hashing a random number generator. JA3 learned this and added GREASE removal. Chrome’s 2023 extension randomization broke JA3 anyway, on a different axis, and JA4 answered with sorting on top of GREASE removal. Each fix is a normalization step, and the pattern holds going forward: every time a browser adds entropy to keep the protocol flexible, a fingerprint has to add a canonicalization step to stay stable. The values designed to be ignored are the ones a fingerprint has to understand most precisely, because the cost of misreading them is a different answer on every connection.


Sources & further reading

Further reading