
No. 10 · 30 October 2026 · Security · Supply Chain
Broccoli2Shell: counting vendored Brotli decoders hiding in the software supply chain
One small C decoder, copied into runtimes, bindings and databases, and an exploited bug in all of them. A plan to count the vulnerable copies inventories miss, and a cheap receipt that already tells patched from vulnerable in real release files.
If you don't work in software security, here is what this is about.
Brotli is a way of compressing data, made by Google, used among other things to shrink pages and responses sent over the web. The code that unpacks it, the decoder, is free to copy, so many programs carry their own copy inside them; programmers call that "vendoring". In October 2026 fixed versions came out for a flaw in that decoder, catalogued as CVE-2026-111497: a specially made compressed stream can take over the program unpacking it. CISA, the US government's cyber defense agency, lists it among flaws attackers are already using 5.
The trouble is that copies hide. Upgrading the obvious package doesn't fix a copy compiled into some other program, and the tools companies use to list the software they run may not see those copies at all.
This essay proposes counting how many unfixed copies ship in public, downloadable code, and how many of them the usual tools miss. It also tests a cheap way to tell a fixed copy from an unfixed one. It matters because the decoder can sit in software that reads data sent by strangers.
CVE-2026-111497 is an out-of-bounds write (writing data past the end of the space set aside for it) in Google's Brotli C decoder, versions 1.0.3 through 1.2.0. It reaches code execution on official Node.js (a JavaScript runtime for building network applications) builds and was added to CISA's Known Exploited Vulnerabilities catalog on 27 October 2026 45. The decoder is small, permissively licensed and copied everywhere: into language runtimes, Python and Ruby bindings, databases and proxies. Each copy is patched on its owner's schedule, and many don't appear under any Brotli name in an inventory 3. This essay proposes a census of how many vulnerable copies ship in PyPI and npm artifacts with native code (packages from the public Python and JavaScript package registries that include compiled machine code), and how many of them standard inventory tools miss. It also reports one small measurement made for it. The fix added a new error code whose name is compiled into the binary as text. In eight official release artifacts (four Node.js builds and four Python wheels (prebuilt Python packages)) that string's presence matched patch status in every case. Eight default builds don't validate a detector. The census, and the validation that has to come first, haven't been run.
- The bug: An invalid block switch was reported as "need more input", so the decoder kept appending bytes to an eight-byte buffer and wrote past it 47. Fixed in Brotli 1.0.10, 1.1.1 and 1.2.1 2.
- Exploited: ELLIO's (ELLIO is a threat-research firm that runs its own network of decoy systems) own chain got a shell (control of the machine's command line) on four stock Node.js 24.22.0 targets. A captured attacker chain failed 50 of 50 times because its last stage was broken 4. The failure belongs to the attacker, not to the bug.
- The version string misleads: Patched Node.js 24.22.1 still reports Brotli 1.2.0, and 22.23.4 reports 1.1.0 8.
- A cheap receipt: The fix's error name,
_ERROR_FORMAT_BLOCK_SWITCH, is present in all four patched artifacts tested here and absent from all four vulnerable ones. - The census: Count PyPI and npm artifacts that ship native code, in three tiers, and compare each tier with what Syft and Grype (inventory tools: Syft lists the software it finds, Grype checks that list against known flaws) report.
Here is the case the census is looking for. A team gets the advisory, upgrades the brotli package in its lockfile (the file that pins a project's package versions), reruns the scanner, and the dashboard goes green. Somewhere in the same image, a native extension built two years ago carries its own copy of the decoder, compiled in. Nothing names it, and it decompresses whatever a partner's API sends back.
One question, three desks: does the inventory describe the code that actually runs?
- Engineers: which processes in production decode Brotli from someone else, and from which copy?
- Security leaders: when a scanner says "not affected", has it seen the decoder, or only failed to find a package name?
- Executives: a KEV deadline is met by fixing the code, not by updating a manifest. Can anyone show the deployed binary is the fixed one?
Drafts here go through the Moot before I read them: a panel of frontier models I call by their lab bosses' first names (Dario, Sam, Sundar), plus Eddie, the house model that drafts. After the first version went up, three outside readers (Claude, OpenAI, Gemini) sent critiques, and this version is what came back. The first version cited ELLIO's findings to CISA, misdescribed the bug, and treated the attacker's 50 failures as a reason to relax. All three are fixed below. Every source was re-read on 31 October 2026. The string check in section 4 was run on the official release files.
A Brotli stream switches between blocks of data, and each switch names which block type comes next. If only one type has been declared, a switch is invalid and the decoder should reject the stream. Instead, the helper that reads a switch returned a plain boolean. It returned false for that invalid switch, and false again when it had simply run out of input partway through. The caller treated both as "send more input". A real shortfall drains the decoder's eight-byte holding buffer on the next chunk; the invalid switch never does. So the bytes keep arriving, and past the eighth byte the writes land outside the buffer 47. CWE-252, an unchecked return value, is the bug. CWE-787, an out-of-bounds write, is what it causes 5.

The fix, upstream commit 0ed4268a, gives the helper its own error type. An invalid switch now returns BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH, value -17, and stops. In Brotli 1.2.0, -17 was a reserved, unused code 1. The commit is dated 27 February 2026. The fixed releases, 1.0.10, 1.1.1 and 1.2.1, were published on 21 October 12. The bug itself shipped in 1.0.3, in March 2018 4.
Then it moved fast:
- Node.js fixed its bundled copy in 22.23.4, 24.22.1 and 26.12.1, on 20–21 October 7.
- ELLIO reproduced code execution on the official Node.js 24.22.0 x86-64 binary against four default targets: a plain
node:httpserver, the officialnode:24.22.0-slimimage, Express 5.3.0 and Koa 3. With address-space randomization on, a shell came at around attempt five 4. - At 15:08 UTC on 26 October, ELLIO's sensors caught a 23 MB exploit request aimed at that same binary. Its delivery stages were copied from public proofs of concept, and its final stage was original. ELLIO tested it 50 times against a vulnerable target and got no shell: the chain fired and crashed the process but never reached
execvp. ELLIO calls it "a working framework with a defective last 12 instructions" 4. - CISA added the CVE to the KEV catalog the next day, with a due date of 10 November 2026 5.
Read those last two together. A broken attacker chain says something about that attacker. ELLIO's working chain says what the bug allows. KEV listing requires evidence of exploitation, not just severity. The required action for federal civilian agencies runs through BOD 26-04, which asks each agency to weigh every asset's internet exposure 5. Google's CNA scores the bug 9.2 under CVSS 4.0 (the standard 0-to-10 severity scale);
Node.js rates it High 47. A service with no restart supervisor doesn't need the exploit at all: in ELLIO's lab, the second request crashed it and it stayed down 4.

"Vendored" covers three different situations, and they need different fixes:
| Tier | What it looks like | Examples | How it gets fixed |
|---|---|---|---|
| A. Bundled in a runtime | The decoder is compiled into the interpreter or server | Node.js, Bun, Envoy, the .NET runtime | A runtime upgrade, and every image built on it rebuilt |
| B. The binding is the decoder | A package whose job is Brotli, shipping its own compiled copy | PyPI Brotli and brotlicffi , the Ruby gem, the PHP extension | A package upgrade, which manifests do see |
| C. A private copy | A package that compiles Brotli into its own native code for its own reasons | DuckDB, ClickHouse, Apache Arrow's bundled build | That project's next release, whenever it comes |

Tier B is where most copies probably live, and it's the case inventories handle best. The package is named Brotli, so its version is visible. Python's urllib3, requests, httpx and aiohttp don't carry a decoder at all. They use whichever binding is installed, and they decide from the response's Content-Encoding, whatever the request asked for 4. Tier C is the one no manifest describes. As of 25 October, a public tracker cited by the Cloud Security Alliance still listed the .NET runtime, DuckDB, ClickHouse, Arrow's bundled build and several archivers as unpatched 3.
Linking against the system library is the exception that works. Debian's nodejs uses the system libbrotli, so the fix lands with the library package 4. Official Node.js binaries, and the Docker images built from them, carry their own copy 48. The Go port, andybalholm/brotli, is a separate implementation with no C code to match. Its v1.2.7 is described as "a port of" the upstream fix 10.
Node.js cherry-picked the fix without changing the bundled version number. In Node's own source tree, v24.22.1's Brotli version.h still says 1.2.0 and v22.23.4's says 1.1.0, and both contain the fix 8. So process.versions.brotli reports the same number on a patched build and a vulnerable one 4. Linux distributions do the opposite kind of damage: they backport fixes and keep the upstream version, so a version-only scanner flags patched systems 3. Wrong in both directions, and the false "fixed" is the one that costs. A version string is a promise, not a receipt.

Inventory tools aren't all manifest readers. Syft has binary catalogers that inspect ELF files and classify known binaries by their contents 9. At commit e6813ba (30 October 2026), its binary classifier list has 88 entries, and none is for Brotli 9. So a tier-C copy reaches Syft's output only if some other cataloger happens to name it. Syft and Grype also do different jobs: Syft lists what it finds, and Grype matches that list against vulnerability data. A missed finding can fail at four points, and the census should record which:
- the decoder wasn't found
- it was found but not named or versioned
- a backport wasn't recognized
- the CVE wasn't matched
None of those failures means a tool has called the binary safe. An empty result isn't an assertion.
The fix leaves a mark that version numbers don't. Brotli compiles the name of every error code into the decoder through BrotliDecoderErrorString, so the binary carries strings like _ERROR_FORMAT_DISTANCE, which is code -16 and present in every version. Code -17 was reserved and unnamed until the fix 1. A build that includes the error-name table therefore carries _ERROR_FORMAT_BLOCK_SWITCH only if it has the patched header. The -16 name is the control: it shows the table was linked at all.

I checked this on the official release files. Nothing was executed; each binary was only searched for the two strings:
| Artifact | Patch status | BLOCK_SWITCH | DISTANCE (control) |
|---|---|---|---|
| Node.js 24.22.0, linux-x64 | vulnerable | absent | present |
| Node.js 24.22.1, linux-x64 | fixed | present | present |
| Node.js 22.23.3, linux-x64 | vulnerable | absent | present |
| Node.js 22.23.4, linux-x64 | fixed | present | present |
| PyPI Brotli 1.2.0, CPython 3.12 x86_64 wheel | vulnerable | absent | present |
| PyPI Brotli 1.2.1, CPython 3.12 x86_64 wheel | fixed | present | present |
| PyPI brotlicffi 1.2.0.0, abi3 x86_64 wheel | vulnerable | absent | present |
| PyPI brotlicffi 1.2.1.0, abi3 x86_64 wheel | fixed | present | present |
The checksums of the Node.js archives match the published SHA256 lists. The 24.22.0 binary is the one ELLIO identified as the attacker's target 4. That's eight for eight, and it validates very little. These are default release builds from the projects' own pipelines, two of each kind. Three things could break the method:
- A build that never calls
BrotliDecoderErrorStringmay drop the whole table, and the compiler gets a vote here, and it rarely votes the way you hoped. - A vendor could fix the logic without taking the header change.
- A fork could rename things.
So the string gives a three-way answer. Present means patched. Control present but BLOCK_SWITCH absent means vulnerable, unless the vendor fixed it some other way. Both absent means the cheap check can't tell.
The cases it can't settle need the expensive check. That doesn't mean recovering a C return type, which doesn't survive compilation. It means confirming behavior: on the invalid- switch path, does the decoder stop with a fatal error before anything is written to the holding buffer, or does it report a need for more input? That can be found by matching the decoder's code regions and following that one branch. Inlining and link-time optimization make it harder, and it's still a narrower question than "does this look like version 1.2.1?"
So the classifier has four outcomes, kept apart:
- VULNERABLE: a decoder was found, and the vulnerable behavior or string pattern established.
- PATCHED: a decoder was found, and the fix established.
- ABSENT: sufficient evidence that no Brotli C decoder is present.
- INDETERMINATE: not enough evidence either way.
INDETERMINATE is never reported as clean. In the validation it counts as a miss.
The population comes first. Most popular PyPI and npm packages are pure Python or pure JavaScript, with no native code and nothing to find. Counting them only dilutes the result. The census covers artifacts that ship native code:
- PyPI wheels containing
.so,.pydor.dylibfiles - npm packages containing
.nodefiles, including their prebuilt optional-dependency packages
Each is drawn from the top of each registry by download count, with the cut-off and date fixed in advance. The unit is the artifact, so a project that publishes fourteen wheels is fourteen observations, and results roll up to projects separately. PyPI and npm, and tiers A, B and C, are reported separately. Three numbers come out, with different denominators:
| Metric | Denominator | What it says |
|---|---|---|
| Brotli prevalence | All native artifacts in the corpus | How often a decoder ships at all |
| Vulnerable share | Artifacts where a decoder was found | How often the copy found lacks the fix |
| Inventory miss rate | Artifacts the census confirms vulnerable | How often Syft fails to list the decoder, and how often Grype fails to report the CVE, counted separately |

Code being present isn't the same as attacker-reachable. The census measures presence; reachability belongs to whoever deploys the artifact.
The detector is validated before it's trusted. The validation corpus is built from source:
- every affected upstream version from 1.0.3 to 1.2.0, and the three fixed releases
- the Node.js cherry-pick
- two compilers, four optimization levels, with and without link-time optimization, stripped and unstripped
- x86-64 and arm64
It's split by source version, not at random, so the detector can't pass by recognizing a compiler instead of a fix. Report precision, sensitivity, false-positive rate and the INDETERMINATE rate separately, per build configuration. One bound needs its arithmetic stated. If a detector makes no false calls in n independent negatives, the one-sided 95% upper bound on its false-positive rate is about 3/n 11. Zero errors in 50 builds bounds it near 6%; getting to 1% takes about 300 11. That bound is about false positives on the tested builds and nothing else.

Wiz reports that 91% of cloud environments have resources vulnerable to this CVE and 82% have publicly exposed Node.js 6. That counts environments, not artifacts. One unpatched Node.js image can sit in thousands of environments, and a hundred vulnerable niche wheels might sit in none. The census answers a different question, and neither number checks the other.
The order matters:
- Upgrade or rebuild what can be upgraded.
- Confirm the deployed binary is the fixed one. The string check above is enough for most of tiers A and B.
- Find every path by which attacker-controlled Brotli reaches a decoder.
- Contain only those paths, and only until the fix ships.
A Dockerfile that pins 24.22.0 keeps shipping the vulnerable build until that line changes4.
The paths are specific:

- Inbound request bodies. Express 5, body-parser 2 and Koa's body parser inflate
Content-Encoding: brbefore any route handler runs 4. Dropping that header at an edge proxy protects the services behind that proxy. It does nothing for a worker, queue consumer or internal call that the edge never sees. - Outbound responses. Node's built-in
fetch, axios and got, and on Pythonurllib3,requests,httpxandaiohttpwith a Brotli binding installed, decode abrresponse automatically. That puts link previews, webhooks, crawlers and URL-fetching agent tools in scope 34. Turning off automatic decompression helps for the client you configure, not for a library that decompresses on its own. - Files. Brotli also arrives inside Parquet, WOFF2, JPEG XL, archives and firmware images, which data pipelines load from outside 3. Raw Brotli streams have no reliable magic number, so "reject Brotli uploads" isn't a filter you can write.
Crashing isn't the only symptom. Against Python Brotli 1.2.0, brotlicffi 1.2.0.0, the PHP streaming API and the Ruby gem, ELLIO's trigger produced a stream that returned no bytes and never finished, and the PHP case spun at full CPU 4. Where core dumps are on, each forced crash writes another process's memory to disk 4. Treat a run of decoder crashes as an availability incident and a disclosure incident at once.

Each claim is judged on a 95% interval fixed before the run. An interval that straddles its threshold means the study was too small.
- H1 (the receipt holds): Across the validation corpus, in every build where the control string is present,
_ERROR_FORMAT_BLOCK_SWITCHis present if and only if the build has the fix, with at least 300 such builds.- Disproof: A single build where the string's presence disagrees with the source it was built from. This is an invariant, not a rate: one counterexample is enough.
- H2 (the tail is real): Among census artifacts where a Brotli C decoder is found, at least 10% still lack the fix on the census date. This is a decision threshold, not a prediction. At 10% or more, a binary check belongs in release gating. The basis for expecting a tail is the list of projects still unpatched on 25 October 3.
- Disproof: The interval for the vulnerable share lies entirely below 10%.
- H3 (tier C is invisible to inventories): Among tier-C artifacts the census confirms vulnerable, Syft with its binary catalogers enabled lists no Brotli component for at least half. The basis is that Syft has no Brotli classifier 9.
- Disproof: The interval for that miss rate lies entirely below 50%.
This is the cheap first pass, written for reading. It searches bytes and runs nothing.
# First pass: strings only. Executes nothing.
PATCH = b"_ERROR_FORMAT_BLOCK_SWITCH" # code -17, added by the fix
CONTROL = b"_ERROR_FORMAT_DISTANCE" # code -16, in every version
def first_pass(native_file):
data = read_bytes(native_file)
if PATCH in data:
return "PATCHED" # the fixed header is compiled in
if CONTROL in data:
return "VULNERABLE" # error table present, no -17 name
if has_decoder_markers(data): # decoder found, no error table
return "INDETERMINATE" # -> behavioral check of the switch
return "ABSENT?" # confirm before reporting ABSENT
has_decoder_markersstands for the decoder's built-in data, such as its static dictionary, which it can't run without and which survives when the error names don't. A VULNERABLE call from this pass is a lead to confirm, because a vendor could have fixed the logic without the header.
How not to:
- Don't read
process.versions.brotli. It says 1.2.0 on a fixed 24.22.1 8. - Don't treat an empty scanner result as "not affected".
- Don't let the attacker's 0-for-50 lower the priority. ELLIO's own chain worked 4.
- Don't assume the edge rule covers outbound fetches, workers or files.
- Don't upgrade the Python
brotlipackage and stop: native extensions with their own copy need their own releases 3.
- Claim: "The exploit failed 50 times, so this is hard to weaponize."
- Check: That was one attacker's chain with a broken final stage. ELLIO's chain got a shell on four default targets 4.
- Claim: "
process.versions.brotlisays 1.2.0, so we're vulnerable", or "it says 1.2.0, so we're fixed."- Check: Neither. Node.js 24.22.1 is fixed and still says 1.2.0 8.
- Claim: "We upgraded
brotliin the lockfile, so we're done."- Check: That fixes tier B. The runtime and any native extension with a private copy are separate 3.
- Claim: "91% of everything is vulnerable."
- Check: Wiz counts cloud environments with at least one vulnerable resource, from its own telemetry 6. That's a different number from a share of artifacts.
- Claim: "Blocking
Content-Encoding: brat the WAF covers it."
The engineer's question was which copy of the decoder each process actually uses. The executive's was whether anyone can show the deployed binary is the fixed one. Both are answered by the same artifact: a per-binary record that says which decoder was found, how its patch status was established (a string, a behavioral check, or a vendor's signed statement), and what was left INDETERMINATE. A manifest can't produce that record. A receipt can.
- Almost nothing has been run. The census and the validation corpus haven't been built. The only measurement is the eight-artifact string check in section 4.
- Eight default builds. They show the string signal exists in real release artifacts. They don't show it survives stripping, link-time optimization or vendor patches.
- Presence isn't reachability. A vulnerable copy in an artifact may never see attacker- controlled input where it's deployed.
- Public registries only. Private builds, enterprise images and firmware aren't in the corpus, and they're where some of tier C lives 3.
- The C decoder only. The Go port and other reimplementations are out of scope 10.
| Claim | Footnote(s) | Source type | Status |
|---|---|---|---|
| Affected: Brotli 1.0.3–1.2.0; fixed in 1.0.10, 1.1.1, 1.2.1. | [1], [2] | Primary | Established |
| The helper reported an invalid switch as "need more input", overrunning an eight-byte buffer. | [4], [7] | Primary | Established |
| The fix adds error code -17, _ERROR_FORMAT_BLOCK_SWITCH ; -17 was reserved in 1.2.0. | [1] | Primary | Established |
| Commit dated 27 Feb 2026; fixed releases 21 Oct 2026. | [1] | Primary | Established |
| ELLIO's chain reached a shell on four default Node.js 24.22.0 x86-64 targets. | [4] | Primary | Established |
| The captured attacker chain (26 Oct, 15:08 UTC) got 0 shells in 50 tests. | [4] | Primary | Established |
| Added to KEV 27 Oct 2026, due 10 Nov 2026, under BOD 26-04. | [5] | Primary | Established |
| Node.js 24.22.1 bundles version 1.2.0 and 22.23.4 bundles 1.1.0, both with the fix. | [8] | Primary | Established |
| Syft has no Brotli binary classifier at e6813ba. | [9] | Primary | Established |
| Wiz: 91% of cloud environments have vulnerable resources (vendor telemetry). | [6] | Secondary | Established (as reported) |
| The fix string matched patch status in 8 of 8 official release artifacts. | — | Measured here | Established |
| Zero false calls in n negatives bounds the false-positive rate near 3/n. | [11] | Primary | Established |
| The string signal survives optimized and stripped builds (H1). | — | Inference | Hypothesis |
| At least 10% of Brotli-bearing native artifacts still lack the fix (H2). | [3] | Inference | Hypothesis |
| Syft misses at least half of confirmed tier-C copies (H3). | [9] | Inference | Hypothesis |
| How many vulnerable copies ship in PyPI and npm native artifacts. | — | — | Open (unresolved) |
References
- 01Google. "Detect and report invalid stream earlier." google/brotli commit 0ed4268a, 27 Feb 2026; headers at v1.2.0 and v1.2.1. https://github.com/google/brotli/commit/0ed4268a4401abcb4472096340477cf4af7d1a2b (accessed 2026-10-31).
- 02GitHub Advisory Database. "Brotli: Out-of-bounds write in the Brotli decoder ('Node2Shell') also affects the PyPI Brotli package." GHSA-9v8h-qqgm-4m35, 2026. https://github.com/advisories/GHSA-9v8h-qqgm-4m35 (accessed 2026-10-31).
- 03Cloud Security Alliance AI Safety Initiative. "One Decoder, Many Copies: Brotli and the Vendored-Library Patch Tail." CSA Lab Space, Oct 2026. https://labs.cloudsecurityalliance.org/research/csa-research-note-vendored-library-monoculture-brotli-202610/ (accessed 2026-10-31).
- 04ELLIO Threat Research Team. "Node2Shell (CVE-2026-111497) aka HTTP2Shell and Broccoli2Shell: RCE analysis." ELLIO blog, Oct 2026, updated 26 Oct 2026. https://ellio.tech/en/blog/node2shell-cve-2026-111497-rce-on-stock-nodejs-builds/ (accessed 2026-10-31).
- 05CISA. "Known Exploited Vulnerabilities Catalog," entry CVE-2026-111497 (added 2026-10-27, due 2026-11-10), catalog version 2026.10.30. https://raw.githubusercontent.com/cisagov/kev-data/main/known_exploited_vulnerabilities.json (accessed 2026-10-31).
- 06Wiz Research. "Broccoli2shell: Everything You Need To Know About The Critical Brotli Decoder Vulnerability." Wiz blog, 25 Oct 2026. https://www.wiz.io/blog/cve-2026-111497-brotli-rce-node-js (accessed 2026-10-31).
- 07Node.js. "October 2026 Security Releases." nodejs.org, Oct 2026. https://nodejs.org/en/blog/vulnerability/october-2026-security-releases (accessed 2026-10-31).
- 08Node.js. "deps/brotli at v24.22.1 and v22.23.4" (c/common/version.h; c/include/brotli/decode.h). https://github.com/nodejs/node/tree/v24.22.1/deps/brotli (accessed 2026-10-31).
- 09Anchore. "SBOM Catalogers" guide, and syft/pkg/cataloger/binary/classifiers.go at commit e6813ba. https://oss.anchore.com/docs/guides/sbom/catalogers/ (accessed 2026-10-31).
- 10A. Balholm. "brotli v1.2.7." GitHub release, 23 Oct 2026. https://github.com/andybalholm/brotli/releases/tag/v1.2.7 (accessed 2026-10-31).
- 11J. A. Hanley, A. Lippman-Hand. "If Nothing Goes Wrong, Is Everything All Right? Interpreting Zero Numerators." JAMA 249(13):1743–1745, 1983. https://doi.org/10.1001/jama.1983.03330370053031 (accessed 2026-10-31).