Node2Shell: remote code execution on stock Node.js builds (CVE-2026-111497)
A checker calling itself node2shell-check is already mapping which hosts decompress Brotli. CVE-2026-111497 turns a malformed Brotli body into a shell on an official, unmodified Node.js build, which ELLIO reproduced on x86-64 against four targets.
Update, 26 October 2026. ELLIO sensors have recorded CVE-2026-111497 exploit traffic in the wild, carrying the public exploit that we confirmed gets a shell on vulnerable builds. Treat this as exploited and patch now.
A public exposure checker calling itself node2shell-check reached ELLIO's sensors first, sending 20 Brotli bodies in two bursts to establish which hosts decompress Brotli at all. The day after, exploit traffic followed from a different source, carrying the public exploit code we had already confirmed gets a shell on vulnerable builds. Both were after CVE-2026-111497, an out-of-bounds write in Google's Brotli decoder that Google's CNA rates 9.2 Critical under CVSS 4.0. It circulates as Node2Shell, and also as HTTP2Shell and Broccoli2Shell.
A malformed compressed request body returns a shell on an official, unmodified Node.js build. Imperva's Red Team showed that on arm64 a day after the fix shipped. The ELLIO Threat Research Team reproduced it on x86-64 against four targets running the same build: a plain node:http server, the official node:24.22.0-slim image running as root, Express 5.3.0, and Koa 3. Each used the official published binaries under default configuration, with no debugger and address space layout randomization left on, and each returned a shell running as the service's own account. All four runs are recorded in remote code execution on x86-64.
The bug
A Brotli stream is a sequence of metablocks. Inside one, the data is split into runs called blocks. The blocks come in three independent kinds: literals, insert-and-copy commands, and distances. Each kind has its own table of block types and its own counter for how many symbols are left in the current block. When a counter reaches zero, the decoder reads a block switch from the stream. The switch says which type comes next and for how long.

Brotli splits compressed data into blocks. A switch between two blocks says which type comes next.

The same kind, with one type declared. The switch has nothing to choose, and that is the input that triggers this bug.
Declare only one block type for a kind and there is nothing to switch to. A block switch there is not valid Brotli, and the decoder should reject it. Instead, the block-switch helper returned a plain boolean. It returned false for that invalid switch, and false again when the decoder simply ran out of input partway through reading one. Two different faults, one answer.
When a caller is told it needs more input, it sends more input. Between calls the decoder keeps its unconsumed bytes in an eight-byte internal buffer. A real shortfall drains that buffer on the next chunk. The invalid switch never drains it. The bytes keep arriving, the buffer never empties, and past the eighth byte the writes land outside it.
Google's record lists two CWEs, and the pairing is exact. CWE-252, unchecked return value, is the bug. CWE-787, out-of-bounds write, is what it causes. The write is relative to the ring buffer and uses a signed 32-bit displacement. So a client who controls the stream picks a destination within roughly 2 GB of that buffer and copies 16 bytes of its own content there.
The fix is upstream commit 0ed4268a, "Detect and report invalid stream earlier". It gives the helper its own error type. The block-switch helpers now return a BrotliDecoderErrorCode instead of a boolean. An invalid switch returns BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH, value -17, and stops. When the decoder really does run out of input, it still returns NEEDS_MORE_INPUT.
c/include/brotli/decode.h
+ BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH = -17
c/dec/decode.c, where a block-type tree holds at most one type
- return BROTLI_FALSE;
+ return BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH;
The caller behaved correctly the whole time. The decoder asked for more input, and the caller gave exactly what it asked for.
Affected infrastructure and packages
Node.js 22.x, 24.x and 26.x are affected. So are the end-of-life 20.x, 23.x and 25.x lines. Those lines will not get a fix.
A bare Node.js HTTP server does not inflate an incoming Brotli body just by receiving it. Something has to hand the bytes to the decoder, and that step decides your exposure.

Three ways Brotli from an attacker reaches the decoder. Any client can send the first one.
The direct path is an inbound request body sent with Content-Encoding: br. Express 5 with express.json() or express.raw(), body-parser 2, and Koa with @koa/bodyparser all inflate it before any route handler runs. Imperva tested Express 5.0.0 through 5.3.0 with body-parser 2.0.0 through 2.3.0.
The second is an outbound response coded with br. undici decodes it automatically, so built-in fetch() is affected. So are axios, got, SuperAgent and server-side fetches in frameworks such as Next.js. Any fetch counts if an attacker influences the URL: link previews, webhook delivery, crawlers, URL-fetching agent tools. The third is a direct node:zlib brotli call on data a client supplied.
Beyond Node.js, the same decoder ships in Bun before 1.4.4. There, fetch, DecompressionStream and the node:zlib brotli functions all reach it. Executables built with bun build --compile stay affected until you rebuild them. On Python, you are exposed as soon as you install the package. urllib3, requests, httpx[brotli], aiohttp and Scrapy use whichever binding is present. They decide from the response's Content-Encoding, and what the request asked for does not matter. Envoy is affected wherever the Brotli decompressor is configured.

Patching Node.js fixes the copy Node.js bundles. Everything else here needs its own update.
Your Node.js version may not answer the question. Wherever Node links the system library, the brotli package version is the one that matters, which is why Debian's tracker records "nodejs uses system provided brotli library".
One check to get right before you read the list. Node.js cherry-picked the fix without bumping the bundled version string, so process.versions.brotli reports 1.2.0 on a patched build and on a vulnerable one. Check the release version instead.
Here is what to move to:
Node.js 22.23.4, 24.22.1, 26.12.1
Google Brotli 1.0.10, 1.1.1, 1.2.1
Bun 1.4.4
PyPI Brotli 1.2.1
PyPI brotlicffi 1.2.1.0
Envoy 1.40.0, 1.39.3, 1.38.6, 1.37.8, 1.36.12
Ruby brotli gem 0.9.1, 0.9.2
Debian bookworm brotli 1.0.9-2+deb12u1 (DLA-4855-1)
Debian trixie brotli 1.1.0-2+deb13u1 (DSA-6582-1)
Alpine edge, 3.24, 3.23 brotli 1.2.1-r0
Alpine 3.21 brotli 1.1.1-r0
Node.js 20.x, 23.x and 25.x have no fix, so move to a supported line.
Remote code execution on x86-64
Imperva's Red Team was the first to show the attack against an official build, on 22 October. They worked in fresh containers, with no debugger and no instrumented binary. They ran code on the official Node.js 24.22.0 Linux arm64 image through built-in fetch() and through Express 5 request bodies. 24.22.1 rejected the same inputs. Their write-up is Node2Shell: From Brotli Data to Remote Code Execution.
Three questions stay open. Does the result work on x86-64, does it hold across Node.js builds, and does the application in front of the decoder matter? Every result below comes from the official published binaries, unmodified: default configuration, no debugger attached, and address space layout randomization left on as the host sets it. The patched release rejected the input every time. It answered right away with a 400 and ERR__ERROR_FORMAT_BLOCK_SWITCH.

Plain node:http calling node:zlib
RCE worked with no framework and no body parser anywhere in front of the decoder.
The official node:24.22.0-slim image, running as root
RCE worked on the published image under a restart policy, with the shell running as root.
Express 5.3.0 with express.json()
RCE worked against the most common shape in production.
Koa 3 with @koa/bodyparser 6
RCE worked here too, through the body parser a Koa application normally uses.
Each shell ran as the service's own account and answered id and uname -r back over the connection that delivered the payload.
Two things matter: the runtime, and whether anything hands it Brotli data from a client.
Two target-specific values separate a crash from a shell. Both matter more for defense than the chain itself.
The first is the accepted socket number. The chain ends by pointing the shell's input and output at a file descriptor, and that descriptor is the attacker's own connection inside the target process. Get the number right and the shell answers over the connection that delivered the payload. Get it wrong and the attempt looks like any other miss, because both go silent. A fresh Node.js process opens its descriptors in a fixed order, so the first accepted connection lands on a predictable number. That order held on all four targets, because the runtime sets it and the application does not. Concurrency is what makes it less predictable. Node.js hands out the lowest free descriptor, so the more connections a service is already holding, the wider the range the next one can land in.
On a quiet service the number is a certainty, and so it is behind a load balancer that keeps per-instance concurrency near zero. On a busy one the range is roughly the peak concurrency, which an attacker can watch from outside and can narrow by holding connections open. A wrong guess costs the attacker another attempt. It costs the service a crashed worker. No mainstream hardening pins or randomizes accepted descriptor numbers, so only the patch removes the dependency.
The second value is the memory offset. The restart loop is what gives an attacker attempt after attempt at it. Official Node.js builds are non-PIE (not a position independent executable), so the binary loads at the same address every time and every gadget, library entry point and string inside it sits where the linker put it. Address space layout randomization never touches any of that. It randomizes the heap base, the thread stacks and the allocator arenas, and that is exactly the layout the attack has to match. A running process has one layout. A miss usually segfaults the worker, and the supervisor restarts it with a fresh one. We measured the same target both ways. With ASLR enabled, a shell came at around attempt 5. With ASLR disabled, 60 attempts gave no hit. Disabling ASLR does not help. It leaves one layout that never changes, so an attacker measures it once and hits it every time.
The restart loop is what makes the attempts possible. It exists to keep the service up, and every restart hands the attacker a fresh layout to try. Slowing the restarts slows the attack. It does not stop it.
Each Node.js build needs its own offset. The offset for 24.22.0 gave a shell on all four targets. Against 22.23.3 and 26.12.0, which need their own offsets, that same value gave nothing in 10,000 attempts.
Failed RCE attempts are a denial of service
In our lab, most of the four hundred Express attempts crashed the worker instead of returning a shell.
A crash copies whatever was live in memory. Say a visitor logs in to a service and gets a session cookie, a bearer token and RSA key material. All three sit in the process heap in several copies, in the raw request buffer and again as parsed strings. They are all still there the instant a single trigger kills the process. systemd-coredump is the default on a stock Ubuntu host. Where core dumps are enabled, every attacker-forced crash writes a core into /var/lib/systemd/coredump, and that core has another visitor's credentials in it.

Core files are root-readable and often kept. Anyone who can read one gets the session that was live when the process died.
Containers usually do the opposite. core_pattern points to a binary the container does not have. The crash then leaves no core at all, and the only harm is to availability. A triggered request returns nothing either way. Node's async node:zlib hands back decoded output only when its threadpool work unit finishes, and the block-switch trap keeps that unit open. Treat a run of Brotli decoder crashes as two incidents at once: availability, and disclosure wherever cores get written. Limit who can read them and how long you keep them.
A crash shows up right away, and the process gets restarted. Several decoders with this bug do something quieter.

The same bytes against six decoders. Several never crash. They return nothing and never finish.
The same bytes hit Python Brotli 1.2.0, brotlicffi 1.2.0.0, the PHP extension's streaming API and the Ruby gem's Decompressor#process. In each one, the stream returns zero bytes, never finishes and never raises an error. A consumer waits forever for the stream to finish. The PHP streaming case spins at 100% CPU while it waits. None of it looks like a failure to the caller.
The public denial-of-service payload makes Google Brotli 1.2.0 itself run 44.4 million in-call block-switch attempts in 25 seconds at 99.9% CPU, with no bytes in and no bytes out. AddressSanitizer stays clean throughout. In the fixed version, Brotli 1.2.1 rejects both that payload and our trigger in about 40 ms with error -17.
A service with no restart supervisor does not need an exploit chain at all. In our lab the second request crashed it, and it stayed down until someone restarted it.
Why it arrives in waves

Eight years from the release that shipped the bug to the scanning running now.
The bug shipped in Brotli 1.0.3 in March 2018. The fix was written on 27 February 2026 and went into the public master branch of google/brotli with no release and no advisory. It stayed there, readable, for eight months.
Scanning has already started. A public exposure checker has reached ELLIO's sensors from AS44620, registered in Turkey. It calls itself Mozilla/5.0 (compatible; node2shell-check/1.0). ELLIO first observed the address on 13 June 2026.
What it sent matters more. After a GET / it delivered 20 POST bodies carrying Content-Encoding: br, in two bursts about two minutes apart. Five were malformed at 1032 bytes, padded with repeated A characters, which a patched decoder rejects at the code-length table before it ever reaches a block switch. The other 15 were valid Brotli that decoded cleanly, cycling four distinct stream sizes, the largest 65,743 bytes. None of the 20 carried the block-switch construct. Twenty bodies, two bursts, four stream sizes and a malformed stub is a methodical sweep of the decode path.
That user agent names a public tool, so its code is readable. It takes its verdict from the Server and X-Powered-By response headers, and it does not treat Brotli response compression as proof of a vulnerable decoder. The Brotli bodies are there to establish which hosts have a decode path at all.
That is what the hour before a wave looks like. Someone is building a list of hosts that decompress Brotli. Whoever has a working chain next can use that list. Attackers aiming at Node.js today still reach for React2Shell (CVE-2025-55182) and older bugs. That changes now that someone has demonstrated code execution on official builds and a public proof of concept is out. An attacker measures the offset once for a popular build, then reuses it against every host running that build. A Node.js version sits in a banner, a Server header, a lockfile and a container tag, so picking targets is a database query. Version-specific waves are the economical way to run this at scale, and the per-build constant makes them the only way.
What to do
Patch to one of the fixed releases. Node.js 22.23.4, 24.22.1 or 26.12.1. Brotli 1.0.10, 1.1.1 or 1.2.1. Bun 1.4.4. On Python, Brotli 1.2.1 and brotlicffi 1.2.1.0. Node.js 20.x, 23.x and 25.x will not get a fix, so move off them.
A Node.js upgrade covers the copy of the decoder that Node bundles and nothing else. Other software carries a vendored copy, and without an SBOM it is hard to tell which of it needs patching. A distro build linking the system libbrotli, DuckDB, the .NET runtime, a PHP extension and any bun build --compile executable each need their own update. Rebuild images, regenerate lockfiles and restart processes, and cover staging, CI, build images, serverless runtimes and developer machines. A Dockerfile that pins 24.22.0 keeps shipping the vulnerable build until you change that line.
Until the patch lands everywhere, drop Content-Encoding: br on inbound requests if you can.
Assume the exploitation wave is coming. Reconnaissance has already reached ELLIO's sensors, the ELLIO Threat Research Team has built and lab-tested a working chain that reaches code execution on a default Node.js 24.22.0 on x86-64, and public proofs of concept keep appearing.
Update, 26 October 2026
At 15:08 UTC ELLIO's sensors observed a Brotli-coded request body of 23,069,983 bytes, hashing to 7fcbdaaecd4419e2930927844f3b772668204737a4e7602a2290c991c93c5c4b. The body is a single HTTP request built to attack one specific target: Node.js v24.22.0 on x86-64.
Inside it are two stages: a 1031-byte block carrying the attacker's instructions for the target's memory, and a 256-byte stage two telling the decoder where to put them. The delivery layer is a straight copy of publicly available PoCs. Rebuild the public stream generator with their block and you reproduce the first 23,069,727 bytes byte for byte, stage two is the public routine unmodified, and the target address, the file descriptor and the scratch space are all public values.
The ROP chain, the part that actually takes over the process, is original. It is the third construction we have seen. Both public chains hunt for a /bin/sh string that already exists inside Node and point at it. This one does something cleverer: it plants /bin/sh in its own smuggled data, drops a pointer to it into writable memory, and then makes Node call its own execvp with both arguments read from that memory. Same result, none of the same bytes. We are not publishing those addresses.
Every address in the chain points to targeting exactly one binary: the official Node.js v24.22.0 x86-64 release, sha256 67ca5783..., the same binary the public tooling targets.
If it works, it wires the attacker's connection to stdin and runs /bin/sh. In one respect that is less sophisticated than the chain we demonstrated above. Ours redirects both stdin and stdout, which is why the recordings show id and uname -a answering back over the connection that delivered them. This one redirects stdin only, so commands go in and nothing comes out. Whatever this actor does next, a reverse shell or a download, happens after initial exploitation.
The source is 94.154.43.7. ELLIO rates it malicious with high confidence, first observed on 15 December 2025 and active on 47 of the last 90 days, carrying 25 behavior tags including PHP2Shell exploitation, Palo Alto GlobalProtect and FortiOS SSL VPN probing, and Kubernetes API and container orchestration scanning, with six CVEs already on its record.
This is indicative of the RCE being adopted by an operator focused on mass exploitation.
The attacker's payload is a genuine, correctly-structured Node2Shell exploitation attempt for node v24.22.0 - the brotli stream, staged displacement, pivot pair, and delivery are all correct and identical to the public tooling. The ROP chain is an original construction (the third we have seen) that reuses three gadgets appearing in neither public chain. We tested it end-to-end against a pristine vulnerable node 24.22.0 target: our own chain (using the public ROP construction) scores a confirmed shell on the same target, while the attacker's chain scored zero hits in 50 attempts. The chain fires (the pivot works, the process crashes on miss) but never reaches execvp. This was likely a first-generation attempt fired in the wild before the operator fully tested the final stage against a live target - a working framework with a defective last 12 instructions.
Credits
Reported by OpenAI Codex Security, Khai Tran and Sylvie Mayer. Fixed in Node.js by Matteo Collina and Antoine du Hamel. Exploitation research on arm64 by the Imperva Red Team, Ron Masas. Reproduction on x86-64, the per-build offset finding and the decoder testing in this post by the ELLIO Threat Research Team. Python ecosystem analysis by OpenSourceMalware and Aikido. The Broccoli2Shell name is credited to Juliano Rizzo.
References
- Node.js, October 2026 security releases
- google/brotli commit
0ed4268a, "Detect and report invalid stream earlier" - GitHub advisory GHSA-9v8h-qqgm-4m35, for the PyPI
Brotliandbrotlicffipackages - Imperva, "Node2Shell: From Brotli Data to Remote Code Execution"
- Aikido, Node.js Brotli vulnerability
- Aikido, Python packages affected
- broccoli2shell.com, independent cross-ecosystem tracker

Track node2shell activity in the ELLIO Threat Intelligence Platform
No credit card required.
Written by
A group of researchers at ELLIO, transforming insights from mass exploitation and network reconnaissance into real-world cybersecurity defenses.
Read next
All posts →
PHP2Shell: attacks before the patch, and automation by nightfall
PHP2Shell is a pair of critical, unauthenticated RCE flaws in PHP core (CVE-2026-109618 and CVE-2026-109619), fixed on 21 October 2026. What the two bugs are, what to patch, and what the ELLIO Deception Network recorded on disclosure day, including what the attack tooling actually carried.
React2Shell in the Wild: Payload Analysis, Active Campaigns, and IoCs
The ELLIO sensor network has been tracking active exploitation of CVE-2025-55182 (React2Shell) in the wild. Here’s what we’re seeing.
React2Shell Update: Custom Go L7 DDoS Botnet
A single delivery IP has been exploiting React2Shell to distribute malware from an open directory. 31 binaries including a custom Go L7 DDoS botnet with Cloudflare token forgery, two Mirai variants across 13 CPU architectures, and a C2 server.