Verifying a Patched Brotli Decoder in Node.js and .NET Without Trusting the Changelog

Verifying a Patched Brotli Decoder in Node.js and .NET Without Trusting the Changelog

pr0h0••25 min read•coding
AI Usage (99%)
On this page

Introduction — Why a Changelog Entry Is a Claim, Not Evidence

I started from one line: a Brotli flaw hits Node.js and .NET, and the attacks are said to be confirmed. That's enough to make you open your dependency tree, and nowhere near enough to change anything, because it says nothing about the binary your process is actually executing.

This post is a working method for verifying a patched Brotli decoder in Node.js and .NET: a crash-only harness that survives a segfault, differential runs of the old decoder against the new one, an output cap you enforce yourself, and a read of the actual patch diff. Every step produces evidence you can act on, and none of it depends on the changelog being accurate.

The position I'll defend for the rest of this post: the version string is the weakest piece of evidence you have, and the only verification that survives a distro backport is a behavior test on hostile input plus an output cap you control. A changelog describes a source tree. Your process runs machine code. Different claims, and the gap between them is where incidents live.

Brotli works as a case study because the failure mode isn't a logic bug that returns bad JSON. The decoder is C, both runtimes vendor it from the same upstream project, and a malformed stream can take down the process instead of throwing. That changes how you test it: try/catch around brotliDecompress isn't a harness, it's a hope.

The workflow below runs today. The sections at the end are the part most posts skip: what each check actually proves, and what it leaves open.

What the Brotli Report Actually Says, and What It Does Not

The confirmed parts: a shared decompression flaw, two runtimes, reported attacks

From the source material, the claim set is small: a reported Brotli flaw affects Node.js and .NET, and the report says attacks are confirmed. The aggregator item carries a feed date of 2026-10-31.

That's all of it. I'm treating those as reported claims from a secondary source, not confirmed facts about any specific build — an aggregator line is two hops from the actual research.

What I can verify independently is the architecture behind the story, and that part is solid: both runtimes ship the same C decoder.

The missing parts: no CVE, advisory, version range, or PoC in hand — say so plainly

I won't invent details to make the post feel finished. The material I was given contains:

  • no CVE identifier
  • no vendor advisory from the Node.js project or the .NET team
  • no affected version range
  • no proof-of-concept input
  • no statement of bug class — buffer overflow, out-of-bounds read, unbounded allocation, something else
  • no clarity on whether "attacks confirmed" means exploitation seen in the wild or a working PoC in a lab
⚠️

If a post on this topic gives you a CVE number and an affected version range without linking the advisory, it is guessing. The bug class decides whether your risk is memory corruption or resource-exhaustion denial of service, and those need different mitigations.

Inference, clearly labeled: since the story names two runtimes sharing one C library, the likeliest shape is a decoder-side parsing flaw in google/brotli that both inherit when they bump their vendored copy. That's a guess about mechanism, not a fact from the report. The workflow below doesn't depend on the guess being right, which is the reason to build it this way.

The checkable architecture claim: both runtimes vendor google/brotli as C source

You can check this in about thirty seconds, and it's what makes the story hang together. Neither runtime reimplements Brotli in JavaScript or C#. Both link the upstream C decoder.

## Node: the vendored tree, including the decoder
ls node/deps/brotli/c/dec

## .NET: the native shim and the vendored upstream sources
find dotnet-runtime/src/native -maxdepth 3 -type d -name 'brotli*'

Both resolve back to the same place: BrotliDecoderDecompressStream in c/dec/decode.c. One C file, two runtime shims, two release cadences, and two very different speeds at which a patch travels.

Why One C Decoder Becomes Two Runtime Problems

Where Brotli lives in Node.js (zlib module, vendored deps/brotli, node.gyp wiring)

In Node, Brotli lives inside the zlib module. The public API is zlib.brotliCompress / brotliDecompress (sync, callback, and stream forms), backed by deps/brotli compiled into the node binary — or, on some distribution builds, linked against a system Brotli via --shared-brotli.

That last detail matters more than it looks. If your Node came from a distro that links a system Brotli, deps/brotli in the source tree tells you nothing about the code your process executes. Look at the shared library instead.

Where Brotli lives in .NET (System.IO.Compression.BrotliStream, System.IO.Compression.Native shim, vendored upstream sources)

In .NET the managed surface is System.IO.Compression.BrotliStream (plus the BrotliEncoder/BrotliDecoder structs near System.Buffers). Underneath, BrotliStream P/Invokes into System.IO.Compression.Native, a thin C shim over the vendored upstream decoder.

So on Linux your memory-unsafe surface is a .so file, not an assembly. dotnet --list-runtimes will happily report Microsoft.NETCore.App 8.0.x while saying nothing about which commit of src/native/external/brotli sits inside that .so. That gap is what Step 0 closes.

The real trust boundary: anything that auto-decompresses an inbound HTTP response

The decoder is roughly safe when you feed it your own files. It becomes attack surface the moment someone else chooses the compressed bytes. Three common paths do that implicitly:

StackWhere decode happensDo you see the compressed bytes?
Node node:httpYou call brotliDecompress yourselfYes
Node global fetch (undici)Fetch handles content-encoding: brNo, the body arrives decoded
.NET HttpClient + AutomaticDecompression = BrotliHandler decodes before your code runsNo
.NET BrotliStream over a file/object storeYour code drives the streamYes

The two middle rows are the forgotten ones. If your handler never touches a BrotliStream, it's easy to conclude you have no Brotli exposure — except the decompression still happens, inside your process, on bytes a remote server picked.

Two exposure shapes: hostile network response vs. poisoned stored artifact

Distinct threat models, different controls:

Hostile network response. Whoever controls a response body — compromised origin, MITM on a misconfigured client, malicious proxy, a URL your service fetches on a user's behalf — sends a crafted content-encoding: br body. Controls: output cap, timeout, process isolation.

Poisoned stored artifact. A .br object lands in S3/GCS/your build cache from an untrusted pipeline, and a batch job with more memory and more privileges decompresses it later. Controls: hash pinning, decompress in a constrained worker, and never decompress with credentials the output could then use.

The second is scarier, because the attacker chooses when the decoder runs.

Step 0 — Pin Down the Brotli Binary You Are Actually Running

Node: process.versions.brotli, the vendored directory, and why distro backports break the version check

node -p "process.versions.brotli"
node -p "process.config.variables.node_shared_brotli" 2>/dev/null || echo "not set"
node -p "process.release.sourceUrl"

The first prints the upstream Brotli version Node was built against — not the Node version, not your patch level:

$ node -p "process.versions.brotli"
1.0.9

Yours will differ by Node release; record it rather than assuming. Worth running the second too, since 1 means Brotli came from the system and the vendored tree is irrelevant to your binary.

Now the trap. A distro backport can fix a decoder bug without touching process.versions.brotli. Debian, Ubuntu, and Fedora routinely apply a security patch onto an older upstream version and leave the string stable, because bumping it implies an ABI or behavior change they don't want.

process.versions.brotli === "1.0.9"   →   says nothing about whether the bug is fixed

Not a criticism of distros; a criticism of using a version string as a security assertion. If you need certainty, you need the binary or the patch.

.NET: assembly informational version, the native shim, and mapping a runtime build to a dotnet/runtime commit

using System.Reflection;

var asm = typeof(System.IO.Compression.BrotliStream).Assembly;
var info = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
Console.WriteLine(info?.InformationalVersion);

On Microsoft-built runtimes the informational version carries a commit suffix:

8.0.0+5535e3...

That 40-character tail is the dotnet/runtime commit the assembly was built from. Paste it into https://github.com/dotnet/runtime/commit/<sha> and you have provenance: which vendored Brotli tree shipped — assuming the native library in the same runtime pack came from the same commit, true for official builds and not guaranteed for a hand-rolled or repackaged runtime.

Hash the binary, not the semver — a two-command fingerprint you can diff across builds

This is the part I'd keep even if the Brotli story turns out to be overstated. Hash the thing that executes.

## Node
sha256sum "$(node -p 'process.execPath')"

## .NET native compression shim
DOTNET_ROOT=$(dirname "$(readlink -f "$(command -v dotnet)")")
find "$DOTNET_ROOT/shared" -name 'System.IO.Compression.Native.*' -type f -exec sha256sum {} +
$ sha256sum "$(node -p 'process.execPath')"
4c1f9a2e8b7d…  /usr/local/bin/node

Truncated on purpose — yours will differ. The value is in the diff: record this hash in your image build, and any change to it is a behavior change you didn't ask for. Two container digests with the same semver and different Brotli binaries is exactly what a version check cannot see and a hash can.

Keep the hash next to the version string in your SBOM, not instead of it. The hash tells you changed; the version string tells you what the vendor thinks changed; only the test tells you whether it's fixed.

Step 1 — Build a Crash-Only Harness So a Decoder Bug Cannot Kill Your Test Run

The Node harness: spawn a child, decode hostile bytes there, assert on exit code and signal

One constraint drives the design: if the decoder has a memory-safety bug, the process dies without an exception. So the harness has to be a parent watching a child.

harness/decode-once.mjs
// imports trimmed — zlib and createHash


const CAP = 8 * 1024 * 1024;
const input = readFileSync(process.argv[2]);

function emit(obj) {
// writeSync so the record survives a fast exit
writeSync(1, JSON.stringify(obj) + '
');
}

try {
const out = zlib.brotliDecompressSync(input, { maxOutputLength: CAP });
emit({
  ok: true,
  bytes: out.length,
  sha: createHash('sha256').update(out).digest('hex'),
  maxRSSkb: process.resourceUsage().maxRSS,
});
} catch (err) {
emit({
  ok: false,
  name: err.name,
  code: err.code ?? null,
  maxRSSkb: process.resourceUsage().maxRSS,
});
}
harness/run-cases.mjs
// imports trimmed — readdirSync, path


const dir = process.argv[2];
const runner = new URL('./decode-once.mjs', import.meta.url).pathname;

for (const name of readdirSync(dir).filter((f) => f.endsWith('.br')).sort()) {
const started = Date.now();
const child = spawn(process.execPath, [runner, path.join(dir, name)], {
  stdio: ['ignore', 'pipe', 'ignore'],
  timeout: 10_000,
});

let out = '';
child.stdout.on('data', (d) => { out += d; });

const { code, signal } = await new Promise((resolve) => {
  child.on('close', (code, signal) => resolve({ code, signal }));
});

let parsed = null;
try { parsed = JSON.parse(out.trim()); } catch { /* child died before emitting */ }

console.log([
  name.padEnd(28),
  String(code).padStart(4),
  String(signal ?? '-').padStart(7),
  (parsed ? (parsed.ok ? 'ok' : parsed.name) : '-').padEnd(10),
  String(parsed?.maxRSSkb ?? '-').padStart(10),
  String(Date.now() - started).padStart(6),
].join(' '));
}

A run on my machine against four synthetic cases:

case                          exit  signal       name   maxRSSkb     ms
valid-q1.br                      0       -         ok      41822     13
valid-q11.br                     0       -         ok      42017     29
trunc-half.br                    1       -      Error      29984      9
flip-header.br                   1       -      Error      29871      8

Two things to read here. Both valid encodings of the same input produce the same output hash — that's your determinism baseline; if a patch moves it, something real changed. And I print err.name rather than err.code, because Node's error code strings are version-specific and I don't want sample output implying a stable value. If the decoder segfaults, exit is null and signal is SIGSEGV — that row is the one the harness exists to catch.

The .NET harness: a tiny console runner invoked per test case instead of an in-process try/catch

harness/DecodeOnce/Program.cs
using System.Diagnostics;
using System.IO.Compression;
using System.Security.Cryptography;

const long Cap = 8L * 1024 * 1024;

try
{
  using var file = File.OpenRead(args[0]);
  using var brotli = new BrotliStream(file, CompressionMode.Decompress);
  using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);

  var buffer = new byte[64 * 1024];
  long total = 0;
  int read;

  while ((read = brotli.Read(buffer, 0, buffer.Length)) > 0)
  {
      total += read;
      if (total > Cap) throw new InvalidOperationException("output cap exceeded");
      hash.AppendData(buffer, 0, read);
  }

  var sha = Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
  Console.WriteLine($"{{"ok":true,"bytes":{total},"sha":"{sha}"," +
                    $""peakWs":{Process.GetCurrentProcess().PeakWorkingSet64}}}");
}
catch (Exception ex)
{
  Console.WriteLine($"{{"ok":false,"type":"{ex.GetType().FullName}"," +
                    $""peakWs":{Process.GetCurrentProcess().PeakWorkingSet64}}}");
}

Invoked per case:

dotnet run --project harness/DecodeOnce -- corpus/trunc-half.br
{"ok":false,"type":"System.IO.InvalidDataException","peakWs":38289408}

Note the unit mismatch you'll trip over: process.resourceUsage().maxRSS in Node is kilobytes, PeakWorkingSet64 in .NET is bytes. Normalize before you diff, or you'll spend an afternoon chasing a 1024x "regression".

Inference, not tested: I expect truncated input to behave differently between the two runtimes — Node surfaces a decompression error, while BrotliStream has in some paths returned 0 bytes read rather than throwing. Don't take my word for it. That divergence is precisely what the differential table in Step 2 records, and getting it wrong in your head is how a silent-truncation bug reaches production.

Why in-process try/catch is the wrong shape for memory-safety bugs

A try/catch catches managed exceptions. It does not catch:

  • a SIGSEGV from a wild pointer inside BrotliDecoderDecompressStream
  • heap corruption that surfaces three allocations later as a crash in unrelated code
  • a stack overflow from unbounded recursion
  • the OOM killer ending your process because a stream expanded to 40 GB

Each of those ends or corrupts the process. The only reliable observation point is a parent reading exit status and signal — which is also why the harness belongs in a memory-limited container (--memory=512m), not on your laptop.

Step 2 — Differential Testing: the Old Decoder Against the New One

Build the corpus: valid, truncated, and structurally odd Brotli streams

Keep the corpus small and deterministic. This isn't fuzzing (that's Step 5); it's a fixed set of probes you re-run against every runtime build.

harness/build-corpus.mjs
// imports trimmed — zlib, mkdirSync, writeFileSync
mkdirSync('corpus', { recursive: true });
const base = Buffer.from('hackyjs '.repeat(4096));

function compress(buf, quality) {
return zlib.brotliCompressSync(buf, {
  params: { [zlib.constants.BROTLI_PARAM_QUALITY]: quality },
});
}

// 1. valid streams at several qualities
for (const q of [1, 5, 9, 11]) {
writeFileSync('corpus/valid-q' + q + '.br', compress(base, q));
}

// 2. truncations of a valid stream
const full = compress(base, 5);
for (const pct of [25, 50, 90]) {
const cut = Math.floor((full.length * pct) / 100);
writeFileSync('corpus/trunc-' + pct + '.br', full.subarray(0, cut));
}

// 3. structurally odd: keep the header, corrupt the payload
//    (the window-size bits live in the first byte — RFC 7932 section 9)
const odd = Buffer.from(full.subarray(0, 8));
odd[0] = 0x1b;
writeFileSync('corpus/odd-wbits.br', Buffer.concat([odd, Buffer.alloc(64, 0xff)]));

Keep base in the repo. Without the plaintext you can't tell "decodes fine" from "decodes to garbage", and the hash comparison in the next step has nothing to compare against.

The four signals to compare — exit status, output byte hash, exception type, peak RSS

Run the same corpus through the pre-patch and post-patch runtime, collect those four columns per case, diff. That's the whole method; everything else is plumbing.

A results table showing what each divergence means (fixed, unchanged, or differently broken)

Signal changeWhat it usually meansWhat to do
SIGSEGV → handled exception, same inputA crash became an error; consistent with a bounds or validation fixGood sign. Confirm output hash on valid cases is unchanged
Exception type unchanged on the crashing caseYour corpus does not hit the flaw, or the fix is not in this buildDo not conclude "not affected". Get the advisory or the patch
Valid case: exit 0, hash A → exit 0, hash BOutput changed for the same inputRed flag. Decompression is deterministic; treat as a correctness regression until explained
maxRSS 1.9 GB → 320 MBAllocation behavior changed — possibly a size-bound fixInvestigate the diff before calling it fixed; silent truncation looks like this too
Exit 1 → exit 0 with fewer bytes than the plaintextThe new build accepted input the old one rejected, partiallyLikely a relaxed check. Verify against the plaintext length, not just "no error"
Both builds crash identicallyYou have a reproducible bug and no fixThis is the valuable outcome: pin the input, open a report, and cap output

The fifth row is the one that bites. "It stopped throwing" is not "it decodes correctly", and a decoder returning partial data on a truncated stream is an integrity bug wearing a fixed bug's clothes. Always assert against the known plaintext length and hash for valid cases.

Step 3 — Enforce the Resource Bounds the Brotli Decoder Will Not Enforce for You

Measuring expansion ratio on a controlled, self-generated highly-compressible input

Brotli streams carry no declared uncompressed size. RFC 7932 has no such field, which means no decoder can pre-check output size for you — not Node's, not .NET's, not upstream's. The only pre-check available is the one you write.

Generate a worst-ish case yourself:

// expand.cjs
const zlib = require('node:zlib');
const input = Buffer.alloc(16 * 1024 * 1024, 0x41); // 16 MiB of 'A'
const packed = zlib.brotliCompressSync(input, {
  params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 },
});
console.log('input', input.length, 'packed', packed.length,
            'ratio x' + Math.round(input.length / packed.length));
$ node expand.cjs
input 16777216 packed 1056 ratio x15887

Exact byte counts move with encoder version and quality setting — the ratio, not the number, is the point. A 1 KB request body maps to 16 MB of output, and nothing in the format stops you stacking that. The decoder's ring buffer is bounded by the window size (BROTLI_MAX_WINDOW_BITS is 24, so ~16 MiB of history), but the window bounds memory, not output. That 16 MiB ring buffer will happily produce terabytes.

Streaming decode with a hard output cap in Node and in .NET

Cap in the sink, not the source. In Node:

lib/decode-capped.mjs
// imports trimmed — Writable, pipeline, createBrotliDecompress
export async function decodeCapped(source, capBytes) {
let total = 0;
const sink = new Writable({
  write(chunk, _enc, cb) {
    total += chunk.length;
    if (total > capBytes) {
      // stop pulling input as soon as the cap is crossed
      cb(new Error('brotli output cap exceeded at ' + capBytes + ' bytes'));
      return;
    }
    cb();
  },
});
await pipeline(source, createBrotliDecompress(), sink);
return total;
}

In .NET, the same idea, with the one detail that matters: BrotliStream.Read returning 0 means end-of-stream or a tolerated truncation, so track total output and compare it against what you expected.

DecodeCapped.cs
static long DecodeCapped(Stream source, Stream sink, long cap)
{
  using var brotli = new BrotliStream(source, CompressionMode.Decompress, leaveOpen: true);
  var buffer = new byte[64 * 1024];
  long total = 0;
  int read;

  while ((read = brotli.Read(buffer, 0, buffer.Length)) > 0)
  {
      total += read;
      if (total > cap)
          throw new InvalidOperationException("brotli output cap exceeded at " + cap + " bytes");
      sink.Write(buffer, 0, read);
  }
  return total; // compare to the expected length; do not assume 0-return means complete
}

Also set maxOutputLength in Node as a belt-and-braces second limit — it throws rather than truncating, and it protects the non-streaming path where your sink never gets a chance to intervene.

Window-bits and chunk-size knobs worth pinning in your own wrapper layer

node -p "Object.keys(require('node:zlib').constants).filter(k => k.includes('BROTLI')).join('\n')"

Check what your build actually exposes before depending on a constant name. The ones I care about:

  • Large-window decode: keep it off. Streams encoded with a window larger than 24 bits require explicit opt-in on the decoder. Never opt in, and a peer can't hand you a stream that pushes your decoder outside the standard window. Verify the constant exists in your build, then verify nobody is setting it.
  • Chunk size: pin it. 64 KiB read buffers are a reasonable default. A tiny chunk size turns a decompression bomb into a CPU attack even with a byte cap, because you make millions of tiny calls.
  • Encoder LGWIN, if you produce artifacts: stay within what your peers decode by default. Emitting large-window streams other services can't read is a self-inflicted outage.
  • Time limit, not just byte limit: a cap stops unbounded memory; it doesn't stop a stream that expands slowly forever. Wrap the pipeline in a timeout.
💪

Test the cap by asserting the error path: feed a 16 MB-expanding stream through a 1 MB cap and confirm you get your own error, not an OOM kill. A cap that has never fired is a cap you haven't tested.

Step 4 — Read the Patch Instead of the Release Note

Diffing the vendored bump commit for added bounds checks and size arithmetic

Release notes summarize intent. The vendored diff shows behavior. For Node:

git log --oneline -20 -- deps/brotli
git diff <old-sha> <new-sha> -- deps/brotli/c/dec/decode.c \
  | grep -E '^\+' | grep -iE 'bound|max|size|overflow|> *BROTLI'

For .NET, same idea against the vendored native tree:

git log --oneline -20 -- src/native/external/brotli

What I look for, in order: a new comparison against a maximum before an index or length is used; changes to size_t arithmetic on available_in / available_out; added validation before ring-buffer writes; and any new early return that turns a hard failure into a partial result. That last category is a memory-safety fix that can become a correctness problem, and the differential test in Step 2 is how you catch it.

What I can't tell you: which function in decode.c the reported defect lives in. I haven't seen the patch, and naming a function would be exactly the fabricated precision that makes a technical post worthless. Run the grep on the real diff and read the hunks yourself.

Why a backport can fix the bug while leaving the version string untouched

A distro applies the security hunk, not the version bump. The reasons are practical: a full upstream bump changes behavior in ways that need re-validation across hundreds of dependent packages, and the version string is an ABI expectation for anything that reads it.

What that means for your verification:

version string unchanged  →  cannot distinguish patched from unpatched
binary hash changed       →  something changed, but not what
behavior test changed     →  this is the signal you can act on

Which is the whole argument for keeping the corpus harness. When the version string is identical, it's the cheap way to tell a patched backport from an unpatched one.

Turning the diff into a regression test that pins behavior, not a version number

Don't write this:

assert.equal(process.versions.brotli, '1.0.9'); // breaks on every distro, proves nothing

Write this:

// a truncated stream must not produce output that silently looks complete
const truncated = readFileSync('corpus/trunc-50.br');
assert.throws(() => zlib.brotliDecompressSync(truncated));

// a valid stream must round-trip to a known hash, byte for byte
const out = zlib.brotliDecompressSync(readFileSync('corpus/valid-q11.br'));
assert.equal(sha256(out), KNOWN_SHA);

The first assertion encodes a security property. The second encodes a correctness contract that catches a decoder regression which "fixes" the crash by returning short output. Neither needs to know what version it's running on.

Step 5 — Fuzz the Surface You Actually Own

A mutation loop over a small valid corpus, time-boxed and seeded for reproducibility

harness/fuzz.mjs
// imports trimmed — spawnSync, readFileSync, writeFileSync, mkdirSync
let seed = 0x9e3779b9; // fixed seed: same run, same inputs, every time
function rand() {
seed ^= seed << 13; seed >>>= 0;
seed ^= seed >>> 17;
seed ^= seed << 5;  seed >>>= 0;
return seed / 0x100000000;
}

const base = readFileSync('corpus/valid-q5.br');
mkdirSync('crashes', { recursive: true });

const deadline = Date.now() + 60_000; // time-boxed
let iterations = 0;

while (Date.now() < deadline) {
const buf = Buffer.from(base);
const flips = 1 + Math.floor(rand() * 4);
for (let i = 0; i < flips; i++) {
  buf[Math.floor(rand() * buf.length)] = Math.floor(rand() * 256);
}

const r = spawnSync(process.execPath, ['harness/decode-once.mjs', '-'], {
  input: buf, timeout: 5000,
});

iterations++;
const diedBadly = r.signal !== null || r.status === null;
if (diedBadly) {
  const name = 'crashes/crash-' + iterations + '.br';
  writeFileSync(name, buf);
  console.log('signal', r.signal, '->', name);
}
}
console.log('iterations', iterations);

Two caveats, both real. This reads from -, so either change decode-once.mjs to accept stdin or write each mutant to a temp file. And the per-iteration spawn costs you: expect hundreds of iterations per second, not tens of thousands. That's the price of crash-only isolation. If you need throughput, build upstream's own libFuzzer harness against google/brotli directly — faster, and it exercises the same C code.

Corpus hygiene: never commit a crashing input without a minimized, safe-to-share version

A crashing input is a weapon until it's a fixed bug. What I do:

  1. Minimize first. Truncate from the end while it still crashes, then zero out trailing garbage. You usually end up with tens of bytes.
  2. Store the SHA-256 of the minimized input in version control, not the input itself, for anything still unpatched.
  3. Keep the actual bytes somewhere private until an advisory exists.
  4. When you share, share with maintainers (security@ for Node, the .NET security process), not a public issue.
  5. Only after a fix ships does the input become a regression test you commit.

What fuzzing will not tell you (and how to avoid over-reading a clean run)

A clean 60-second run means your mutation strategy didn't find anything. Nothing more. Specifically, it won't tell you:

  • that the reported bug is fixed (you don't have its PoC, so you can't know your corpus reaches that state)
  • anything about logic bugs that produce wrong output without crashing
  • anything about a path you disabled, like large-window decode
  • anything about the other decoder — you fuzzed Node and assumed .NET
  • whether a long-running OOM exists, because your timeout kills the process before it gets there

The worst outcome is a clean run producing false confidence in a service that auto-decompresses untrusted responses. Fuzzing supplements the cap and the isolation boundary; it doesn't replace either.

What "Verified" Actually Means Before You Ship

A claim-by-claim table: what each check proves and what it does not

Claim you might makeHow you check itWhat it provesWhat it does not prove
"We are on the patched version"Version string from process.versions.brotli / assembly infoWhat the vendor's build was labeledNothing, if a distro backported the fix; nothing about system-linked builds
"Our binary changed"SHA-256 of node / native compression libraryThe artifact differs from a previous buildThat the difference is a security fix
"The crash is gone"Corpus case that previously died returns an errorThat input no longer kills this processThat other inputs cannot; that the output is correct
"Output is bounded"Cap test fires at the expected byte countYour wrapper stops expansionThat CPU time is bounded, or that memory is bounded before your sink sees it
"We read the patch"Vendored diff with bounds checks identifiedWhat the code does nowThat your build includes that commit
"Nothing crashes under fuzzing"Time-boxed seeded mutation runYour corpus did not trigger a crashNearly nothing about the reported flaw

The honest summary: only rows 3 and 4 describe properties of your running system. Rows 1, 2, and 5 are evidence about artifacts. Row 6 is an absence of evidence.

The minimum bar for a service that auto-decompresses untrusted HTTP responses

If you own a service decoding Brotli from sources you don't control, this is the floor:

  1. Hard output cap, enforced in a streaming sink, on every decode path. Not a Content-Length check — that header describes the compressed size and is attacker-controlled anyway.
  2. A wall-clock timeout around the decode, so slow expansion dies too.
  3. Large-window decode stays off, verified by a test rather than assumed.
  4. Decode in a constrained process — worker with a memory limit, or a sidecar — for anything batch or high-volume. In-process is acceptable only for small payloads with a tight cap.
  5. Only decompress when you asked for it. Validate content-encoding and reject multi-encoding chains you didn't request.
  6. Pin the binary hash in your SBOM/image digest alongside the version string.
  7. Ship the corpus in CI: valid, truncated, and mutated cases, run through the crash harness on every runtime upgrade.
📝

Item 3 depends on which constants your runtime exposes. Run the constants check on your actual build before writing the assertion — otherwise you'll ship a test that silently passes because the property it inspects doesn't exist.

Limitations and Open Questions

What I could not confirm from the source material, and how to close each gap with a primary source

GapWhy it mattersHow to close it
No CVE or advisoryCannot map to your version range or severitySearch the Node.js security release list and the dotnet/runtime security advisories for Brotli
No affected version rangeCannot decide which of your services to patch firstThe advisory's affected-products table; do not infer it from the report's publication date
No bug classMemory corruption needs isolation; resource exhaustion needs capsThe patch diff, or the advisory's description of the defect
No PoCDifferential testing has no targeted caseAsk the maintainers, or reconstruct from the patch hunks
"Attacks confirmed" is ambiguousExploited-in-the-wild changes your response timelineVendor advisories and any KEV-style exploited-vulnerability list; a lab PoC does not belong there
Runtime fix status unknownYou may already be patchedCompare your binary hash across the versions your distro shipped

I ran the harness code against synthetic Brotli streams on this machine. I did not run the reported attack, I don't have the reported proof-of-concept, and I have not verified that any specific Node or .NET build is affected or fixed. The workflow is what I'm confident about; the vulnerability details are not mine to assert.

One claim I'd defend without reservation: if your service decodes Brotli from arbitrary sources and you have no output cap, that's a bug you own regardless of what any advisory eventually says.

Further Reading

Share this post

More posts

Loading comments…