Supply Chain Security

A One-Pass Sweep for Every Vendored Brotli Copy: CVE-2026-111497 in Node, .NET, Bun and Python

Dark cyberpunk illustration of one cracked glass prism repeated across dozens of identical pedestals in a cavernous hall, each copy lit from inside in amber while cyan light threads run between them.

Every CVE-2026-111497 check I have read asks one question: can an attacker send my server a Brotli-compressed request? That covers half of the exposure. The same decoder runs on the way back in, on every compressed response your monitoring agent, your CI runner, your webhook retry or your fetch() call pulls from a host you do not control. Node.js shipped the fix on October 21. Microsoft shipped an out-of-band .NET release on October 25. Two days after that, most of the vulnerable copies on a typical estate are still sitting there, because no package manager will ever touch them.

What the decoder gets wrong

The flaw is an out-of-bounds write in Google's Brotli C decoder, present in versions 1.0.3 through 1.2.0. The Node.js security release states the mechanism without decoration: "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." Feed it a malformed stream and it writes past the end of that buffer into adjacent memory. The outcomes Node lists are a process crash, disclosure of process memory, or remote code execution.

Google, as the assigning authority, scores it 9.2 under CVSS v4.0. Microsoft scores its own .NET exposure 8.1 under CVSS v3.1. Both numbers hold up, because they measure different objects: a library with no configuration, and one runtime where the relevant features ship switched off. Google committed the upstream fix on February 27, eight months before anyone outside the project could attach a CVE to it. Node credits the report to Khai Tran and Sylvie Mayer of OpenAI Codex Security.

You will see this called Brotli2Shell, Broccoli2Shell, Node2Shell and HTTP2Shell. All four names point at the same CVE.

Who owns a vulnerable copy, and who can stop reading

If you run Node.js 22, 24 or 26, Bun, .NET 8, 9 or 10, a Python service with the brotli or brotlicffi wheel installed, Envoy, Istio, or PHP on Windows, you own at least one affected copy and it needs a version check today. If your stack is PHP on Linux from a distribution package, Java without Brotli4j, or a Go service that never imports andybalholm/brotli, this one is not yours and you can close the tab.

One group should read the rest of this slowly: anyone whose code fetches URLs it does not control. Scrapers, link previewers, SSRF-adjacent integrations, AI agents that retrieve pages, uptime checkers, and any service that follows a customer-supplied webhook URL all decompress responses from hosts an attacker can rent. A web application firewall in front of your listener does nothing about that direction of traffic.

Why upgrading the system library changes nothing

Node, Bun and .NET each compile their own copy of the decoder into the runtime. Running apt upgrade libbrotli1 leaves all three exactly as they were. The reverse trap is worse: when a distribution backports the fix, it keeps the same ABI version and the same filename, so reading libbrotlidec.so.1.1.0 off disk tells you nothing about whether the patch is inside it. The only authority on a shared library is the package manager that installed it.

That cuts the other way too. Oligo's write-up notes that Debian's nodejs packages in Debian 12, 13 and 14 were listed as affected with no fix available, which means a team that installed Node from the distribution repository is waiting on someone else's build, not on their own change window.

The sweep

This is the script I run on a host to answer "which copies do I have, and are they fixed." It is read-only, it shells out to nothing it does not already trust, and it exits non-zero when anything is unfixed, so it drops straight into a configuration-management check or a CI job.

#!/usr/bin/env bash
# brotli-sweep.sh - report every Brotli decoder copy on this host and whether
# it is fixed for CVE-2026-111497. Read-only. Exit 1 if anything is unfixed.
set -uo pipefail
fail=0

ok()   { printf 'OK      %s\n' "$1"; }
bad()  { printf 'UNFIXED %s\n' "$1"; fail=1; }
skip() { printf 'ABSENT  %s\n' "$1"; }

# True when $1 is older than $2 under version sort.
older() { [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" = "$1" ]; }

# Node bundles its own decoder. 22.x -> 22.23.4, 24.x -> 24.22.1, 26.x -> 26.12.1.
if command -v node >/dev/null; then
  v=$(node -p 'process.versions.node')
  case "${v%%.*}" in
    22) min=22.23.4 ;; 24) min=24.22.1 ;; 26) min=26.12.1 ;;
    *)  bad "node $v (release line is end of support, no fix shipped)"; min= ;;
  esac
  [ -n "$min" ] && { older "$v" "$min" && bad "node $v (need $min)" || ok "node $v"; }
else skip "node"; fi

# Bun 1.0.21 through 1.4.3 are affected.
if command -v bun >/dev/null; then
  v=$(bun --version)
  older "$v" 1.4.4 && bad "bun $v (need 1.4.4)" || ok "bun $v"
else skip "bun"; fi

# .NET 8 -> 8.0.33, 9 -> 9.0.22, 10 -> 10.0.14.
if command -v dotnet >/dev/null; then
  while read -r _ v _; do
    case "${v%%.*}" in 8) min=8.0.33 ;; 9) min=9.0.22 ;; 10) min=10.0.14 ;; *) continue ;; esac
    older "$v" "$min" && bad "dotnet runtime $v (need $min)" || ok "dotnet runtime $v"
  done < <(dotnet --list-runtimes | grep '^Microsoft.NETCore.App')
else skip "dotnet"; fi

# Python wheels carry their own decoder: Brotli -> 1.2.1, brotlicffi -> 1.2.1.0.
for py in python3 python; do command -v "$py" >/dev/null && { PY=$py; break; }; done
if [ -n "${PY:-}" ]; then
  while IFS='=' read -r pkg _ v; do
    case "$(printf '%s' "$pkg" | tr 'A-Z' 'a-z')" in
      brotli)     min=1.2.1 ;;
      brotlicffi) min=1.2.1.0 ;;
      *) continue ;;
    esac
    older "$v" "$min" && bad "python $pkg $v (need $min)" || ok "python $pkg $v"
  done < <("$PY" -m pip list --format=freeze 2>/dev/null | grep -iE '^(brotli|brotlicffi)==')
fi

# Shared library: the filename is not evidence. Ask the package manager.
if command -v ldconfig >/dev/null; then
  ldconfig -p | grep -o '/[^ ]*libbrotlidec[^ ]*' | sort -u | while read -r so; do
    printf 'REVIEW  shared %s -> %s\n' "$so" "$(readlink -f "$so")"
  done
fi

exit $fail

On a stock container image this morning it returned UNFIXED node 22.22.0 (need 22.23.4) and two REVIEW lines for the shared library. Resolve a REVIEW line with the package manager, never by reading the version out of the filename:

# Debian and Ubuntu
dpkg -S /usr/lib/x86_64-linux-gnu/libbrotlidec.so.1.1.0
apt-cache policy libbrotli1

# RHEL, Rocky, Alma
rpm -qf /usr/lib64/libbrotlidec.so.1
rpm -q --changelog brotli | grep -i -m3 111497

Run it where the copies actually live

  • Inside every container image, not on the Docker host. The host runtime and the image runtime are different copies.
  • On CI runners and build agents, which pin their own Node and .NET versions and fetch from the internet all day.
  • In serverless base images and anything built from a FROM node: tag that was pinned months ago.
  • On developer laptops, if they run the same scrapers and integration scripts as production.

The clients that decode Brotli without being asked

Node fixed the decoder in 22.23.4, 24.22.1 and 26.12.1, all released October 21 by cherry-picking the upstream commit. The 20, 23 and 25 lines reached end of support and received nothing; if you are on one of those, the fix is a major version move, not a patch.

The exposure is wider than the code you wrote, because several clients decompress Content-Encoding: br on their own:

  • Built-in fetch(). It decodes a Brotli response based on the header the server sent, not on what your request advertised. Changing the Accept-Encoding you send is not a mitigation.
  • Express 5 with body-parser 2.x decodes Brotli-compressed request bodies by default. Express 4 rejects them with a 415.
  • axios, got and node-fetch 3.x decode br by default, and each has an option to turn it off.
  • undici.request() never decompresses, so the decoder cannot run unless you pipe the body through zlib yourself.

That list is the practical reason a grep for brotli in your application code comes back clean on a vulnerable service. The decompression is a default in the layer below you.

.NET shipped the fix twice in two weeks

Microsoft's out-of-band release on October 25 fixes the Brotli bug in .NET 8.0.33, 9.0.22, 10.0.14 and .NET 11 Release Candidate 3. Microsoft's own description is narrower than Node's: an unauthenticated attacker "may be able to read or corrupt process memory," leading to information disclosure, denial of service or elevation of privilege. .NET Framework is not affected.

Three details decide whether you are exposed. ASP.NET Core request decompression is off by default. HttpClient automatic response decompression is off by default. Direct calls into the System.IO.Compression Brotli APIs on attacker-controlled data are the third path, and that one is your code. So check for the opt-ins before you panic:

grep -rn --include='*.cs' --include='*.csproj' \
  -e 'AddRequestDecompression' \
  -e 'AutomaticDecompression' \
  -e 'BrotliStream' \
  -e 'DecompressionMethods' .

The same release re-issues two fixes from October 13 that Microsoft says were incomplete: CVE-2026-70197 in SocketAsyncEventArgs and CVE-2026-102870 in the Blazor WebAssembly debugger. A shop that patched .NET two weeks ago and marked the ticket closed needs this release for three reasons, not one.

The rest of the fix matrix

Tenable's FAQ tracks the downstream releases, and the list is long enough that an inventory beats a memory. Upstream Brotli is fixed in 1.0.10, 1.1.1 and 1.2.1. Python's Brotli wheel is fixed in 1.2.1 and brotlicffi in 1.2.1.0. Bun is fixed in 1.4.4. Envoy is fixed in 1.36.12, 1.37.8, 1.38.6, 1.39.3 and 1.40.0; Istio in 1.29.9, 1.30.6 and 1.31.2, which matters if you inherited a service mesh you do not administer closely. PHP for Windows rebuilt its bundles as 8.2.35-1, 8.3.36-1, 8.4.27-1 and 8.5.12-1, and the PHP brotli extension is fixed in 0.21.1 elsewhere. Wiz adds the Go port, andybalholm/brotli, fixed in v1.2.7.

Two are still open as of this morning. ClickHouse has the fix merged upstream with no release carrying it, and the Apache Arrow issue is unresolved. If either is in your path, the control you have is the network, not the version: terminate untrusted compressed traffic somewhere you have patched and hand those services plain bytes.

What the exploitation reports actually say

The claims in circulation do not agree, and the difference matters when you are deciding whether to patch tonight or Thursday.

  • The first public proof of concept appeared on October 22 and reached code execution only against a deliberately weakened build.
  • Imperva's red team reported execution on an unmodified Node.js 24.22.0 Linux arm64 image, and described it as unreliable across attempts. Node 24.22.1 rejected the same input.
  • Tenable's FAQ reports that ELLIO's sensors recorded an exploitation attempt against Node.js 24.22.0 on x86-64 on October 26, combining public proof-of-concept code with an original chain. Tenable states it has not independently confirmed that activity.
  • The CISA KEV catalog, version 2026.10.26, does not list CVE-2026-111497. The two PHP entries CISA added that day are a separate story.

Read together: a working exploit against a real build exists in at least two hands, reliability is not there yet, and there is no federal deadline. That is a patch-this-week vulnerability with a hard floor under it, and the floor is that memory corruption in a decoder gets more reliable over time, never less. Wiz reports that 91 percent of the cloud environments it measured hold a vulnerable resource and 82 percent have a publicly exposed Node.js instance, so attacker targeting costs nothing.

Rebuild the images, then re-run the sweep

A patched package on disk is not a patched process. The decoder is loaded into memory at start, so every service you updated keeps running the old copy until it restarts, and every container keeps running the old copy until the image is rebuilt and redeployed. Updating a base image and leaving yesterday's pods in place changes a number in a scanner and nothing else. Patch, restart, rebuild, redeploy, then run the sweep again and keep the output: when someone asks in three weeks whether you were exposed, a dated list of every copy and its version is the answer, and nobody can reconstruct it later.

Want to try our open-source security tools?

We build and publish open-source tools that automate the unglamorous parts of security work, including external attack-surface and exposure checks. Browse them at github.com/redhoundinfosec, or book a session if you want a second set of eyes on an estate-wide inventory before the next decoder bug lands.