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.

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?

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.

Past the Barrier

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:

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.

The Birdwatcher

"Vendored" covers three different situations, and they need different fixes:

TierWhat it looks likeExamplesHow it gets fixed
A. Bundled in a runtimeThe decoder is compiled into the interpreter or serverNode.js, Bun, Envoy, the .NET runtimeA runtime upgrade, and every image built on it rebuilt
B. The binding is the decoderA package whose job is Brotli, shipping its own compiled copyPyPI Brotli and brotlicffi , the Ruby gem, the PHP extensionA package upgrade, which manifests do see
C. A private copyA package that compiles Brotli into its own native code for its own reasonsDuckDB, ClickHouse, Apache Arrow's bundled buildThat project's next release, whenever it comes

Sources: 3, 4.

The Suitcase in the Wall

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.

The Mended Wall

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:

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.

The Butterfly Net

I checked this on the official release files. Nothing was executed; each binary was only searched for the two strings:

ArtifactPatch statusBLOCK_SWITCHDISTANCE (control)
Node.js 24.22.0, linux-x64vulnerableabsentpresent
Node.js 24.22.1, linux-x64fixedpresentpresent
Node.js 22.23.3, linux-x64vulnerableabsentpresent
Node.js 22.23.4, linux-x64fixedpresentpresent
PyPI Brotli 1.2.0, CPython 3.12 x86_64 wheelvulnerableabsentpresent
PyPI Brotli 1.2.1, CPython 3.12 x86_64 wheelfixedpresentpresent
PyPI brotlicffi 1.2.0.0, abi3 x86_64 wheelvulnerableabsentpresent
PyPI brotlicffi 1.2.1.0, abi3 x86_64 wheelfixedpresentpresent

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:

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:

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:

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:

MetricDenominatorWhat it says
Brotli prevalenceAll native artifacts in the corpusHow often a decoder ships at all
Vulnerable shareArtifacts where a decoder was foundHow often the copy found lacks the fix
Inventory miss rateArtifacts the census confirms vulnerableHow often Syft fails to list the decoder, and how often Grype fails to report the CVE, counted separately
The Archive Ladder

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:

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.

The Floating Roots

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:

  1. Upgrade or rebuild what can be upgraded.
  2. Confirm the deployed binary is the fixed one. The string check above is enough for most of tiers A and B.
  3. Find every path by which attacker-controlled Brotli reaches a decoder.
  4. 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:

The Parcels

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.

The Umbrella Roof

Each claim is judged on a 95% interval fixed before the run. An interval that straddles its threshold means the study was too small.

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_markers

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

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.

ClaimFootnote(s)Source typeStatus
Affected: Brotli 1.0.3–1.2.0; fixed in 1.0.10, 1.1.1, 1.2.1.[1], [2]PrimaryEstablished
The helper reported an invalid switch as "need more input", overrunning an eight-byte buffer.[4], [7]PrimaryEstablished
The fix adds error code -17, _ERROR_FORMAT_BLOCK_SWITCH ; -17 was reserved in 1.2.0.[1]PrimaryEstablished
Commit dated 27 Feb 2026; fixed releases 21 Oct 2026.[1]PrimaryEstablished
ELLIO's chain reached a shell on four default Node.js 24.22.0 x86-64 targets.[4]PrimaryEstablished
The captured attacker chain (26 Oct, 15:08 UTC) got 0 shells in 50 tests.[4]PrimaryEstablished
Added to KEV 27 Oct 2026, due 10 Nov 2026, under BOD 26-04.[5]PrimaryEstablished
Node.js 24.22.1 bundles version 1.2.0 and 22.23.4 bundles 1.1.0, both with the fix.[8]PrimaryEstablished
Syft has no Brotli binary classifier at e6813ba.[9]PrimaryEstablished
Wiz: 91% of cloud environments have vulnerable resources (vendor telemetry).[6]SecondaryEstablished (as reported)
The fix string matched patch status in 8 of 8 official release artifacts.—Measured hereEstablished
Zero false calls in n negatives bounds the false-positive rate near 3/n.[11]PrimaryEstablished
The string signal survives optimized and stripped builds (H1).—InferenceHypothesis
At least 10% of Brotli-bearing native artifacts still lack the fix (H2).[3]InferenceHypothesis
Syft misses at least half of confirmed tier-C copies (H3).[9]InferenceHypothesis
How many vulnerable copies ship in PyPI and npm native artifacts.——Open (unresolved)

References

  1. 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).
  2. 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).
  3. 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).
  4. 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).
  5. 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).
  6. 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).
  7. 07Node.js. "October 2026 Security Releases." nodejs.org, Oct 2026. https://nodejs.org/en/blog/vulnerability/october-2026-security-releases (accessed 2026-10-31).
  8. 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).
  9. 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).
  10. 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).
  11. 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).