Skip to content

About

Node2Shell / HTTP2Shell (CVE-2026-111497) exposure-audit kit — read-only scanner + agent prompt pack. Portable: macOS bash 3.2 + Linux. Mutation-tested regression suite, CI on 3 platforms.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Node2Shell / HTTP2Shell — CVE-2026-111497 Response Kit

Auditing kit for the Google Brotli decoder out-of-bounds write (CVE-2026-111497),
also known as Node2Shell and HTTP2Shell.

Not the same as React2Shell. CVE-2025-55182 (React Server Components,
Dec 2025) is a different bug with a different root cause. This kit is only for
CVE-2026-111497.

Advisory snapshot: 24 October 2026. Version tables reflect the vendor
advisories as of that date. Re-check upstream before relying on them —
see Sources.

Contents

File What it is
brotli-exposure-audit.sh Read-only scanner. Machine + repo inventory, reach-path analysis.
node2shell-prompt-pack.md Agent workflows the scanner can't do: triage, remediate, incident.
QUICK-REFERENCE.md One-page version tables + commands.
test-audit.sh Mutation-tested regression suite: classifiers, macOS simulation, FROM-line matrix, curl/Electron/proxy checks, derived contracts.
AGENTS.md Operational invariants for AI agents contributing to this repo.
CONTRIBUTING.md How to help, and the portability rules this kit follows.
LICENSE MIT.

Design Principles

This kit is intentionally defensive and constrained.

Read-only by default The scanner and watcher never modify files, install packages, start services, or send network traffic (unless the user explicitly enables a local behavioral check).

No payloads shipped The repository contains zero exploit code, zero test vectors, and zero weaponized Brotli streams. Any behavioral verification requires the user to supply their own test vector. The kit will refuse to run if it detects a payload inside the repository itself.

Local-only execution All checks run on the machine or against local artifacts (binaries, images, containers). There is no remote scanning capability and none will be added.

Fail closed When the tool cannot confidently classify a runtime, image, or code path, it reports INCONCLUSIVE or a clear warning instead of guessing.

Auditability over convenience Every detection rule and version table is visible in the code. Convenience features that would hide logic or reduce transparency are rejected.

These constraints are non-negotiable. They exist so the kit remains a safe, trustworthy tool for defenders rather than something that can be turned into an offensive artifact.

Quick start

Install

Zero dependencies — one file, no build step, no package manager:

curl -fsSL https://raw.githubusercontent.com/pt-act/node2shell-cve-2026-111497/v1.0.5/brotli-exposure-audit.sh \
  -o brotli-exposure-audit.sh && chmod +x brotli-exposure-audit.sh

Or pipe it straight into a scan:

curl -fsSL https://raw.githubusercontent.com/pt-act/node2shell-cve-2026-111497/v1.0.5/brotli-exposure-audit.sh \
  | bash -s -- --no-code

Use

chmod +x brotli-exposure-audit.sh

# 1. Scan your machine and repos
./brotli-exposure-audit.sh . ~/projects/myapp

# 2. Runtime inventory only (no code scan)
./brotli-exposure-audit.sh --no-code

# 3. Repo scan only — fast and deterministic (skips the host inventory)
./brotli-exposure-audit.sh --code-only ~/projects/myapp

# 4. After patching: confirm the fix reached deployed artifacts
./brotli-exposure-audit.sh --verify

# 5. Optional: also run container images to read their runtime versions
./brotli-exposure-audit.sh --docker-run

The scanner is strictly read-only: it changes no files, installs nothing,
sends no network traffic, and never runs exploit payloads. It also never
starts container images unless you pass --docker-run — by default it
reads image metadata only. One precision worth stating: --verify reads
version strings from already-running containers via docker exec
(read-only probes; nothing is ever started).

In CI

# Fail the build when anything is exposed
./brotli-exposure-audit.sh --exit-code --no-code

# Machine-readable output
./brotli-exposure-audit.sh --json --no-code | jq .

--exit-code returns 1 when critical findings exist, 0 otherwise, and 2 on
a usage error (e.g. --no-code --code-only — contradictory flags are
rejected, never silently produce a clean verdict).
--json prints one JSON object on stdout (the human-readable report goes
to stderr) — so | jq . and > report.json both work exactly as written.

Reusable GitHub Actions workflow

This repo ships a reusable workflow. Call it from your own repo — it audits your Dockerfiles and dependency manifests on every pull request:

# .github/workflows/node2shell.yml in YOUR repo
name: node2shell
on:
  pull_request:
jobs:
  audit:
    uses: pt-act/node2shell-cve-2026-111497/.github/workflows/audit.yml@v1.0.5
    with:
      paths: '.'
      fail-on-findings: 'true'

The workflow fetches the scanner, runs --code-only over your repo (no host inventory — fast and deterministic on runners), uploads a JSON + text report as a build artifact, and fails the job when critical findings exist.

A critical finding that is known and reviewed can be declared in brotli-audit-exceptions.json at your repo root — file, class, and a reason. The job then passes with the finding still printed as [REVIEWED] in the log; without the file, any critical finding fails as before. An entry that matches no finding fails the gate too, so the list cannot rot. See audit-gate.py for the matching rules.

To keep it self-contained instead, copy .github/workflows/audit.yml into your own .github/workflows/ and adjust the curl line to point at the raw file.

Platform support

Runs on macOS (bash 3.2, BSD userland) and Linux. Verified by
test-audit.sh, which exercises the script under a real bash 3.2 build with a
simulated BSD environment (otool, no ldd, no sort -V, readlink without
-f).

./test-audit.sh /bin/bash          # test one shell
./test-audit.sh --all              # test bash 5 + bash 3.2

No third-party dependencies. No jq, no ripgrep, no GNU coreutils.

macOS notes

  • otool -L is used to detect shared libbrotli linkage. If the tool is
    unavailable the scanner says "could NOT determine" rather than guessing.
  • Homebrew libraries are found via brew --prefix / $HOMEBREW_PREFIX.
  • Running processes are found with ps -Ao pid=,comm= (not /proc).
  • Service checks use launchctl; macOS does not expose ExecStart the way
    systemd does, so launchd services are listed for manual inspection.
  • Version comparison is pure shell — BSD sort has no -V flag, and relying on
    it would silently corrupt every verdict.

What makes this CVE different

The bug is in the Brotli C decoder (c/dec/decode.c), not in an npm or PyPI
package. Every runtime that vendors or links Brotli inherits it:

  • Node.js (vendors its own copy under deps/brotli)
  • Bun, Envoy, Istio
  • Python Brotli / brotlicffi wheels
  • Anything linking system libbrotli (curl, distro Node.js packages)

Exposure requires BOTH:

  1. A runtime/library below the fixed version, AND
  2. A path for attacker-controlled Brotli data to reach the decoder.

A vulnerable version with no reachable decode path is not an exploit.

Reach paths (condition 2)

Path Detail Off switch?
Request bodies Express 5 / body-parser 2.x decode Content-Encoding: br by default (inflate: true). Also koa-bodyparser, @koa/bodyparser, koa-body, co-body, @fastify/compress. Express 4 / body-parser 1.x do NOT. express.json({ inflate: false })
Outbound responses fetch() (undici), axios, node-fetch, got decode Brotli responses automatically. Exposed when fetching a user-influenced URL — webhooks, link previews, OG fetchers, image proxies. None — runtime patch only
Direct node:zlib brotliDecompress, brotliDecompressSync, createBrotliDecompress, BrotliDecompress Don't pass untrusted bytes
Direct Python brotli/brotlicffi brotli.decompress(), brotli.Decompressor, Brotli.Decompressor — compression-only imports are not exposure; only decompression reaches the decoder Don't pass untrusted bytes

How the bug works

A crafted stream declares one block type, then switches block types. The
decoder should reject that as a format error, but an unchecked return value
made it treat the invalid switch as "need more input" and keep appending bytes
past an 8-byte internal buffer. That is the out-of-bounds write (CWE-787).
The fix adds a new error code, BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH (-17),
which is also what makes the safe version probe possible.

Fixed versions

Component Fixed in
Node.js 22.x 22.23.4
Node.js 24.x 24.22.1
Node.js 26.x 26.12.1
Node.js 20.x and older none — end-of-life, migrate
Bun 1.4.4
Google Brotli 1.0.10, 1.1.1, 1.2.1
Envoy 1.40.0, 1.39.3, 1.38.6, 1.37.8, 1.36.12 (1.36 branch unsupported)
Istio 1.31.2, 1.30.6, 1.29.9
Python Brotli 1.2.1
Python brotlicffi 1.2.1.0

Fixing it

Upgrade the runtime, the library, or both — which one matters depends on
whether the binary bundles Brotli or links it:

# does it link a SHARED libbrotli?
otool -L "$(command -v node)" | grep -i brotli    # macOS
ldd "$(command -v node)" | grep -i brotli         # Linux
  • Statically bundled → the runtime upgrade is the fix.
  • Shared linkage → the package manager's brotli package is the fix; the
    runtime version alone proves nothing.
Component Command
Node (nvm) nvm install 22.23.4 && nvm alias default 22.23.4
Node (Homebrew) brew upgrade node
Node (distro) apt install --only-upgrade nodejs / dnf update nodejs
Bun bun upgrade (or brew upgrade bun)
Brotli (Homebrew) brew upgrade brotli
Brotli (Debian/Ubuntu) apt install --only-upgrade libbrotli1
Brotli (Fedora/RHEL) dnf update brotli
Python pip install -U 'Brotli>=1.2.1' / conda update brotli
curl (brotli feature) Upgrade the library, not curl — brew upgrade brotli / apt install --only-upgrade libbrotli1

Distro packages may backport the fix without a version bump — check the
distro's advisory rather than the version alone.

Then rebuild and redeploy: an image that was rebuilt but not redeployed still
runs the old binary. ./brotli-exposure-audit.sh --verify checks exactly that.

Full command reference: QUICK-REFERENCE.md.

Two traps

1. process.versions.brotli is not a verdict. Node cherry-picked the Brotli
patch without bumping the bundled version string — 24.22.0 and 24.22.1 both
report 1.2.0. Use the constant probe:

node -p "require('zlib').constants.BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH ?? 'missing'"
# -17 = patched, 'missing' = not

This probe is authoritative only for statically bundled builds. If
otool -L (macOS) or ldd (Linux) shows a shared libbrotli, the fix must
come from your package manager's brotli package and the probe proves nothing.
And note what the probe actually proves: the constant existed at build
time
. It is necessary, not sufficient — always pair it with the linkage
check above before calling a runtime safe.

2. Half of SCA tooling now flags this — the other half never will. The npm
package named brotli is an unrelated project, so npm audit never sees this
CVE. In Python, PYSEC-2026-4322 (brotli) and PYSEC-2026-4323 (brotlicffi)
were published 2026-10-23, so pip-audit, Dependabot and OSV do flag the
Python packages now. Check versions directly regardless — the mapping lag on
day one is the lesson, not a reason to skip the check.

Six more traps the scanner handles for you

  • Prereleases sort below their release. 22.23.4-rc1 is treated as
    below 22.23.4, so it is flagged as vulnerable. The conservative direction.
  • A rebuilt image is not a redeployed image. --verify inspects running
    containers, the actual binary behind each running PID, and service units.
  • Pinned old tags stay vulnerable. FROM node:22.19.0 is flagged even if
    you rebuild daily, because the tag itself pins the vulnerable runtime.
  • Floating tags are unauditable, not vulnerable. FROM node:22-alpine
    resolves to whatever the registry serves at pull time — the scanner warns
    and asks you to pin instead of guessing a verdict either way. EOL majors
    are the exception: FROM node:18-alpine is CRITICAL, because the major is
    knowable from the tag even when the patch level is not.
  • Docker accepts spellings you might not write. Lowercase from and
    tab-separated FROM parse fine in a real build — the scanner's prefilter
    matches both, and a FROM line referencing node/bun that it cannot parse
    (build variables, external registries) WARNs instead of skipping silently.
  • Severity differs by who you ask. Node.js rates this High in its own
    releases; NVD/GHSA carry 9.2 Critical; Istio's advisory says 7.7.
    Same bug, different scope assumptions — don't be surprised by the spread.

Two caveats the scanner cannot resolve for you

  • Distro backports can fix without changing the version string. Debian
    DLA-4855-1 ships 1.0.9-2+deb12u1 and ELA-1861-1 ships 1.0.7-2+deb10u2 —
    a version-only test reads these as vulnerable even when patched. On a
    distro-managed libbrotli, the distro advisory is the authority, not the
    version.
  • Distro Node.js packages are a separate supply chain. Debian/Ubuntu
    nodejs packages link system libbrotli and have their own fix timeline —
    apt install --only-upgrade nodejs may not remediate; check the distro
    advisory. nginx and Apache are not affected by default (they do not decode
    Brotli request bodies or fetch-encoded responses).

Not covered (known scope limits)

This kit audits the Node/Bun/Python-Brotli/native-libbrotli surface, plus curl, Electron/Chromium apps, and Envoy/Istio decoder reachability. The following adjacent surfaces are real but out of scope today — listed here so "not flagged" is never read as "not affected":

  • OpenSSL with Brotli-compressed certificates. If your PKI serves certificates in Brotli-compressed form, OpenSSL's decoder is in the path. There is currently no reliable signal to detect this: OpenSSL does not advertise whether a given certificate or key was Brotli-compressed, and guessing from file contents produces false positives on ordinary DER. The scanner therefore does not attempt it — per the kit's own rule, detecting speculation is not detection. If you know you serve Brotli-compressed material, treat OpenSSL as in-scope and check it against the upstream OpenSSL advisory yourself.
  • Chromium browser decoders (Chrome/Chromium itself, not Electron apps). Electron apps are enumerated and warned about; a plain browser install is not, because its bundled Brotli version is not exposed by any stable signal and version tables for it decay every few weeks.
  • Named and indirect tags. node:lts, node:latest, node:${VERSION}, gcr.io/distroless/nodejs20 — aliases and indirection resolve at pull/build time, so the scanner WARNs (never guesses a verdict). Knowably-EOL codename aliases (argon…iron = Node ≤ 20) are CRITICAL because the major IS knowable from the tag; jod (22) WARNs as supported-but-unauditable.

Each item above maps to the Roadmap — nothing here is silently permanent.

Roadmap

Ordered by the kit's prime directive: close silent-skip and false-safe classes first, adoption second, research-heavy detection last. Every item lands through the standard gate (suite → commit → push → CI) with mutation-safe tests — see AGENTS.md for the rules.

Phase 1 — close the adjacent silent skips ✅

  • curl / libcurl check. curl --version advertises the brotli feature; otool -L / ldd shows which libbrotlidec it links. Feature + vulnerable dylib → CRITICAL with "upgrade the library, not curl" guidance. Reuses the existing check_native machinery. Shipped. A curl that bundles its own decoder WARNs instead — its version is not readable, and "not readable" is never reported as "safe".
  • Distro-backport annotation. Detect +deb / +el / +ubuntu version suffixes and annotate the CRITICAL: "a backport may already carry the fix — verify against your distro advisory." The verdict keeps its direction (a false positive annoys; a false safe kills) but discloses why it may be wrong. Shipped, covering the real-world suffixes: -2+b6 (Debian binNMU), -2+deb12u1, -2ubuntu1, -2.el9, -2+rhel9.
  • Facts-freshness stamp in QUICK-REFERENCE, mirroring the README's advisory-snapshot date. Shipped.

Phase 2 — reach and adoption ✅

  • Reusable CI workflow. The building blocks exist (--json, --exit-code, --code-only); ship a documented snippet that audits a repo's Dockerfiles on every pull request. Shipped as .github/workflows/audit.yml — a workflow_call workflow other repos can uses: directly.
  • Install story. A curl -fsSL one-liner for the zero-dependency scanner — friction-to-first-scan is the adoption metric. Shipped — see Install.

Phase 3 — the hard omitted components ✅ (documented-only where detection would be speculation)

  • Electron/Chromium. Enumerate installed Electron apps and WARN "bundles its own decoder — update the app." Detection plus advisory only; no version tables (they decay — the trap AGENTS.md forbids). Shipped for Electron apps and installs. Plain Chromium browser decoders remain documented-only, for the same reason.
  • OpenSSL brotli-compressed certificates. Documented-only until upstream produces a checkable signal. Detecting speculation isn't detection. Shipped as documentation — see Not covered.
  • Envoy/Istio decoder-reachability (config analysis): last, on demand. Shipped as a file-based check (no network calls, per AGENTS.md): the scanner greps readable Envoy/Istio config paths for brotli decompression. It states explicitly that config-not-found is not proof of absence — when the version verdict is CRITICAL, treat it as exposed until confirmed otherwise.

Still open

  • A release. The repo has no tagged release yet; curl -fsSL .../main/ works, but a tag pins the script to a known-good commit.
  • Chromium browser decoders — a stable signal would be needed first.
  • Envoy admin endpoint (/admin/config_dump) would give exact reachability, but querying it is a network call and AGENTS.md forbids those. The file-based check is the deliberate compromise.

Affected ranges are not a single threshold

The CNA ranges are three separate windows, and the fixed release depends on
the line you are on:

Line Vulnerable Fixed in
1.0.x 1.0.3 – 1.0.9 1.0.10
1.1.x 1.1.0 1.1.1
1.2.x 1.2.0 1.2.1

So "version < 1.2.1 means vulnerable" is wrong — it would flag a patched
1.0.10 and a patched 1.1.1. The scanner classifies against all three
ranges. A prerelease sorts below its release (1.2.1-rc1 is vulnerable).

Known limitations

  • Version checks are not proof. A runtime can carry a backported fix without a
    version bump — always confirm against your vendor's advisory.
  • The scanner reads versions and code patterns. It cannot prove a route is
    reachable pre-auth. That needs a code read (see the Triage prompt in the pack).
  • Output is capped: 40 files per code pattern and 3–5 hits per file. A large
    repo may need several runs.
  • Filenames containing newlines are not handled (paths with spaces are).
  • The Docker path reads image metadata by default. A distroless image
    exposes no runtime version that way — it reports "no node/bun present".
  • Reach-path detection is heuristic. A hand-written node:zlib call through
    an alias may not match the patterns.
  • The scanner reports what it finds. It does not decide whether a service is
    actually reachable from the internet — that judgement is yours.

On the "structurally unreachable" claim

A public analysis argues that the block-switch path is unreachable from any stream. The fixed decoder's own FORMAT_BLOCK_SWITCH error falsifies it. The counterexample, the affected source lines, and the hole in the argument's Lemma 2 are in docs/structural-unreachability-response.md.

Keeping watch after the audit (v2)

The patch wave passes; EOL runtimes, cached images, and stale CI artifacts stay exposed for months. watch/ is the long-tail layer:

  • watch/install_watch.py — fingerprints local Node/Bun/Electron binaries, Homebrew formulas, and Docker images; writes candidates.jsonl.
  • watch/lab_consumer.py — runs a user-supplied malformed Brotli vector against a queued artifact's own decoder; writes verdicts.jsonl with CONFIRMED_VULNERABLE, CONFIRMED_FIXED, or INCONCLUSIVE.

Both are optional and local-only. The core scanner remains zero-dependency bash; the watcher uses Python 3 stdlib only. The repository ships no trigger. See watch/README.md and the Local verification exception in AGENTS.md.

Safety

Do not send exploit traffic to any server. No vendor or CERT ships a safe live
test, and public PoCs are weaponisable. Version checks and code review are safe;
active exploitation is not. For indicators of compromise instead, see the
Incident prompt in the prompt pack.

This kit contains no exploit code and no payloads. The core scanner reads versions and source files, and nothing else.

The optional watch/ verifier is the one narrow, explicit exception: it can run a user-supplied malformed Brotli test vector against a local artifact you own, in a process or container with no network, and records the decoder error class. It ships no vector and executes no command. See the Local verification exception in AGENTS.md.

Status as of 24 Oct 2026

  • CVE published 22 Oct 2026; Node.js patched 21 Oct 2026
  • Not in CISA KEV; CISA SSVC: exploitation none, automatable no, impact total
  • EPSS ~0.5%
  • Public PoCs exist; Imperva demonstrated RCE on the official Node 24.22.0 arm64 image
  • Pre-auth RCE is demonstrated; mass exploitation is not confirmed

Patch anyway. The CVSS vector includes AT:P precisely because of condition 2.

Timeline

Date (2026) Event
Feb 27 Root cause fixed in google/brotli — not recognised as security-relevant, no release
Oct 16 CVE-2026-111497 reserved
Oct 21 Brotli 1.0.10 / 1.1.1 / 1.2.1 released; Node.js 22.23.4 / 24.22.1 / 26.12.1
Oct 22 CVE published; PyPI advisory; Bun 1.4.4 released
Oct 23 Python and other runtimes confirmed affected; Imperva demonstrates RCE
Oct 24 This snapshot of the advisories

Facts freshness policy

The code in this kit does not decay; the version tables and advisory status do. Before relying on any pinned copy:

  • Snapshot date — the README and QUICK-REFERENCE carry the date the facts were last verified against vendor advisories (the block at the top of each). Facts older than a month should be re-checked, not trusted.
  • Who updates — the maintainer (repo owner) on: any Node.js or Brotli security release touching this CVE, any KEV/EPSS status change, or a dated issue from a reader. CONTRIBUTING.md §Upstream data applies.
  • A pinned tag (e.g. v1.0.0) means — the code at that commit, with facts as of that tag's snapshot date. A tag does not certify the facts; the snapshot date in the tagged README does. When facts move, a new tag carries them; main always holds the current stamp.
  • The install one-liner pins the tag for reproducibility — if you need today's facts instead, fetch from main.

Sources

Naming

"Node2Shell" and "HTTP2Shell" are community names for this CVE, coined by
security vendors. Note that React2Shell (CVE-2025-55182) is a different
bug with a different root cause — searching "node2shell" mixes the two.

About

Node2Shell / HTTP2Shell (CVE-2026-111497) exposure-audit kit — read-only scanner + agent prompt pack. Portable: macOS bash 3.2 + Linux. Mutation-tested regression suite, CI on 3 platforms.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages