CompressionStream Fingerprint: gzip OS Byte
CompressionStream('gzip') emits a header whose tenth byte is a zlib compile-time constant: the OS the browser was built for, whatever the user agent says.
Published · Updated
- Category
- Environment
- API
CompressionStream- Reveals
- OS the binary was built for
- Spoofing
- Spoofable, spoof detectable
- Since
- 2023
Every gzip stream opens with the ten-byte header defined by RFC 1952, and the
last byte of it is an operating-system identifier. Browsers expose gzip to
JavaScript through CompressionStream, so five lines of script can read that
byte — and because it comes from a #define compiled into the binary rather
than from anything the page or the user can set, it reports the platform the
browser was built for. No permission, no secure context, no GPU or font
involvement, and nothing a user-agent spoofer touches.
The panel above ran it. If the last row says the UA and the binary disagree, something in the stack is lying about the platform.
Where the CompressionStream OS byte comes from
CompressionStream is a thin wrapper over zlib. Chromium's transformer picks
the gzip wrapper by adding 16 to the window-bits argument:
constexpr int kWindowBits = 15;
constexpr int kUseGzip = 16;
// ...
case CompressionFormat::kGzip:
err = deflateInit2(&stream_, level, Z_DEFLATED, kWindowBits + kUseGzip, 8,
Z_DEFAULT_STRATEGY);zlib lets a caller supply its own header through deflateSetHeader(). Chromium
never calls it. That single omission is the whole signal: with no header
supplied, deflate.c takes the default branch and fills the fields itself.
if (s->status == GZIP_STATE) {
/* gzip header */
crc_reset(s);
put_byte(s, 31);
put_byte(s, 139);
put_byte(s, 8);
if (s->gzhead == Z_NULL) {
put_byte(s, 0); /* FLG */
put_byte(s, 0); /* MTIME, four zero bytes */
put_byte(s, 0);
put_byte(s, 0);
put_byte(s, 0);
put_byte(s, s->level == 9 ? 2 : /* XFL */
(s->strategy >= Z_HUFFMAN_ONLY || s->level < 2 ?
4 : 0));
put_byte(s, OS_CODE); /* <- byte 9 */Two things are worth noticing in that block beyond the last line. MTIME is
four explicit zeroes — zlib is not leaking a clock, which is the correct
behavior and a reminder that this header was thought about. And XFL encodes
the compression level, which for CompressionStream is fixed, so it carries
nothing. Only OS_CODE varies.
OS_CODE is chosen by the preprocessor in zlib's zutil.h, from a ladder of
platform guards ending in a fallback:
#if defined(MACOS)
# define OS_CODE 7
#endif
/* ... */
#if defined(WIN32) && !defined(__CYGWIN__)
# define OS_CODE 10
#endif
/* ... */
#ifdef __APPLE__
# define OS_CODE 19
#endif
/* ... */
#ifndef OS_CODE
# define OS_CODE 3 /* assume Unix */
#endifThree of those are reachable from a shipping browser. Modern macOS defines
__APPLE__ but not MACOS — that guard is for classic Mac OS and is dead
code today — so Apple platforms land on 19. Windows builds land on 10. Nothing
else matches, so Linux, Android, ChromeOS and the BSDs all fall through to the
assume Unix default of 3.
Measurement status
Being honest about which cells are measured matters more than the table looking complete:
| Platform | OS byte | How established |
|---|---|---|
| macOS | 19 | Measured — Chrome 148, macOS, full header 1f 8b 08 00 00 00 00 00 00 13 |
| Windows | 10 | Source-confirmed from zutil.h only; not yet measured on a real binary |
| Linux / Android / ChromeOS | 3 | Source-confirmed from the fallback only; not yet measured |
The two source-confirmed rows follow directly from public preprocessor guards and I have no reason to doubt them, but they are inferences until somebody runs the probe on those platforms. If you are reading this on Windows or Linux and the panel shows something other than 10 or 3, that is a finding — please tell me.
The byte doesn't mean what RFC 1952 says
RFC 1952 assigns the OS field a table: 0 is FAT, 3 is Unix, 7 is Macintosh, 11 is NTFS, 255 is unknown. It stops at 13.
zlib does not follow that table. It emits 19 on Apple platforms, which the RFC never assigns to anything, and 10 on Win32, which the RFC assigns to TOPS-20 — a PDP-10 operating system discontinued in 1988. The value a modern Windows browser writes into a standards-defined field nominally means an extinct mainframe OS.
This is not a bug so much as thirty years of accreted #ifdefs, but it matters
for anyone parsing the field: decode it with zlib's table, not the RFC's, or
you will read macOS as unassigned and Windows as a DEC mainframe.
Why a low-entropy signal is still interesting
On its own this byte is nearly worthless for identification. It has three reachable values, and it is almost perfectly correlated with the platform token in the user agent — so against a browser telling the truth, it adds essentially nothing to a fingerprint that already read the UA.
Its value is that it is orthogonal to the things people spoof. A user-agent
override — an extension, a devtools setting, a --user-agent flag, a
navigator.platform patch — changes a string. It cannot change which #ifdef
branch the C library took when the binary was compiled months earlier. So the
interesting output is not the byte; it is the comparison between the byte and
the claim, which is what the last row of the panel shows.
This generalizes. A whole class of signals works this way: near-zero entropy
alone, high value as a consistency check, because they are produced by
compile-time constants no runtime setting reaches. Blob's line-ending
normalization is another one, gated on an entirely separate branch:
void NormalizeLineEndingsToNative(const std::string& from,
Vector<uint8_t>& result) {
#if BUILDFLAG(IS_WIN)
InternalNormalizeLineEndingsToCrLf(from, result);
#else
NormalizeLineEndingsToLf(from, result);
#endif
}So new Blob(['a\nb'], {endings: 'native'}).size is 4 on Windows and 3
everywhere else — one LF becoming CRLF. Measured 3 here on macOS; the Windows
value is source-confirmed from that BUILDFLAG(IS_WIN) branch and not yet
measured. Two independent compile-time branches, in unrelated subsystems,
answering the same question. Getting both to agree with a forged user agent
means patching each one individually, and patching a native function in
JavaScript is itself detectable — see
headless and automation detection.
Spoofing it
There is no setting, flag, or privacy mode that changes this byte, because there is nothing to change: the value was fixed when the binary was compiled. That makes it unusually robust compared to most of this dictionary.
It is still JavaScript, so a page's own CompressionStream can be replaced
wholesale by anything running script in that realm — an automation framework, a
userscript, an extension. That kind of override is the detectable kind: the
replacement is not native code, and it has to be applied in every realm
(workers and fresh iframes included) or the realms disagree with each other.
A browser that wanted to close this would call deflateSetHeader() with the OS
field set to 255, the RFC's "unknown". That costs a few lines, makes output
identical everywhere, and loses nothing — the field has no consumer that needs
the truth. As far as I can tell nobody has filed that against Chromium.
References
- RFC 1952 — the GZIP file format, section 2.3.1 for the OS field and its table.
- Chromium source, all public:
third_party/zlib/zutil.h(theOS_CODEladder),third_party/zlib/deflate.c(the header write, in thes->gzhead == Z_NULLbranch),third_party/blink/renderer/modules/compression/deflate_transformer.cc(thedeflateInit2call, and the absentdeflateSetHeader), andthird_party/blink/renderer/platform/wtf/text/line_ending.cc(theBUILDFLAG(IS_WIN)branch). - MDN:
CompressionStreamandBlob.
How the probe works
This is the exact source that ran in the panel above — no summary, no drift.
// Reads byte 9 of a gzip stream produced by CompressionStream. That byte is
// zlib's OS_CODE, a compile-time #define baked into the browser binary, so it
// reports the OS the browser was *built* for regardless of what the user agent
// claims. Also runs the Blob line-endings cross-check, which is gated on a
// separate compile-time branch, and compares both against the UA.
// zlib's own table (third_party/zlib/zutil.h), which is not RFC 1952's table —
// see the entry text. Only a handful of these are reachable from a browser.
const OS_CODES = {
0: "MS-DOS / 16-bit Windows",
1: "Amiga",
2: "VMS",
3: "Unix (Linux, Android, BSD, ChromeOS)",
5: "Atari TOS",
6: "OS/2",
7: "Classic Mac OS",
10: "Win32",
13: "Acorn RISC OS",
16: "BeOS",
18: "OS/400",
19: "Apple (macOS, iOS)",
255: "unknown (explicitly unset)",
};
// What the UA claims, reduced to the same three buckets the OS byte can express.
function claimedFamily() {
const p = navigator.userAgentData?.platform;
if (p) {
if (p === "Windows") return "windows";
if (p === "macOS") return "apple";
if (p === "Linux" || p === "Android" || p === "Chrome OS" || p === "Chromium OS") return "unix";
}
const ua = navigator.userAgent;
if (/Windows NT/.test(ua)) return "windows";
if (/Mac OS X|iPhone|iPad/.test(ua)) return "apple";
if (/Android|Linux|CrOS|X11/.test(ua)) return "unix";
return "unknown";
}
function byteFamily(os) {
if (os === 10 || os === 0) return "windows";
if (os === 19 || os === 7) return "apple";
if (os === 3) return "unix";
return "unknown";
}
export async function probe() {
if (typeof CompressionStream === "undefined") {
throw new Error("CompressionStream is unavailable — no gzip header to read");
}
const cs = new CompressionStream("gzip");
const writer = cs.writable.getWriter();
// Payload content is irrelevant; the header is emitted before any of it.
writer.write(new Uint8Array([0x61, 0x62]));
writer.close();
const bytes = new Uint8Array(await new Response(cs.readable).arrayBuffer());
const header = [...bytes.slice(0, 10)];
if (header[0] !== 0x1f || header[1] !== 0x8b) {
throw new Error("not a gzip stream — magic bytes absent");
}
const os = header[9];
// Independent cross-check on a different compile-time branch: Blob's
// endings:'native' normalizes to CRLF only under BUILDFLAG(IS_WIN), so one
// LF becomes two bytes on Windows and stays one byte everywhere else.
const nativeSize = new Blob(["a\nb"], { endings: "native" }).size;
const claimed = claimedFamily();
const actual = byteFamily(os);
const agrees = claimed === actual;
return {
facts: {
"Built for": OS_CODES[os] ?? `unassigned code ${os}`,
"User agent": agrees ? "agrees with the binary" : "DISAGREES with the binary",
},
label: `OS byte ${os} — ${OS_CODES[os] ?? "unassigned"}`,
value: {
"gzip header (10 bytes)": header.map((b) => b.toString(16).padStart(2, "0")).join(" "),
"OS byte (byte 9)": `${os} (0x${os.toString(16).padStart(2, "0")})`,
"zlib OS_CODE meaning": OS_CODES[os] ?? "unassigned in zlib's table",
"MTIME field": header.slice(4, 8).every((b) => b === 0) ? "zeroed" : "non-zero — leaks a clock",
"XFL field (byte 8)": `${header[8]} (${header[8] === 2 ? "level 9" : header[8] === 4 ? "level 1" : "default level"})`,
"Blob endings:'native' size": `${nativeSize} bytes (${nativeSize === 4 ? "CRLF — Windows" : "LF — not Windows"})`,
"User-agent claims": claimed,
"Binary was built for": actual,
"Consistent": agrees ? "yes" : "NO — the UA disagrees with the binary",
},
};
}