// cve-2026-111497 // node.js // brotli // pre-auth rce
TL;DR Node.js bundles Google's Brotli decoder. On the 22.x, 24.x and 26.x lines that decoder kept going after an impossible block switch and wrote past its buffer. Imperva turned that into pre-auth RCE against an unmodified node:24.22.0 image. If you run Express 5 or body-parser 2.x, or your service fetch()es anything you do not own, you are in scope. Fixed in 22.23.4, 24.22.1 and 26.12.1, and in Bun 1.4.4. The advisory offers no workaround.
One boolean, two meanings
A Brotli stream may only switch block type if it declared more than one block type. Craft a stream that declares a single type and asks to switch anyway. The old decoder had no error code for that case. It returned the same BROTLI_FALSE it uses for "I ran out of input", the caller asked for more bytes, and decoding carried on with internal state that no longer described the stream. That is the whole of CVE-2026-111497: an out-of-bounds write in a native parser most Node teams did not know they had on the request path.
Imperva Red Team named it Node2Shell, a nod to their PHP2Shell work from the same week, and on Thursday they published a video of the decoder dropping a marker file inside an unmodified Node.js 24.22.0 container. Express 5 with express.json() on a public route was enough.
The diff is two files
Pull google/brotli@0ed4268. Fifty-nine lines added, thirty-two removed, all in c/dec/decode.c and decode.h. The commit message reads "Detect and report invalid stream earlier." Nothing about security, nothing about a CVE, and it landed in February 2026.
The interesting part is small. DecodeBlockTypeAndLength() used to return a bool. Now it returns a proper BrotliDecoderErrorCode, and the illegal switch gets its own code, BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH, value -17. Look at the header before the patch: -17 was literally commented as reserved. The three block-switch call sites move onto a new BROTLI_SAFE_WITH_STATUS macro that bails out on anything other than success. And the old BROTLI_SAFE macro gains a comment that describes the design flaw in one sentence: in "unsafe" mode, when input is guaranteed to be sufficient, the result was ignored.
c/include/brotli/decode.h
- /* -17 code is reserved */
+ BROTLI_ERROR_CODE(_ERROR_FORMAT_, BLOCK_SWITCH, -17)
c/dec/decode.c
-static BROTLI_INLINE BROTLI_BOOL DecodeBlockTypeAndLength(
+static BROTLI_INLINE BrotliDecoderErrorCode DecodeBlockTypeAndLength(
int safe, BrotliDecoderState* s, int tree_type) {
...
if (max_block_type <= 1) {
- return BROTLI_FALSE; /* my note: same value as "need more input" */
+ return BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH;
}
/* NB: METHOD should return BROTLI_FALSE only in case there is not enough input;
in case of "unsafe" execution, when input is guaranteed to be sufficient,
result is ignored. */
#define BROTLI_SAFE(METHOD) ...
- BROTLI_SAFE(DecodeCommandBlockSwitch(s));
+ BROTLI_SAFE_WITH_STATUS(DecodeCommandBlockSwitch(s));
So on the fast path the decoder never looked at the return value at all. On the safe path it looked and drew the wrong conclusion. Either way decoding continued with state that no longer matched the stream, and the sanitizer run in Imperva's write-up shows where that ends: a write past the buffer. Node's half of the fix is a cherry-pick of that commit plus a one-liner from Antoine du Hamel exposing the new code through node:zlib, so the same input now fails as a format error instead of continuing.
A boolean carrying two meanings is a smell worth grepping your own native code for. "Needs more input" and "this input is garbage" are different outcomes, and the second one must never be recoverable.
Eight months in plain sight
The timeline bothers me more than the bug does.
February 2026: the fix goes public in google/brotli under a message that reads like housekeeping. No release cut, no SECURITY line in the changelog. 21 October 2026: Node.js ships 22.23.4, 24.22.1 and 26.12.1, and the same day Brotli tags 1.2.1, 1.1.1 and 1.0.10 with a CHANGELOG entry that finally says the words "out-of-bounds write permits remote code execution". The Node advisory credits OpenAI Codex Security (Khai Tran and Sylvie Mayer) for the report, and Matteo Collina and Antoine du Hamel for the fix. Thursday: a public proof of concept, Imperva's video, and Ron Masas on X telling everyone to patch.
Whether Google knew this was a security fix in February and sat on it quietly, or only joined the dots when the Codex Security report came in, I do not know. The outcome for you is identical either way. The patch existed, in a public repo, for roughly eight months while your runtime kept the bug. Nothing in a normal npm toolchain saw any of it. npm audit has no idea what revision of deps/brotli is compiled into your node binary. Your SBOM stops at package.json and this bug lives two layers under it. The one thing that did catch it this week was the container image scanner, because it reads the runtime version straight out of the image.
Three doors, one decoder
The Node advisory tells you which release lines are affected and stops. No CVSS, no version ranges, no mitigation. Reachability is on you, and there are three ways attacker bytes reach the decoder.
- Inbound bodies. Express 5 and body-parser 2.x inflate request bodies sent with
Content-Encoding: br. body-parser only picked up Brotli in 2.0.0, so Express 4 on body-parser 1.x does not open this door. Imperva got execution through bothraw()andjson()across the Express 5.0.0 to 5.3.0 and body-parser 2.0.0 to 2.3.0 ranges, by pinning endpoints rather than testing every release. A barenode:httpserver does not decode Brotli bodies on its own. - Outbound responses. The built-in
fetch()decompresses Brotli responses automatically. Webhook delivery, link previews, anything that calls a URL a user handed you, any partner API that gets compromised. Imperva got execution on this path too, with nothing butfetch(). Edge rules that dropbron inbound traffic do nothing here. - Direct
node:zlib.brotliDecompressandcreateBrotliDecompresson anything untrusted. Grep for both.
Two more notes. Bun carried the same decoder and fixed it in 1.4.4. Distro-packaged Node may link the system libbrotli instead of the bundled copy, in which case the fix arrives through your distro's brotli package and node --version on its own will lie to you.
What it looks like coming at you
The doors are not equally easy to walk through, and the one that should worry you is not the one the demos use. Here is each with a real adversary behind it.
1. The single request
A trading API, app.use(express.json()) mounted once at the top, the auth middleware two lines below it. That ordering is the whole attack. body-parser inflates the body before your token check ever runs, so the decoder is chewing on attacker bytes while the request is still anonymous. The attacker sends one POST to any route that reads a body, sets Content-Encoding: br, and puts the malformed stream in the payload. No login, one packet on the wire. This is the Masas demo and it is the version that ruins your weekend: pre-auth code execution on anything public that parses JSON. What misses it: your auth layer, because the decoder runs underneath it.
2. The call you make
This is the one your threat model does not have. Your app has a "test webhook" button, or it unfurls links in a chat window, or it fetches an avatar from a URL someone pasted, or it pulls an OIDC discovery document mid-SSO. Every one of those is an outbound fetch() to a destination someone else chose. The attacker stands up a server, points your feature at it, and answers with 200 OK, Content-Encoding: br, and a malformed body. Node inflates the response for you. The payload is running in your process and the attacker never sent you a request. What misses it: every inbound control you own. The WAF, the br drop at the edge, the rate limiter on the API. None of them sit in the path, because the bytes came back as a reply to a call you made. Vercel and Aikido both say it in plain words: the edge rules are inbound only.
3. The partner you trust
You call a partner API over TLS on an allowlisted egress route. You have called it ten thousand times and never once looked at the raw bytes of a response. Then the partner gets popped, or a dependency quietly repoints a base URL, or something in the middle turns hostile. The Brotli responses you were always going to decompress now carry the payload. You have no inbound surface for this, and your egress ACL waves it straight through, because the destination is on the allow list and the connection is one you asked for. What misses it: network segmentation, for the same reason as the last one.
The thread through all three is the same. Your controls live above the decoder and the decoder runs below them, on bytes that are still untrusted when it touches them. That is why the stopgaps only ever half-work, and why the version bump is the only thing that actually closes the hole.
What the proofs actually prove
Two reports landed on 22 October and they do not say the same thing. The first public PoC reached code execution only against a deliberately weakened build; on a stock build its author got a crash and a limited read of process memory. Imperva Red Team, the same day, reported execution against the official node:24.22.0 image in fresh Linux arm64 containers, with no debugger and no instrumented binary, and 24.22.1 rejected the identical input. They also say it took multiple attempts in their setup and that they did not test every build.
So the honest summary is a memory-corruption bug in an internet-facing parser, one lab report of unreliable execution on a stock arm64 build, no reliable public exploit, and no exploitation in the wild reported as of Thursday. That is a patch-this-week bug rather than a drop-everything bug. I would still not gamble on it. "Unreliable on arm64" is a statement about one team's Thursday, not about your x86 fleet.
What to do, in order
- Inventory the runtime, not the packages.
node --versionon every host and CI runner, anddocker run --rm <image> node --versionfor every image you ship. A Dockerfile pinned tonode:24.22.0stays vulnerable through every rebuild. Managed runtimes lag, so check what your provider actually runs rather than what their changelog promises. - Upgrade. 22.23.4, 24.22.1 or 26.12.1. Bun 1.4.4. Anything on 20.x, 23.x or 25.x is end of life and gets nothing, so that is a line migration rather than a patch.
- Order the rollout by reachability.
npm ls express body-parserto find Express 5 and body-parser 2.x behind public POST or PUT routes. Then every outboundfetch()to a party you do not control. Then direct zlib use. Those go first, the rest can follow in the normal window. - Stopgap while you roll. Drop
Content-Encoding: brat the proxy or load balancer. Vercel did it platform-wide on the 21st; Aikido did it on internal services they could not upgrade the same day. It covers inbound only, and if your change log records it as remediation, your change log is wrong. - Take the whole release. The same drop fixes three other HIGHs nobody is talking about: two TLS hostname-verification bypasses triggered by unusual IP spellings, and
dns.resolvetruncating names at an embedded NUL. It also blocks the eight compromised eMudhra intermediate CA keys. Do not cherry-pick the famous one.
# 1. the runtime, not the packages. every host, every CI runner, every image
node --version
docker run --rm <image> node --version
# 2. the inbound door: Express 5 / body-parser 2.x behind public POST or PUT
npm ls express body-parser
# 3. the outbound door and the direct one
grep -rn "fetch(" src/
grep -rn "brotliDecompress\|createBrotliDecompress" src/ node_modules/
# 4. stopgap at the edge while you roll. inbound only. not remediation.
if ($http_content_encoding = "br") { return 415; } # nginx, inside the server block
A fix in a vendored dependency is not a fix until your runtime ships it. This one took eight months, and nothing in an npm-shaped toolchain would have told you. Patch, then go and find out what else is compiled into your binaries that your SBOM has never heard of.
Sources
Node.js: Wednesday, October 21, 2026 Security Releases
Node.js v22.23.4 release notes (cherry-pick + exposed error code)
google/brotli commit 0ed4268: "Detect and report invalid stream earlier."
Brotli CHANGELOG, 1.2.1 (2026-10-21) SECURITY entry
Imperva Red Team: Node2Shell, From Brotli Data to Remote Code Execution
Aikido: Node2Shell, who is affected and the stopgaps
The Hacker News: the first PoC, the other nine CVEs, the eMudhra block
Elusive Thoughts // securityhorror.blogspot.com · posted 23 Oct 2026 · patch, then hunt.
No comments:
Post a Comment