NEW RESEARCH! Node2Shell a.k.a. Broccoli2Shell a.k.a. HTTP2Shell RCE. Read here.
Threat/Vulnerability News

Node2Shell: remote code execution on stock Node.js builds (CVE-2026-111497)

14 min read

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.

node2shell hero: remote code execution through the Brotli decoder (CVE-2026-111497)

A public exposure checker calling itself node2shell-check has already reached ELLIO's sensors, sending 20 Brotli bodies in two bursts to establish which hosts decompress Brotli at all. It is surveying for 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, circulating as node2shell.

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.

A Brotli block switch naming the next block type, with two types declared

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

A Brotli block switch with a single type declared and nothing to name

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.

The three paths by which attacker-influenced Brotli reaches the Node.js decoder

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.

Fixed and still-affected Brotli decoder versions across the software ecosystem as of 25 October 2026

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.

ELLIO test results for CVE-2026-111497 against Node.js targets on x86-64

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.

Where the memory live at an attacker-forced crash ends up, on a host and inside a container

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.

What one malformed Brotli stream does to six decoders, before and after the fix

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

Timeline of CVE-2026-111497 from the Brotli 1.0.3 release in March 2018 to exposure scanning in October 2026

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.

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

PHP2Shell exploitation attempts tagged live in the ELLIO Threat Intelligence Platform.

Track node2shell activity in the ELLIO Threat Intelligence Platform

No credit card required.

Start free trial

Written by

ELLIO Threat Research Lab
ELLIO Threat Research Lab

A group of researchers at ELLIO, transforming insights from mass exploitation and network reconnaissance into real-world cybersecurity defenses.

Read next

All posts →