Skip to main content

On Tuesday 21 October, Node.js disclosed CVE-2026-111497, an out-of-bounds write in the Brotli decoder that lets a remote, unauthenticated attacker corrupt memory and potentially run code. By Thursday, Python shipped the same fix for its native Brotli bindings. By Friday the blast radius had a new nickname, HTTP2Shell, as the industry worked out that anything decoding attacker-controlled Brotli over HTTP was in scope. OpenAI then added the detail that stings: they found the bug in February, the root cause was quietly fixed in the Brotli source at the time, and nobody recognised it as a security issue, so no release shipped the fix for eight months.

That is a good story, and it has been told well elsewhere. We were interested in a smaller, more practical question. If you want to know whether a given machine is exposed, the first thing any runbook tells you to do is check the version. So we did. On one stock Ubuntu box we found two independent Brotli decoders, both reachable by ordinary application code, and both reporting exactly the same version string. That string cannot tell you whether either one is patched.

TL;DR

  • CVE-2026-111497 is a CVSS 9.2 out-of-bounds write in Google’s Brotli C decoder, affecting versions 1.0.3 through 1.2.0. Upstream fixes are 1.0.10, 1.1.1 and 1.2.1.
  • On one unremarkable box we found two native Brotli decoders an app can reach: the system libbrotlidec.so.1 and the copy Node.js bundles. Both report version 1.1.0. The version string is identical whether the code is vulnerable or fixed.
  • The reason is that the people who ship Brotli to you, Node and your Linux distribution, patch it in place. Node’s fix lands in decode.c; the version constant lives in version.h and is untouched, so patched Node v22.23.4 reports the same 1.1.0 as unpatched v22.22.0.
  • The most-downloaded “brotli” npm package (73 million downloads a month) is a pure-JavaScript port and is not affected by this C decoder bug. The exposure is in node:zlib, native bindings like iltorb, and WASM builds compiled from the vulnerable source.
  • Triage on the artefact, not the version. For Node, read the release number. For a Linux package, read the distribution revision (dpkg -s libbrotli1), not BrotliDecoderVersion().

What the bug actually is

Brotli is Google’s general-purpose compression format. You almost certainly use it without thinking about it: it is one of the three encodings a browser advertises in Accept-Encoding, alongside gzip and deflate, and it is the format behind the br token in a Content-Encoding header. Any service that accepts a compressed request body, and any client that accepts a compressed response, may hand attacker-controlled bytes straight to a Brotli decoder.

The flaw is narrow and nasty. An invalid block switch in the compressed stream was reported by the decoder as “I need more input” rather than “this data is malformed”. The streaming decoder, believing more input would complete the block, kept appending bytes to an eight-byte internal buffer. Past byte eight, it is writing over adjacent decoder state. From there an attacker can crash the process, read memory that was never meant to leave it, or, in the worst case, steer execution. It carries a CVSS v4.0 score of 9.2 and works in both directions: a malicious server can attack a client that accepts compressed responses, and a malicious client can attack a server that decompresses request bodies.

Crucially, this is a bug in the C decoder in c/dec/decode.c. That distinction decides who is exposed, and it is the first thing most of the panic gets wrong.

Two decoders, one box, one version string

Here is the measurement. On a current Ubuntu 24.04 machine, with nothing exotic installed, an application can reach a native Brotli decoder by at least two completely separate routes.

The first is the system shared library. It is present because half the desktop and server stack links against it:

$ dpkg -s libbrotli1 | grep Version
Version: 1.1.0-2build2

$ python3 -c "import ctypes; l=ctypes.CDLL('libbrotlidec.so.1'); \
  l.BrotliDecoderVersion.restype=ctypes.c_uint32; v=l.BrotliDecoderVersion(); \
  print(f'{v>>24}.{(v>>12)&0xFFF}.{v&0xFFF}')"
1.1.0

The second is Node. Node does not call out to the system library; it bundles its own copy of the Brotli source and exposes it through node:zlib. Every Express app using the compression middleware, every service that calls zlib.brotliDecompressSync on an upload, runs this copy:

$ node -e "console.log(process.version, process.versions.brotli)"
v22.22.0 1.1.0

Two decoders. Two unrelated code paths. One version string: 1.1.0. That number sits squarely inside the vulnerable range of 1.0.3 to 1.2.0, so a scanner reading it would, correctly in this case, flag both. The problem is the inverse. When these get patched, the number will not move, and then the same 1.1.0 will mean “safe”. The string is doing no work at all.

Why the number does not move

Upstream Google did bump the version. The fix for the 1.1.x line is tagged 1.1.1. If you build Brotli from source and track releases, you get an honest version bump. Almost nobody does that. You get Brotli from two intermediaries, and both of them patch in place.

Node bundles the Brotli C source in its tree and ships it inside the runtime. The October security release patched the decoder for the 22.x, 24.x and 26.x lines, landing fixed builds v22.23.4 and v24.22.1 on 20 October. The fix is a change to decode.c. The version constant Node reports through process.versions.brotli comes from version.h, which the patch does not touch. So a patched v22.23.4 and an unpatched v22.22.0 both answer 1.1.0. The only field that distinguishes them is the Node version itself. The Brotli version string is inert.

Linux distributions do the same thing by a different mechanism. Debian and Ubuntu do not ship upstream 1.1.1; they backport the security patch onto the 1.1.0 package and bump the packaging revision. The box above reads 1.1.0-2build2 today, with no security update yet offered on its mirror. When the fix arrives it will look like 1.1.0-2ubuntu0.1 or similar. The upstream version, the one BrotliDecoderVersion() returns, stays 1.1.0 forever. The only truthful field is the part after the hyphen, and that is visible to dpkg, not to the running code.

This is the whole point. The version string a Brotli decoder reports about itself was never designed to encode patch status, and for a bundled or distribution-packaged library it structurally cannot. If your asset inventory keys off that string, it will read “1.1.0” before the fix and “1.1.0” after it, and call that a clean bill of health.

Who is actually exposed, and who just looks it

Because the bug is in the C decoder, the honest exposure map does not follow the download charts. We pulled a month of npm numbers for the obvious candidates, and the headline figure is a trap.

The brotli package on npm has 73 million downloads a month, by far the largest. It is also not affected. It is a hand-written pure-JavaScript port of the algorithm, originally built for WOFF2 font decompression, with no connection to the C code that carries CVE-2026-111497. A naive search for “brotli” in a lockfile will surface this package first and send a team chasing a dependency that was never vulnerable.

The real exposure is spread across quieter packages:

  • node:zlib itself, the built-in. This is the big one. It underpins the compression middleware (169 million downloads a month) and anything that calls brotliDecompressSync directly. Fixed by upgrading Node, invisible to process.versions.brotli.
  • iltorb (490,000 a month), native bindings that link the C library through a prebuilt binary. Affected on the same terms as the C path, and long deprecated in favour of the built-in, which means plenty of installs are frozen.
  • WASM builds such as brotli-wasm (5.7 million a month), wasm-brotli and brotli-dec-wasm. These are compiled from the same C source, so a build made from a vulnerable version carries the bug. The saving grace is the sandbox: an out-of-bounds write stays inside the module’s linear memory, so the realistic outcome is denial of service or memory disclosure within the WASM instance, not host code execution. Still worth rebuilding, not worth panicking over.

Three different decoders, three different risk profiles, and the version string on the tin tells you nothing about which one you have or whether it is fixed.

What to actually check

The triage that works ignores the Brotli version entirely and reads the artefact that ships it.

  1. Node services: check process.version, not process.versions.brotli. You want 22.23.4, 24.22.1 or later on the respective lines. Then grep your code and middleware for br handling: compression, shrink-ray-current, any manual Content-Encoding: br parsing, and any place you decompress an upload.
  2. System packages: read the distribution revision with dpkg -s libbrotli1 or rpm -q, and compare it against your distro’s security tracker for CVE-2026-111497. The upstream 1.1.0 is meaningless here; the -2ubuntu0.1 style suffix is the signal.
  3. Python: the native bindings (Brotli/brotlicffi) got their fix on 23 October. If you decode Brotli in Python, pin to the patched release and confirm it is the C-backed package, not a pure-Python fallback.
  4. If you cannot patch immediately, the interim mitigation is to stop decoding Brotli you did not produce. Drop br from the accepted encodings on any endpoint that decompresses request bodies, and on clients that fetch from untrusted origins. Nginx and Apache do not decode Brotli by default, so a reverse proxy in front of an app server is not itself the exposure, but it will happily pass the compressed body through to one that is.

This is not the “wrong version number” problem from August

We wrote in August about a WordPress plugin where a vulnerability scanner read the wrong version number because seven separately versioned editions shipped under one slug, so a paid install eleven releases above the “fixed” line showed green. That was a matching problem: the right number existed, the database just compared it against the wrong range.

This is worse in a quiet way. Here there is no version number that distinguishes vulnerable from fixed. Two binaries, genuinely different, one with a critical RCE and one without, report the same 1.1.0 by design. No better matching, no cleaner database, no tighter range would fix it. The identifier a bundled C library exposes about itself simply does not carry the fact you need. You have to go up a layer, to the runtime or the package, to find a number that moved.

Where REPTILEHAUS fits

Most dependency audits stop at the lockfile, and a lockfile is exactly the wrong altitude for a bug like this. The vulnerable code here is not a line in your package.json; it is bundled inside the runtime you deploy on and vendored into transitive native modules you never chose. Knowing where a compression library actually lives in a running system, and which of your services feed it untrusted bytes, is the work. We build and maintain production systems for clients across web, SaaS and DevOps, and incident response like this is part of the brief: mapping the real exposure, patching the artefact rather than the symptom, and leaving behind an inventory that reads the field that tells the truth.

If CVE-2026-111497 has your team staring at a version string that will not answer the question, get in touch. We would rather help you check the right number once than watch a scanner call it green twice.

📷 Photo by Luke Heibert on Unsplash