← dictionary
Environment

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.

Category
Environment
API
CompressionStream
Reveals
OS the binary was built for
Spoofing
Spoofable, spoof detectable
Since
2023
Liverunning the probe…

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:

cpp
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.

c
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:

c
#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 */
#endif

Three 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:

cpp
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 (the OS_CODE ladder), third_party/zlib/deflate.c (the header write, in the s->gzhead == Z_NULL branch), third_party/blink/renderer/modules/compression/deflate_transformer.cc (the deflateInit2 call, and the absent deflateSetHeader), and third_party/blink/renderer/platform/wtf/text/line_ending.cc (the BUILDFLAG(IS_WIN) branch).
  • MDN: CompressionStream and Blob.

How the probe works

This is the exact source that ran in the panel above — no summary, no drift.

JavaScript
// 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",
    },
  };
}