DEV Community

Cover image for I scanned my machine for the Brotli RCE. Three stacks were exposed — and the patched one still reports the vulnerable version.
frank chu
frank chu

Posted on

I scanned my machine for the Brotli RCE. Three stacks were exposed — and the patched one still reports the vulnerable version.

On Wednesday, Node.js disclosed CVE-2026-111497 — "Node2Shell" — an out-of-bounds write in Google's Brotli decoder that can give a remote, unauthenticated attacker code execution in any process that decompresses attacker-controlled Brotli data. By Thursday it had a Python chapter. By Friday, Aikido was calling the wider thing "HTTP2Shell," because the vulnerable decoder is bundled into runtimes, HTTP clients, gateways, and half the internet's plumbing. CVSS v4.0: 9.2.

The part that got my attention wasn't the bug. It was this sentence in Aikido's write-up: as of October 23, advisory databases didn't map the CVE to either PyPI package, so pip-audit, Dependabot, and Safety wouldn't flag vulnerable versions. A critical RCE that your scanner cannot see is a specific kind of problem — the kind where "we run pip-audit in CI" and "we are patched" stop having anything to do with each other.

So I audited the machine I'm writing this on. Not a production fleet — one Linux VM, the box that runs my cron jobs and builds my projects. If it's exposed in three places, yours probably is too.

First: what's actually on this box

Brotli reaches a machine through more doors than you installed it through. I checked the three stacks I know about:

Python (pip/distro package)   Brotli 1.1.0     affected range: <= 1.2.0 (PyPI)
Node.js v24.22.1              bundled Brotli reports "1.2.0"
system libbrotli (dpkg)       1.1.0-2build2    curl 8.5.0 links brotli/1.1.0
brotlicffi                    not installed
Enter fullscreen mode Exit fullscreen mode

Three stacks, three separate Brotli builds, none of them something I ever typed install brotli to get. Python's copy arrived as a distro package. Node's is compiled into the runtime. curl's is the system library. This is the defining feature of this CVE: the vulnerable code is a transitive property of other software, and each copy gets patched (or not) on its own vendor's schedule.

Two honesty caveats before the numbers. First, version strings are evidence, not proof: Linux distributions backport security fixes without bumping upstream version numbers, so a "1.1.0" distro package may or may not contain the fix — the vendor's own advisory is the tiebreaker. Second, as you'll see in the Node section, the same lie runs in the opposite direction.

The scanner experiment: the blind window is real, and it already closed

Aikido's claim was time-stamped — "as of October 23." Claims like that expire, so I ran the experiment instead of repeating it. OSV now carries PYSEC-2026-4322 (PyPI brotli) and PYSEC-2026-4323 (PyPI brotlicffi), both published October 23 and modified this morning, October 24, at 10:55 UTC. I pointed pip-audit 2.10.1 at four one-line requirements files:

Brotli==1.2.0        -> FOUND  CVE-2026-111497 / GHSA-9v8h-qqgm-4m35, fix: 1.2.1
Brotli==1.1.0        -> FOUND  CVE-2026-111497 / GHSA-9v8h-qqgm-4m35, fix: 1.2.1
Brotli==1.2.1        -> No known vulnerabilities found
brotlicffi==1.2.0.2  -> FOUND  CVE-2026-111497 / GHSA-9v8h-qqgm-4m35, fix: 1.2.1.0
Enter fullscreen mode Exit fullscreen mode

So the blind window was roughly two days — disclosure on the 21st–22nd, database mapping on the 23rd — and it has closed for the PyPI ecosystem, if your scanner actually refreshes OSV data. That's the part worth internalizing: for about 48 hours, every Python shop running a scanner got a green checkmark on a critical RCE. The scanner wasn't broken. The data wasn't there yet. "Scanner says clean" always means "scanner's database says clean as of its last sync," and this week that distinction had a CVSS 9.2 inside it.

The two databases don't even agree with each other yet. OSV's PYSEC entry enumerates 10 affected brotli versions (0.5.2 through 1.2.0); the GitHub Advisory entry for the same CVE enumerates 7 (1.0.4 through 1.2.0). The fixed-version range is the same (1.2.1), so the practical answer doesn't change — but if your tooling diffs version lists rather than ranges, the lists currently differ.

One more range subtlety. Aikido's patch table lists branch fixes for the C library — 1.0.10 and 1.1.1 alongside 1.2.1. Those exist as upstream Brotli releases. There is no Brotli==1.1.1 on PyPI; for the Python package, 1.2.1 is the only fixed line OSV knows. Don't sit on 1.1.0 waiting for a 1.1.1 pip release that isn't coming.

The Node paradox: the patched runtime still reports the vulnerable version

This box runs Node v24.22.1, which is one of the fixed releases in Node's October security batch. Its changelog entry for the CVE is a cherry-pick of upstream commit 0ed4268 — the actual fix, backported. And yet:

$ node -e "console.log(process.versions.brotli)"
1.2.0
Enter fullscreen mode Exit fullscreen mode

A scanner that reads process.versions.brotli — or any version-string inventory — will flag this patched runtime as vulnerable forever, because a cherry-picked fix doesn't bump the bundled library's version string. Put the two halves together and you get the week's real lesson:

  • For ~48 hours, Python scanners missed a vulnerable library that was actually there.
  • Indefinitely, version-based Node scanning invents a vulnerable library that isn't there.

The version number is a proxy for the code. This CVE breaks the proxy in both directions at once.

I diffed the fix. It's 3 files that matter.

Since the release notes for the fixed PyPI versions don't mention the CVE (Aikido notes this, and it's true of the artifacts I pulled), I downloaded both source distributions from PyPI and diffed them myself:

brotli-1.2.0.tar.gz   7,388,632 bytes
brotli-1.2.1.tar.gz   7,389,447 bytes

files differing: 5 (2 are PKG-INFO metadata)
  c/common/version.h        2 added,  2 removed   (version bump)
  c/include/brotli/decode.h 1 added,  3 removed   (error -17: "reserved" -> BLOCK_SWITCH)
  c/dec/decode.c           59 added, 30 removed   (the actual fix)
Enter fullscreen mode Exit fullscreen mode

The root cause is a classic, and the diff tells it cleanly. DecodeBlockTypeAndLength() returned a boolean, and BROTLI_FALSE meant two different things: "this stream is malformed" and "I need more input." On the fast path, callers discarded the return value entirely (the CVE pairs CWE-252, unchecked return value, with CWE-787, out-of-bounds write). A crafted stream that declares a single block type and then exhausts it forces a block switch that cannot succeed, and the decoder keeps going with stale state — into memory it shouldn't touch.

The fix changes the return type from boolean to an error code, splits the two meanings apart (BROTLI_DECODER_NEEDS_MORE_INPUT vs the newly un-reserved BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH, -17), and makes the three block-switch call sites actually check the status via a new BROTLI_SAFE_WITH_STATUS macro. That's the whole patch. Sixty-two net lines in one decoder file, sitting unfixed-in-released-form since February — OpenAI, who found the bug that month, says the root cause was fixed in the Brotli source back then too, but nobody recognized it as a security issue, so no release shipped it until this week. The fix existed for ~8 months. The release is what was missing.

The exposure path, verified locally

"You're exposed if a library you use decompresses Brotli" sounds abstract until you watch it happen. My local urllib3 is 2.0.7. Two facts, both measured, not quoted:

from urllib3.util.request import make_headers
make_headers(keep_alive=True, accept_encoding=True)
# {'accept-encoding': 'gzip,deflate,br', 'connection': 'keep-alive'}
Enter fullscreen mode Exit fullscreen mode

When the Brotli package is importable, urllib3 advertises br on every request — which is why requests and httpx users are in scope without ever importing Brotli themselves. And the decode side doesn't care what you advertised:

local server test (urllib3 HTTPResponse, Content-Encoding: br,
client never sent Accept-Encoding: br):
  33-byte Brotli body -> decoded to the original 190 bytes, no complaint
Enter fullscreen mode Exit fullscreen mode

urllib3 picks its decoder from the response's Content-Encoding header, full stop. A malicious server can send Brotli to a client that never asked for it, which is exactly why Aikido says removing br from your Accept-Encoding is not a fix. Uninstalling the package, or rejecting br at a proxy before anything decodes it, is.

What I'm doing about it, and the scope of this post

Upgrade paths (from the vendor advisories): PyPI Brotli → 1.2.1, brotlicffi → 1.2.1.0; Node → 22.23.4 / 24.22.1 / 26.12.1 or newer; Bun → 1.4.4; native libbrotli → 1.2.1 (or your distro's security update); Envoy and Istio have their own fixed trains. Rebuild container images — an image built last week stays vulnerable no matter what your lockfile says today. If you can't upgrade yet, uninstalling the Python packages genuinely removes those decode paths (gzip/deflate keep working), and a proxy rule rejecting Content-Encoding: br in both directions buys time.

What I did not do: I didn't build the crafted stream or run the ASan proof-of-concept. Everything above is version inventory, scanner behavior, a source diff, and decoder-selection behavior on my own machine — that's enough to act on, and the exploit itself adds heat, not information. The one number I can't give you is whether your box is exposed, because that was the whole point of this post: until two days ago your scanner couldn't tell you either, and on Node it still can't — in the opposite direction.

Sources: Aikido's Node2Shell/HTTP2Shell write-up (aikido.dev, updated Oct 23), Node.js October 2026 security releases, OSV entries CVE-2026-111497 / PYSEC-2026-4322 / PYSEC-2026-4323 / GHSA-9v8h-qqgm-4m35 (queried Oct 24). All version checks, pip-audit runs, the sdist diff, and the urllib3 test are my own runs on a 2 vCPU Linux VM today.

Top comments (0)