[CVE-2026-111497] Node2Shell: How Brotli's Counter Exhaustion Turned an Error Into an Arbitrary Write
At a glance Item Detail CVE ID CVE-2026-111497 (nicknamed Node2Shell) Vulnerability class CWE-787 (out-of-bounds write); root cause is CWE-252 (unchecked return value) Severity High (per Node.js's official c
At a glance
| Item | Detail |
|---|---|
| CVE ID | CVE-2026-111497 (nicknamed Node2Shell) |
| Vulnerability class | CWE-787 (out-of-bounds write); root cause is CWE-252 (unchecked return value) |
| Severity | High (per Node.js's official classification — though "remote blind RCE" is not established by any public PoC) |
| Affected component | Google Brotli decoder, c/dec/decode.c (Brotli 1.0.3–1.0.9 / 1.1.0 / 1.2.0) |
| Affected Node.js | 22.x < 22.23.4, 24.x < 24.22.1, 26.x < 26.12.1 (any distro Node that dynamically links system Brotli); Python brotli / brotlicffi share the same bug |
| Fixed in | Brotli 1.0.10 / 1.1.1 / 1.2.1, Node.js 22.23.4 / 24.22.1 / 26.12.1 (2026-10-21) |
| Reported by | OpenAI Codex Security (Khai Tran, Sylvie Mayer); fixed by Matteo Collina and Antoine du Hamel |
| Public PoC |
dimasma0305/CVE-2026-111497-POC; independent reproduction and struct-offset analysis in TheFool-TAROT/CVE-2026-111497-brotli-pwn
|
Brotli is the format behind Content-Encoding: br, so this decoder runs whenever Node.js decompresses Brotli data — directly through node:zlib, automatically inside the built-in fetch() when it receives a Brotli response, or inside Express 5 / body-parser 2 when they decode an incoming request body. Every point where Node.js unpacks Brotli data it didn't produce itself is attack surface.
1. The core problem — an ERROR disguised as "need more input"
A Brotli stream is organized into metablocks, and within each metablock three kinds of "block types" alternate: literals, insert-and-copy commands, and distances. Each block type has a block_length counter, and once that counter hits zero the decoder has to read a new block type from the Huffman tree to keep going.
If a stream declares only a single block type (NBLTYPES ≤ 1) and the attacker drains block_length down to exactly zero, the decoder ends up in a contradictory state: it needs to switch to a new block type, but there isn't one to switch to. That should be an immediate format error — the stream itself is malformed. Instead, the pre-patch code interprets this failed block switch as "the input ran out" and returns NEEDS_MORE_INPUT.
As simplified pseudocode:
// Brotli 1.0.3–1.2.0, c/dec/decode.c (reconstructed, simplified)
BROTLI_BOOL DecodeDistanceBlockSwitch(BrotliDecoderState* s) {
if (s->block_length[2] == 0) {
// NBLTYPES<=1 means there is no next block type to read —
// this is actually a malformed stream.
if (!BrotliSafeReadBits(br, s->block_type_bits_hint, &block_type)) {
// treated as "not enough input yet"
return BROTLI_FALSE; // caller translates this to NEEDS_MORE_INPUT
}
}
...
}
Hitting block_length[2] == 0 exactly isn't luck — it takes input engineered to consume precisely that much. That's why the PoC packs 2^24 (16,777,216) "zero-output" dictionary-transform commands into a single 16 MiB metablock. These commands write nothing to the output buffer; their only effect is draining the distance block counter by one each — a purpose-built way to grind the counter down to zero. Since RFC 7932's maximum metablock length (MLEN) is exactly 2^24, the attacker has to fill the metablock all the way to the cap to reach this boundary condition.
2. The streaming retry loop walks right past the boundary
NEEDS_MORE_INPUT is normally a perfectly legitimate signal. Node.js decompresses data in chunks, so when the decoder says "not enough yet," the caller appends the next chunk and retries. The problem is that the decoder can't tell the difference between genuinely running low on input and a structurally impossible block switch that will never resolve, no matter how many bytes you feed it.
In a real "need more input" case, a few more bytes supply the missing bits and decoding moves forward. But the fake NEEDS_MORE_INPUT produced by counter exhaustion can never be satisfied — there's no amount of additional data that fixes a stream whose block-type table is already exhausted. The retry loop doesn't know that, though, and keeps appending bytes into a small internal staging buffer (buffer.u8[], 8 bytes). That buffer was only ever meant to hold a few bits momentarily while reading them; it was never designed to grow past 8 bytes. This missing length check on the append is the actual CWE-787 (out-of-bounds write).
3. Attack flow diagram
4. The second write — spilling past 8 bytes into decoder state
The real attack is two writeSync calls. The first feeds the entire ~23 MB trigger stream, exhausting the counter and provoking the fake NEEDS_MORE_INPUT. At that point, internal buffer_length is already sitting at 1. The second call then delivers a small 127-byte follow-up chunk. The decoder keeps doing buffer.u8[buffer_length++] = byte, marching one byte at a time straight past the end of the buffer.u8 field (struct offset 72) and into whatever comes next in memory.
The relevant BrotliDecoderState field offsets (x86-64, Brotli 1.2.0, gcc) look like this:
struct BrotliDecoderStateInternal {
/* earlier fields omitted */
brotli_free_func free_func; // offset 56 — callback function pointer
void* memory_manager_opaque; // offset 64 — argument passed to that callback
uint8_t buffer.u8[8]; // offset 72 — the original 8-byte staging buffer (!)
uint32_t buffer_length; // offset 80
int32_t pos; // offset 88 — current write position in the ring buffer
uint32_t max_backward_distance; // offset 92
uint32_t max_distance; // offset 96
uint32_t ringbuffer_size; // offset 100
uint32_t ringbuffer_mask; // offset 104
uint8_t* ringbuffer; // offset 136 — actual decompressed output buffer
};
The trailing bytes of the 127-byte chunk overwrite pos, max_backward_distance, and max_distance wholesale with attacker-chosen values. The overwrite stops around relative offset 99, so it doesn't quite reach ringbuffer_size (100) or the ringbuffer pointer (136) — those two fields become read-only leverage in the next step instead of overwrite targets.
What matters here is that pos and max_distance are normally trusted values the decoder computes internally as it tracks stream progress — never something raw input bytes should be able to set directly. A single missing length check on an 8-byte buffer completely erases the line between "trusted internal state" and "attacker-controlled bytes."
5. The 16-byte arbitrary write — memmove16 and ring-buffer wraparound
Once pos, max_backward_distance, and max_distance have been overwritten, resuming decoding makes the decoder treat these as a valid back-reference and enter the ring-buffer copy path. For performance, Brotli's core copy routine moves data in 16-byte chunks via memmove16, and the destination of that copy is determined directly by the attacker-controlled pos.
// Back-reference copy (reconstructed, simplified)
uint8_t* dst = s->ringbuffer + s->pos;
uint8_t* src = s->ringbuffer + ((s->pos - distance) & s->ringbuffer_mask);
memmove16(dst, src); // copies 16 bytes with no bounds check
Under normal operation pos is always a positive value within the ring buffer's range, but after the second write it's already an arbitrary 32-bit signed integer the attacker fully controls. Make pos negative, and dst = ringbuffer + pos lands before the start of the ring buffer — inside the region occupied by the BrotliDecoderState struct itself. In effect, a single chosen pos value gives the attacker a primitive: "write 16 bytes at an arbitrary offset relative to the state struct."
Exactly which address src resolves to depends on a combination of pos, distance, and ringbuffer_mask, so precisely targeting it in one shot is hard. The public PoC works around this by pre-filling the first 536 bytes of the ring buffer with a repeating 16-byte [function pointer, argument pointer] pattern. Whichever aligned window src happens to land on, it reads out a valid pair of pointers — so whichever window gets chosen, the single memmove16 still copies something useful to the destination.
6. Callback hijacking — from free_func to system()
If pos is tuned so this 16-byte write lands exactly on free_func (offset 56) and memory_manager_opaque (offset 64), the attacker completely replaces the decoder state's "memory free callback." Brotli calls free_func(opaque, ptr) internally whenever BrotliDecoderDestroyInstance() runs. Overwrite free_func with the address of system, and opaque with the address of an attacker-supplied command string already sitting in the ring buffer, and the moment Node.js calls handle.close(), it effectively triggers system(command).
The public PoC's local reproduction script, run under node --predictable (which pins the address space layout), reliably reaches the point of executing an arbitrary command and dropping a marker file. The independent reproduction in TheFool-TAROT/CVE-2026-111497-brotli-pwn reaches the same conclusion — 3 out of 3 local command-execution attempts succeeded under a deterministic layout, and a much smaller 86-byte stream alone was enough to reproduce a separate denial-of-service that pegs a CPU core at 100%.
Imperva Red Team's independent analysis (which coined the name Node2Shell) reproduced code execution against the official Node.js 24.22.0 Linux arm64 build, through both Express 5 / body-parser 2 request bodies and a consumed Brotli HTTP response via fetch(), and confirmed the same inputs were rejected — with the process staying alive — on the patched 24.22.1.
7. Why this isn't a remote blind RCE — yet
Every piece of public research agrees on one limitation. The real target of memmove16 isn't simply pos — it's pos plus a ring-wrap compensation value W. W depends on pos itself and on decoding progress, and it varies between processes, and even between requests within the same process. Reliably hitting free_func remotely would require knowing, ahead of time, at least three things:
-
delta = address of the state struct − address of the ring buffer(readable within the same process, but it drifts between requests) - the absolute address of the
ringbuffer - a
libcbase address to resolvesystem
But every read path the bug itself provides is masked to the ring buffer's 1 KB window by ringbuffer_mask, so there's no way inside the bug to leak the addresses of the state struct, the heap, or libc. The TheFool-TAROT repo tried to close this gap three different ways — ① combining with a path-traversal, Range-capable file-read endpoint to read /proc/self/maps for a libc base, ② combining with an image decoder's out-of-bounds read leaking pointers through output pixels, and ③ a deterministic deployment with ASLR disabled plus two network writes — and in all three cases concluded that leaking something within the same process was achievable, but reliably identifying which decoder state belongs to the current request was not. The public PoC's own author states it plainly: the highest verified result is bounded command execution, demonstrated only on a deliberately non-PIE, ASLR-off local harness — not a portable remote exploit.
In short, a remote attacker's Brotli stream alone can reliably cause a crash or limited memory corruption, but reaching full code execution still requires some separate way to learn or predict the target process's address layout — that's the consistent conclusion across every piece of research published so far. Node.js's official classification of this bug as permitting remote code execution reflects the conservative, worst-case outcome; reaching that worst case still requires solving a separate ASLR-bypass puzzle.
8. What the patch changes
The core of the upstream Brotli fix (google/brotli@0ed4268a) is "detect and report invalid streams earlier." A structurally impossible block-type switch (block_length == 0 with no further block type available) is no longer allowed to flow through as NEEDS_MORE_INPUT; it's immediately classified as a format error and decoding stops there. Node.js also exposes a dedicated BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH error code in src/node_zlib.cc, so the streaming retry loop can never again misinterpret this condition as "just needs a bit more data." As a result, processing ends before the second chunk even has a chance to arrive — the point where the 8-byte buffer would have been overrun never gets reached at all.
9. Blast radius and mitigation
The line separating vulnerable from safe is simple: does the Node.js process decompress Brotli data that an attacker — or a server an attacker controls — supplied?
- Code that calls
node:zlibdirectly to decompress Brotli data - The built-in
fetch(), which automatically decompresses Brotli-encoded responses - Express 5 / body-parser 2 middleware decoding an incoming request body sent with
Content-Encoding: br(both have documented Brotli support starting at 2.x) - Any server that decompresses attacker-uploaded
.brfiles directly
Node.js's official advisory offers no interim mitigation beyond upgrading. Upgrading to 22.23.4 / 24.22.1 / 26.12.1 or later — including bundled Node binaries inside container images and single-executable applications (SEAs) — is the only reliable fix. If an immediate upgrade isn't possible, the only partial mitigation is temporarily restricting untrusted Brotli traffic on the affected paths.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.
