One Discarded Return Value: Auditing Broccoli2Shell Across a Live Fleet
Two Node.js installations on our eight-host infrastructure flagged as vulnerable to CVE-2026-111497 — the Brotli decoder flaw nicknamed node2shell, then HTTP2Shell, and finally Broccoli2Shell. Both were genuinely vulnerable. But getting from "the version string looks wrong" to that confident verdict required three extra checks, because every naive detection method available failed in at least one direction: version scanners false-flagged a patched Debian library, the bundled-version check false-flagged a patched Node build, and the same /usr/bin/node path would have been safe or exploitable depending purely on which repository delivered it.
This article reconstructs the bug, the exposure model, and the audit — because the lessons generalise well beyond Brotli.
A swallowed error code in decode.c
CVE-2026-111497 is an out-of-bounds write in Google's Brotli decoder, present upstream in every release from 1.0.3 through 1.2.0. Google scored it CVSS v4 9.2 — critical — and the attack requires no authentication: a crafted Brotli stream, fed to anything that decompresses it, can corrupt adjacent decoder memory, leak memory contents, crash the process, or in the worst case execute code. Exploit code is publicly available.
The root cause is almost embarrassingly small. Brotli streams are organised into metablocks, and metablocks into block types — literal sections, command sections, distance sections — with block switches marking transitions between them. The decoder's DecodeBlockTypeAndLength() handles those switches. Pre-patch, when a metablock header declared only one block type (num_block_types <= 1), no switch was legal, and the function simply returned failure:
if (max_block_type <= 1) {
return BROTLI_FALSE; // caller may ignore this
}
And the caller did ignore it. On the decoder's fast path, the BROTLI_SAFE macro runs the unsafe variant of each step and discards the return value:
#define BROTLI_SAFE(METHOD) \
{ if (safe) { ... } else { \
METHOD; /* <-- result thrown away */ \
} }
So a malformed stream that reached a block-switch point while only one block type existed left the decoder running with stale state — an old index into the Huffman-tree table that no longer had that many entries. The result is the memory-safety failure that the whole nickname saga grew from. The upstream fix, commit 0ed4268 ("Detect and report invalid stream earlier"), changes the function to return a new dedicated error, BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH (-17), and propagates it all the way out. Node.js cherry-picked that one commit into its bundled copy for the October 2026 security releases.
Why did a bug of this class survive in a widely fuzzed library since 1.0.3? Because well-formed streams cannot reach the broken state: a metablock's maximum length equals the block counter's initial value, so a valid stream always exhausts the block before the counter hits zero. Only a deliberately malformed stream walks off the edge — which is precisely why discarding one error code was survivable for years and catastrophic once noticed.
Two directions of attack
The decoder being embedded in nearly every HTTP stack means exposure is not just a server-side concern:
| Direction | Trigger | Who is exposed |
|---|---|---|
| Inbound | Server decompresses a request body with Content-Encoding: br | Express 5 (body-parser 2.x) does this by default via express.json(); Express 4 rejects Brotli bodies. Envoy/Istio only with the Brotli decompressor configured. |
| Outbound | Client fetch()es a br-compressed response from an attacker-controlled server | Node.js fetch() auto-decodes br. Python's urllib3 (hence requests), httpx and aiohttp do the same whenever Brotli/brotlicffi is installed. |
The outbound path is the sleeper. Your service never accepts untrusted compressed input, but it fetches a URL — a webhook callback, a link preview, a crawler target — and the remote end chooses the content encoding. Every build agent, scraper and integration service decoding Brotli responses is in scope. The installed package is the attack surface, not your code.
One genuine piece of reassurance: Apache and nginx in their default configurations do not decode Brotli, so a stock reverse proxy is not a decoder in the path.
Why patch status defies version numbers
The affected upstream range is 1.0.3 to 1.2.0, fixed in 1.0.10, 1.1.1 and 1.2.1. Downstream, every consumer patched on its own schedule — and each chose a different mechanism:
| Product | Vulnerable | Fixed | Patch mechanism |
|---|---|---|---|
| Node.js 22.x / 24.x / 26.x | before 22.23.4 / 24.22.1 / 26.12.1 | 22.23.4, 24.22.1, 26.12.1 | Cherry-pick into bundled brotli |
| Node.js EOL lines (20.x and older) | all | none — upgrade | — |
| Bun | ≤ 1.4.3 | 1.4.4 | Patch-file backport of 0ed4268 |
| .NET 8 / 9 / 10 | before 8.0.33 / 9.0.22 / 10.0.14 | 8.0.33, 9.0.22, 10.0.14 (11.0 RC3) | Runtime + SDK updates from 25 Oct |
Python Brotli / brotlicffi | ≤ 1.2.0 / ≤ 1.2.0.x | 1.2.1 / 1.2.1.0 | PyPI release |
Debian bookworm libbrotli1 | 1.0.9-2 | 1.0.9-2+deb12u1 (DLA-4855-1) | Backport — version string unchanged |
Debian trixie libbrotli1 | 1.1.0-2 | 1.1.0-2+deb13u1 (DSA-6582-1) | Backport — version string unchanged |
Ubuntu 24.04 libbrotli1 | 1.1.0-2build2 | none published ("needs evaluation") | pending at time of writing |
| Envoy | with Brotli decompressor enabled | 1.40.0, 1.39.3, 1.38.6, 1.37.8 | Rebuild with fixed brotli |
Three traps hide in that table. First, backports: Debian's fixed library still reads 1.0.9 and 1.1.0 — the security revision suffix (+deb12u1, +deb13u1) carries the fix, so anything matching on upstream version ranges flags healthy hosts. Second, bundled versus linked: Debian's own nodejs package links the system libbrotli1 (the Debian tracker marks its CVE status "unimportant" for exactly this reason), so a Debian-built node is remediated by the library update alone — while third-party builds of the same upstream version bundle their own vulnerable copy. Third, the bundled version string lies too: node v22.23.4 — the patched release — still reports process.versions.brotli as 1.1.0, because Node cherry-picked the fix commit without bumping the embedded version. The only dependable signals are the binary's own release version plus the knowledge of who built it.
The audit: eight hosts, one script
The fleet: a workstation, a monitoring server, two Debian servers, a NAS appliance and a three-node Ubuntu storage cluster — heterogeneous by design, managed by Ansible. The scan is a single shell script pushed with the script module, checking every layer the table above touches: runtimes, language packages, and distro libraries.
#!/bin/bash
echo "=== $(hostname) ==="
command -v node >/dev/null && echo "node: $(node --version) [$(command -v node)]"
command -v bun >/dev/null && echo "bun: $(bun --version)"
dotnet --list-runtimes 2>/dev/null | head -5
python3 - <<'EOF' 2>/dev/null
import importlib.metadata as md
for p in ("Brotli", "brotlicffi"):
try: print(f"py-{p}: {md.version(p)}")
except md.PackageNotFoundException: pass
EOF
dpkg -l 2>/dev/null | awk '/brotli/ {print "deb:", $2, $3}'
ansible all -m script -a '/tmp/brotli-scan.sh' -f 8 -o
Thirty seconds later, the verdict per host:
| Host | Runtime finding | Distro libbrotli | Verdict |
|---|---|---|---|
| Debian 12 workstation | node 22.23.4, bun 1.4.4, Brotli 1.2.1, brotlicffi 1.2.1.0 | 1.0.9-2+deb12u1 | ✅ patched across the board |
| Debian 12 monitoring server | node 24.13.0 at /usr/local/bin | 1.0.9-2+deb12u1 | ❌ vulnerable |
| Debian 13 application server | node 22.23.3 at /usr/bin | 1.1.0-2+deb13u1 | ❌ vulnerable |
| Debian 12 server, no runtimes | — | 1.0.9-2+deb12u1 | ✅ patched (DLA-4855-1) |
| storage cluster ×3 (Ubuntu 24.04) | no runtimes | 1.1.0-2build2 | ⚠️ library unpatched upstream, no decoding services |
| NAS appliance | nothing relevant | — | ✅ |
The two hits look deceptively similar — an old node — but needed opposite reasoning to confirm, and one more command each settled it:
$ ldd $(command -v node) | grep -i brotli # empty: statically bundled
$ node -p "process.versions.brotli"
1.1.0
$ apt-cache policy nodejs # application server only
Installed: 22.23.3-1nodesource1
Candidate: 22.23.4-1nodesource1
the monitoring server runs an official upstream tarball placed under /usr/local/bin — no package manager, no backports, bundled brotli 1.1.0 inside a v24.13.0 binary. Unambiguously in the affected range; the patched system library on the same host is irrelevant because the binary never loads it. the application server looks like it could hide behind Debian's DSA-6582 — but the nodesource1 package suffix reveals the install channel: NodeSource builds statically bundle their dependencies, unlike Debian's own nodejs, so this host needs the node fix itself. Tellingly, 22.23.4-1nodesource1 was already sitting in the candidate position — the fix was one apt upgrade away, just not applied.
The Ubuntu cluster's 1.1.0-2build2 sits in the vulnerable upstream range with no published Ubuntu fix. With no Brotli-decoding service on those hosts, that is a watch item, not an incident — which is exactly the kind of judgement a scan-plus-context workflow supports and a version-only scanner cannot.
Remediation and verification
- Node.js via package manager: upgrade to 22.23.4 / 24.22.1 / 26.12.1 (one host's apt candidate already pointed at the fixed release)
- Node.js from tarballs: replace
/usr/local/bin/nodeinstalls with ≥ 24.22.1 — or better, adopt a repo so this cannot silently rot again - Bun: ≥ 1.4.4; Python:
pip install -U Brotli brotlicffito 1.2.1 / 1.2.1.0; .NET: runtimes and SDKs from 25 October - Containers: rebuild any image
FROM node:*orFROM oven/bun:*— base-image tags updated within days; stale local caches did not - Ubuntu hosts: track the CVE page until a
libbrotli1fix lands; revisit theaptunattended-upgrades coverage - Outbound exposure audit: grep services for
fetch(, webhooks, URL-fetch features on vulnerable runtimes; stripbrfromAccept-Encodingif patching must wait
Verification for the patched runtimes is plain version comparison — node --version against the fixed releases — because, as established, process.versions.brotli cannot distinguish a patched build from a vulnerable one.
What this episode teaches
Broccoli2Shell is a transitive-dependency case study wearing a CVE jacket. One C file, one discarded return value, and within a week: three Node lines, Bun, three .NET lines, two Python packages, Envoy, Istio, two Debian security advisories and a pending Ubuntu evaluation. The uncomfortable part is not the bug — memory-safety flaws in stream decoders are a genre — but that knowing your patch state required knowing, per host, which of four different patch mechanisms applied. Version strings encoded none of it.
The durable defences are unglamorous: an inventory that records install channels, not just versions; preference for distro-managed packages precisely because backports arrive through one auditable pipeline; and a fleet-wide scan script that can be written, pushed and read inside thirty minutes when the next one lands. The next one will land — the pattern guarantees it.