Host-side detections for Node2Shell (CVE-2026-111497)
One pass, four checks: runtime posture, decoder fingerprints, dependency exposure, log fingerprints — plus an opt-in behavioural self-test.
An out-of-bounds write in Google's Brotli C decoder, which Node.js bundles (so do Bun and
the PyPI Brotli / brotlicffi wheels). It is reachable whenever an application
decompresses attacker-influenced Brotli data through node:zlib — a Content-Encoding: br
request body behind Express 5 / body-parser 2.x, or the response of a server the app fetches
from. Node.js shipped the fix on 2026-10-21: 22.23.4 / 24.22.1 / 26.12.1. Every other Node
line is end-of-life with no fix. Bun: 1.4.4. Upstream brotli: 1.0.10 / 1.1.1 / 1.2.1.
This package answers the on-shift question four ways, and ships the network coverage beside it
(see Rules package).
# 1) posture + decoder fingerprints with defaults (discovers node/nodejs/bun/deno on PATH, nvm, fnm,
# volta, asdf, n, Homebrew, /usr/bin, /usr/local/bin; libbrotlidec in the standard lib dirs;
# Python Brotli modules in the running interpreter's site-packages)
python3 -m node2shell_detector
# 2) point at the runtime that actually serves traffic, and at the project it serves
python3 -m node2shell_detector --node-bin /opt/app/node/bin/node --project /srv/app
# 3) scan logs: access log + application / journald text (repeatable --log; .gz supported)
python3 -m node2shell_detector --log /var/log/nginx/access.log --log /var/log/app/stderr.log
# 4) fingerprint an Electron app or any folder of binaries / vendored decode.c
python3 -m node2shell_detector --check binaries --scan-dir /Applications/SomeApp.app
# 5) machine-readable report
python3 -m node2shell_detector --project /srv/app --log /var/log/nginx/access.log --json | jq .Developed by IONSEC · repo: https://github.com/ionsec/node2shell-detector
Every fingerprint, CVE fact, fixed version and threshold in this package comes from the IONSEC
research brief (compiled 2026-10-24 from fetched primary sources — the Node.js release post, the
google/brotli tags and commits, GHSA-9v8h-qqgm-4m35, vendor and distro advisories, and the public
write-ups). No fingerprint is invented. Findings carry their provenance: VERBATIM = exact
documented string / shape / version; DERIVED = a threshold or inference grounded in a documented
observation, with the derivation stated inline. Findings cite the brief's fingerprint ids
(N2S-01 … N2S-25). Where the brief says UNCONFIRMED, the tool says unknown / verify and
never issues a guessed verdict.
Status as of the brief (2026-10-24): CNA CVSS v4.0 9.2; Red Hat Important, CVSS 3.1 8.1; Node.js rates it High. No confirmed exploitation anywhere the research looked; CISA ADP SSVC: Exploitation none, Automatable no, Technical Impact total; not in KEV. Imperva reproduced code execution on stock Node 24.22.0 (Linux arm64) in a lab — "did not succeed on every attempt". A public PoC landscape exists (40+ repos), so treat the attempt shapes below as live.
| Check | What it does | What a finding means |
|---|---|---|
| HOST POSTURE | Runtime inventory and verdicts: node / nodejs (PATH, nvm, fnm, volta, asdf, n, Homebrew, /usr/bin, /usr/local/bin, --node-bin), bun, deno; node --version and a node -p probe of process.versions + node_shared_brotli; shared-brotli builds resolved against the distro / Homebrew package; Python Brotli / brotlicffi read from site-packages metadata (no imports); the N2S-20 marker file |
Exposure. Not an incident. A version-table verdict is only as good as the build is official — see the evidence hierarchy below. |
| BINARY FINGERPRINTS | Stream-scans binaries for the Brotli error-table strings (N2S-23, derivation below): node / bun, libbrotlidec*, Python _brotli*.so / _brotlicffi*.so / .pyd, project *.node addons, and any ELF / Mach-O (incl. universal) / PE under --scan-dir (e.g. Electron frameworks); checks vendored decode.c sources (N2S-24) |
Derived evidence of which decoder a file carries — stronger than a version string, weaker than behaviour. No table in the file = inconclusive, never patched. |
| DEPENDENCY EXPOSURE | Lockfiles (package-lock.json v1/v2/v3, npm-shrinkwrap.json, yarn.lock classic + berry, pnpm-lock.yaml, text bun.lock), node_modules (bounded walk), Python requirements / poetry.lock / uv.lock / pdm.lock / Pipfile.lock, go.mod / go.sum, Cargo.lock, Gemfile.lock, pom.xml / build.gradle, Dockerfile and compose node / oven/bun tags — against the brief's exposure table |
Reachability. An npm / PyPI package only decides whether attacker bytes reach a decoder; the runtime decides exploitability (brief §5). Server-side decoders: warn, critical when the same run found a vulnerable runtime. |
| LOG SCAN | Access logs (combined, common, LTSV, JSON lines) and application / stderr / journald text against the documented fingerprints: scanner UAs, Content-Encoding: br request shapes, seed bytes in escaped bodies, the Ostorlab sweep, lab routes, decoder error codes, crash lines, id output in a response |
Attempt evidence, not execution. A hit timestamps an attempt and gives hunting context. |
SELF-TEST (opt-in, --self-test) |
Feeds the public 22-byte Node regression vector to each node / bun binary's own decoder inside a throwaway child process, with a timeout (N2S-25) | Ground truth for that binary: ERR__ERROR_FORMAT_BLOCK_SWITCH / -17 = fixed; BLOCK_LENGTH_1 / -9, any other decoder error, or a crash = vulnerable behaviour. |
- A log hit never proves execution. Imperva calls unexpected process exits a triage clue,
"not a reliable stand-alone sign of exploitation". Correlate
Content-Encoding: brrequests with crash records, response anomalies (uid=… gid=…in a response body — N2S-19) and the host checks. The fetch-trap direction (your service fetching from an attacker-controlled server that answersbr) needs no inbound request: a WAF or an access log of inbound traffic never sees it. process.versions.brotlidoes not change when the fix lands. It is computed fromBrotliEncoderVersion()and the fix commit does not touchversion.h, so a fixed 24.22.1 still reports1.2.0. The tool prints it for context and never uses it for a verdict. Use the Node version for official builds, the distro package for shared-brotli builds, the decoder fingerprint, or the self-test.- Evidence hierarchy. When checks disagree, trust, in this order: behavioural self-test
(N2S-25) › decoder fingerprint of the binary (N2S-23) › shared-library / package verdict ›
version table. With
--self-testthe behavioural result overrides the version / package verdict for that binary (a vendor backport without a version bump stops false-flagging). Without it, a version-vs-fingerprint disagreement is reported asEVIDENCE_CONFLICT(warn) instead of being resolved silently. - Posture is point-in-time and covers the runtimes it can see. It does not enter containers,
and managed platforms (AWS Lambda, Vercel, Netlify, Google Cloud Functions / Run, Azure Functions)
have no public patch level in the brief — verify
process.versionfrom inside the function. AWS Elastic Beanstalk / App Runner and Amazon Linux runtimes were still on unfixed builds when the brief was compiled. - EOL honesty. Node 25 / 23 / 21 / 20 / ≤ 19 have no fixed release; the only remedies are moving to 22.23.4+ / 24.22.1+ / 26.12.1+ or a vendor build that backports the fix (verify it with N2S-23 / N2S-25). The brief verified lines back to v12.22.12; for anything older the tool says so.
- After patching, restart every process — a patched binary on disk is not loaded into running processes. Container images must be rebuilt, not restarted (docker-node moved the 22 / 24 / 26 images on 2026-10-21; floating tags are reported as info — rebuild).
- Shared-brotli builds (
--shared-brotli, including Homebrew's Node) link the systemlibbrotlidec: the Node version says nothing and the verdict moves to the library (table below). Nothing is guessed when the library cannot be attributed: unknown / verify, warn. - Strings are derived evidence. A stripped error table reads inconclusive. A file that links two Brotli copies (an Electron Framework carries Chromium's network decoder and Node's) is fixed only if every table found is; the Electron case is downgraded to info because the brief lists Electron as UNCONFIRMED.
- Deno uses Rust decoders (brotli 6.0.0 + brotli-decompressor 4.0.1 in 2.9.8's
Cargo.lock), not the C library. Whether the Rust port carries the same flaw is unresolved, not cleared — reported as info. The same holds for the Rust crate inCargo.lock. - The edge is covered by the separate rules package — host tooling complements, not replaces, network / WAF coverage (see Rules package).
Nothing in this tool opens a socket (a test asserts the package imports no network module).
Posture shells out to local commands with timeouts — node --version, node -p, bun --version,
deno --version, ldd / otool -L, dpkg-query, rpm, apk, brew list (with Homebrew's
auto-update and analytics switched off) — after stripping NODE_OPTIONS / BUN_OPTIONS from the
child environment; everything else reads local files. There is no update or IOC-feed fetching
feature — IOC freshness is your intel process's job. The opt-in self-test spawns a child runtime
on a local script; the child has no reason to, and the script does not, load any network module.
From this directory (python3 = 3.10+, standard library only):
python3 -m node2shell_detector
| Flag | Meaning |
|---|---|
--project PATH |
project directory for the dependency scan (repeatable); its .venv / venv site-packages are read too |
--log PATH |
log file(s) to scan; repeatable; plain or .gz |
--node-bin PATH |
extra node binary to interrogate (repeatable); default discovery covers PATH, nvm, fnm, volta, asdf, n, Homebrew, /usr/bin, /usr/local/bin |
--bun-bin PATH |
extra bun binary (repeatable); default probes PATH and ~/.bun/bin/bun |
--deno-bin PATH |
extra deno binary to report on (repeatable); default probes PATH and ~/.deno/bin/deno |
--python-site PATH |
extra site-packages / dist-packages directory to read (repeatable) |
--scan-dir PATH |
directory tree to fingerprint for ELF / Mach-O (incl. fat) / PE binaries and vendored decode.c (repeatable), e.g. an Electron app |
--check NAME |
run only this check: posture / binaries / deps / logs (repeatable; default: all four) |
--self-test |
opt-in behavioural test (N2S-25) of each node / bun binary — read Self-test first |
--self-test-timeout SEC |
timeout per self-test child (default 10) |
--no-discover |
inspect only what you name (--node-bin / --bun-bin / --deno-bin / --scan-dir / --python-site / --project): skip PATH, version-manager, Homebrew, system-library and running-interpreter discovery — useful inside containers and for reproducible runs |
--log-format FMT |
auto (default) / combined / common / ltsv / json |
--max-lines N |
lines scanned per log file (default 200,000) |
--max-binary-mb MB |
skip binaries larger than this when fingerprinting (default 512) |
--probe-timeout SEC |
timeout per local probe command (default 10) |
--json |
JSON report (same content as text mode) |
--quiet |
text mode: only warn / critical finding lines |
--version, -h, --help |
metadata / help |
| Code | Meaning |
|---|---|
0 |
checks ran; no warn / critical finding |
1 |
at least one warn / critical finding — inspect the report |
2 |
invocation / resource problem (bad flag values, missing explicit paths) |
- Severities:
critical(act now: a vulnerable or EOL runtime, a decoder without the fix,idoutput in a response),warn(real exposure surface or attempt evidence),info(context — including every unknown / verify),ok(verified fixed / mitigated). - Every finding carries
basis(VERBATIM/DERIVEDprovenance),ids(the brief'sN2S-xxfingerprints it rests on) and, for log findings, the file, line and a defanged evidence excerpt (IPv4 addresses are[.]-mangled; control bytes are escaped). - Per log rule, up to the first 8 line hits are shown with
repeat_countshowing the real volume; the per-file list is capped at 60 entries severity-first.finding_countper file is the untruncated total. Full detail:--json. - Set
NODE2SHELL_DEBUG=1to include tracebacks of a failed check in the error list.
| Runtime / line | Verdict | Source |
|---|---|---|
| Node 22 < 22.23.4, 24 < 24.22.1, 26 < 26.12.1 | critical — a fixed release exists | brief §1, §3 |
| Node ≥ the fixed release on those lines | ok (restart everything) | §3 |
| Node 25 / 23 / 21 / 20 / ≤ 19 | critical — EOL, no fix, move lines | §3 |
| Node on a line newer than the brief's table | info — unknown / verify | — |
| pre-release / nightly / custom version string | warn — unknown / verify (use N2S-23 / N2S-25) | — |
Shared-brotli Node (node_shared_brotli, or ldd / otool -L shows libbrotlidec) |
the system library decides (next table) | §3 |
| Bun < 1.4.4 | critical — every earlier release affected | §3, §5.3 |
| Bun ≥ 1.4.4 | ok | §3 |
| Deno | info — Rust decoder, status unresolved | §5.3 |
Python Brotli < 1.2.1 / brotlicffi 1.0.9.0 – 1.2.0.2 |
critical if aiohttp is installed beside it (its server decodes br request bodies by default), else warn; Scrapy, requests, httpx, urllib3 and pip are named as consumers |
§5.4 |
/tmp/pwn_marker.txt exists (N2S-20) |
warn — a lab-PoC marker; trivially renamed, may be your own test | §7 |
Shared-brotli resolution (the library file itself is also fingerprinted with N2S-23; a package / file disagreement is reported as a conflict, warn):
| Platform | Evidence | Fixed when |
|---|---|---|
| Debian / Ubuntu | zgrep CVE-2026-111497 /usr/share/doc/libbrotli1/changelog.Debian.gz, then a dpkg version compare (a small, correct comparator: epochs, ~, letters vs non-letters) |
the changelog names the CVE, or the version is ≥ bookworm 1.0.9-2+deb12u1 / trixie 1.1.0-2+deb13u1 (or an upstream-fixed 1.0.10 / 1.1.1 / 1.2.1+). forky / sid 1.2.0-4 was unfixed at fetch time; Ubuntu listed brotli Needs evaluation with no fixed package on 2026-10-23 |
| RPM (RHEL / Fedora …) | rpm -q --changelog brotli | grep CVE-2026-111497 |
the changelog names the CVE (Red Hat: Important, no fixed package listed at fetch time) |
| Alpine | brotli-libs |
≥ 1.2.1-r0 (1.1.1-r0 on 3.21) |
| Homebrew | Cellar path of the linked library, else brew list --versions brotli |
brotli ≥ 1.2.1 |
| anything else | — | warn — unknown / verify |
Derivation (DERIVED). BrotliDecoderErrorString() returns the stringified error name, so a
compiled decoder carries _ERROR_FORMAT_BLOCK_LENGTH_1 (present before the fix) and — only after the
fix — _ERROR_FORMAT_BLOCK_SWITCH. The BLOCK_SWITCH code (-17) and its name were introduced only by
the fix commits (git log -S: 0ed4268 / b418fa8 / da98063 / 50a9b70); Node v26.12.0 has 0
occurrences of the string, v26.12.1 has 1.
| The file has … | Verdict |
|---|---|
_ERROR_FORMAT_BLOCK_SWITCH |
ok — fixed decoder |
_ERROR_FORMAT_BLOCK_LENGTH_1 but not …_BLOCK_SWITCH |
critical — decoder without the CVE-2026-111497 fix |
| neither | info — no Brotli error table, inconclusive (dynamic link, other language, stripped) |
Brotli 1.0.x builds name their errors without the prefix (DERIVED — not in the brief; verified against
the google/brotli tags). In v1.0.9 and v1.0.10 BrotliDecoderErrorString uses return #NAME;; from
v1.1.0 it is return #PREFIX #NAME;. A decoder built from 1.0.x — Node 12–16's bundled 1.0.9, Debian
bookworm's system libbrotlidec.so.1 (1.0.9, fixed as 1.0.9-2+deb12u1 through the 1.0.x-branch fix) —
therefore carries the bare strings BLOCK_LENGTH_1 / BLOCK_SWITCH, each as its own NUL-terminated
string (the NUL delimiters matter: BLOCK_LENGTH_1 is also the tail of the 1.1+ name). The tool
recognises such a table by bare BLOCK_LENGTH_1 plus a corroborating bare name (EXUBERANT_META_NIBBLE
or CONTEXT_MAP_REPEAT) and no _ERROR_FORMAT_ prefix: bare BLOCK_SWITCH in it → fixed, absent →
vulnerable. A bare BLOCK_* string without the corroboration is ambiguous → unknown, never
"patched". Node's error codes on these builds read ERR_BLOCK_SWITCH / ERR_BLOCK_LENGTH_1 (the log scan
matches both forms). During development the verdicts were checked against real builds of the upstream tags
(compile-only, nothing executed): v1.0.9 → vulnerable, v1.0.10 → fixed, v1.1.0 → vulnerable, v1.1.1 → fixed.
Details:
- Targets. Discovered node / bun binaries;
libbrotlidec*.so*/*.dylibin the standard library directories (Linux multiarch,/usr/local/lib, Homebrew prefixes); the library a shared-brotli Node links; Python_brotli*.so/_brotlicffi*.so/.pydin the scanned site-packages;*.nodeaddons in--projectnode_modules; every ELF / Mach-O (incl. fat) / PE file under--scan-dir(files without a Brotli table are counted, not listed). - Streaming. Files are read in 4 MiB chunks with marker-length overlap (a marker straddling a
chunk boundary is found); a per-file size cap (
--max-binary-mb, default 512) skips larger files with an info finding. Mach-O universal binaries are scanned per slice and the worst slice decides. - Source check (N2S-24). Any
decode.ccontainingDecodeBlockTypeAndLength:max_block_type <= 1followed byreturn BROTLI_FALSE;with noBROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH→ vulnerable vendored source (warn: a source file only says what would be compiled); with the constant → fixed; anything else → unrecognised (info). The same shapes hold in v1.0.9 / v1.0.10. - Electron. Discovery is by
--scan-dironly. The marker cannot say which of two linked copies carries it, and the brief lists Electron as UNCONFIRMED (every stable line checked still carried pre-fix Node 24.x; Chromium's decoder pin likely contains the fix) — fixed is reported as info.
All Node-side npm decoders end in Node's own zlib.createBrotliDecompress: no npm upgrade fixes
anything, and no downgrade is needed. Ranges below are the brief's; a package outside its range is
not flagged.
Server side — decodes br request bodies |
Affected | Switch |
|---|---|---|
body-parser / express |
body-parser 2.x (2.0.0–2.4.0 read) · express ≥ 5.0.0 | inflate: false (rejects every compressed body) |
inflation / co-body |
inflation ≥ 2.1.0 · co-body ≥ 5.0.0 | — / don't parse br requests |
@koa/bodyparser, koa-bodyparser, koa-body |
≥ 5.0.0 · 2.4.0, 2.5.x, 4.1.0, 4.2.x–4.4.x · ≥ 2.4.0 | enableTypes / json, urlencoded, text: false |
@fastify/compress / fastify-compress |
≥ 5.0.0 · ≥ 3.1.0 | globalDecompression: false, per-route decompress: false, or requestEncodings without br |
@nestjs/platform-express |
≥ 11.0.0 (Nest 8–10 = Express 4: no) | bodyParser: false + your own parser |
@apollo/server · @strapi/strapi · parse-server |
5.x · 5.x · 9.x | none / none / none |
| Verified not decoders: Express 4 / body-parser 1.x (415 unsupported encoding) | reported once as ok | — |
Client side — decodes br responses |
Affected | Switch |
|---|---|---|
axios |
≥ 1.0.0 | decompress: false |
got |
11–16 | decompress: false |
node-fetch |
3.x (2.x: UNCONFIRMED → unknown / verify, info) | compress: false |
needle, superagent, ky, decompress-response |
as read in the brief | — |
Usage not visible → always info: http-proxy-middleware (responseInterceptor), next images (remotePatterns / domains), @whatwg-node/server / graphql-yoga (useContentEncoding()), n8n (partly), @remix-run/web-fetch |
Severity: a server-side decoder is warn, escalated to critical when posture found a
vulnerable runtime in the same run (the finding's related lists it); a client-side decoder is
info, or warn with a vulnerable runtime; the usage-not-visible rows stay info. Run
--check posture --check deps together on the host that serves the project. Other ecosystems:
PyPI Brotli / brotlicffi (critical with aiohttp, else warn; version floors that still admit
1.2.0 are info), Go github.com/andybalholm/brotli < v1.2.7 (a pseudo-version → verify), Cargo
brotli-decompressor (info, unresolved), RubyGems brotli < 0.9.1, Maven brotli4j < 1.24.0, and
FROM node:<tag> / oven/bun:<tag>: pinned vulnerable tags and EOL lines are critical, floating
tags are info — rebuild. package.json ranges are not resolved (only locked / installed
versions), and how your code uses a library is not analysed.
Per line auto-detect: combined and common (Apache / nginx), LTSV, JSON lines (nginx-style
flat objects, Caddy-style nested request.headers, and Docker json-file wrappers {"log": "…"}, which
are unwrapped). Keys used: req|request, method, uri|path|url, host (LTSV) / remote_addr|remote_ip,
status, ua|user_agent|http_user_agent, body|request_body, resp_body|response_body,
content_length, request_length, body_bytes_received.
The Content-Encoding: br rules read request headers, which standard combined / common logs do
not contain. Log them as LTSV or JSON. Request-header fields are recognised by a key ending in
content_encoding / transfer_encoding / content_type (http_content_encoding, req_content_encoding,
or Caddy's request.headers.Content-Encoding); a key containing resp, response, sent, upstream,
downstream, origin, backend or the token out is response-side and ignored — so a normal
brotli-compressed response (sent_http_content_encoding: br) and a normal Accept-Encoding: gzip, deflate, br request never alert.
log_format n2s "host:$remote_addr\ttime:$time_iso8601\treq:$request\tstatus:$status\tsize:$body_bytes_sent"
"\tua:$http_user_agent\tcontent_encoding:$http_content_encoding"
"\ttransfer_encoding:$http_transfer_encoding\tcontent_type:$http_content_type"
"\tcontent_length:$http_content_length\trequest_length:$request_length";
access_log /var/log/nginx/n2s.log n2s;LogFormat "host:%h\ttime:%{%Y-%m-%dT%H:%M:%S%z}t\treq:%r\tstatus:%>s\tsize:%B\tua:%{User-Agent}i\tcontent_encoding:%{Content-Encoding}i\ttransfer_encoding:%{Transfer-Encoding}i\tcontent_type:%{Content-Type}i\tcontent_length:%{Content-Length}i\trequest_length:%I" n2s| Needs | Rules |
|---|---|
| any log (UA, text, crash) | N2S-08/09/10 UAs · N2S-16/17 decoder error codes · N2S-18 crash lines · N2S-19 id output · seed bytes in URLs |
| request headers (LTSV / JSON) | N2S-11 br token (also inside lists) · N2S-12 br + chunked without Content-Length · N2S-13 duplicate Content-Encoding · N2S-14 br + application/octet-stream POST / · N2S-15 very large br body · N2S-22 lab routes together with br from the same client |
a body field ($request_body, escaped) |
N2S-01…06 seed bytes — \xNN, JSON \u00NN, %NN and plain-hex forms are decoded before matching; N2S-05 also as a truncated prefix |
| request-length fields | N2S-07 Ostorlab sweep (same client POSTs ≥ 3 of /, /api, /api/v1, /graphql with tiny bodies) · N2S-15 |
Caveats the tool applies for you: errno: -17 is EEXIST and errno: -9 is EBADF in libuv, so the
errno forms only count beside brotli / zlib / decompression context without EEXIST / EBADF (the
ERR__ERROR_FORMAT_* codes need no context); bare uid=0 gid=0 fields on auditd-style lines are not id
output; the real Apify JavaScript client brotli-compresses request bodies ≥ 1,024 B by default, so
N2S-11 can hit a legitimate sender — rule it out first.
Tune in node2shell_detector/fingerprints.py (THRESHOLDS) — tuning parameters, not fingerprints:
| Rule | Threshold | Documented basis |
|---|---|---|
| OSTORLAB_PROBE_SWEEP (N2S-07) | ≥ 3 of the 4 candidate paths; tiny = ≤ 64 B (content_length) or ≤ 1,024 B (request_length, which includes headers); warn with br or the documented 10 / 14 / 22 B bodies, else info |
Ostorlab's probe bodies and paths |
| BR_LARGE_BODY (N2S-15) | br request body ≥ 16 MiB (1 << 24) |
"1<<24 literals", ~23 MB |
| LAB_ROUTES_WITH_BR (N2S-22) | br from the same client; ≥ 3 × GET /warm adds "cadence" |
lab routes |
| NODE_PROCESS_CRASH (N2S-18) | info for 1–2 records, warn from 3 | Imperva: a single exit is only a clue |
| N2S-05 truncated prefix | header + ≥ 64 × 0x41 |
log pipelines truncate bodies |
--self-test runs, for each node / bun binary, a throwaway child process with a timeout:
zlib.brotliDecompressSync(Buffer.from('<the public 22-byte Node regression vector>', 'hex'))and records the error code / errno (ERR__ERROR_FORMAT_BLOCK_SWITCH / -17 → ok, fixed decoder; any
other decoder error such as BLOCK_LENGTH_1 / -9, or a crash / signal → critical, vulnerable decoder
behaviour; no error, a timeout or a non-decoder error → warn, unknown / verify).
Why it is opt-in. On an unpatched runtime this executes the public regression vector, which
exercises the bug inside the throwaway child (a crash of the child is the expected result there). The
vector is the one in Node's own test suite — there is no payload, no shellcode and no network, and it never
touches a long-running process of yours — but it does drive an out-of-bounds write on a vulnerable decoder,
so it never runs unless you ask, and only on runtimes you are allowed to test. The script lives in a
temporary directory that is removed afterwards; the child gets NODE_OPTIONS / BUN_OPTIONS stripped, its
own session (a timeout kills the whole process group) and a relative --node-bin is made absolute first.
Deno is not tested (status unresolved). The package's own tests exercise this only against stub scripts.
Derived by IONSEC from Ostorlab's negative control: POST the uncompressed JSON {"ping":1} with
Content-Encoding: br to your own endpoint. It is a deliberately invalid Brotli stream — it is not the
trigger — and it asks one question: does this endpoint try to decode Brotli request bodies?
curl -sS -i -X POST 'https://your-service.example/api' \
-H 'Content-Type: application/json' -H 'Accept: application/json' \
-H 'Content-Encoding: br' \
--data-binary '{"ping":1}'| Response | Meaning |
|---|---|
| 400 | the endpoint decodes Brotli (decompression of the non-Brotli body failed) — the decoder is reachable from outside; check the runtime / library behind it |
| 2xx | the endpoint ignores Content-Encoding — no decoder reached on this path |
| 415 | the endpoint rejects the encoding (e.g. Express 4 / body-parser 1.x, or an edge rule) |
Try the candidate paths Ostorlab uses: /, /api, /api/v1, /graphql. A 400 is reachability, not
exploitability: whether the decoder behind it is fixed is what the posture / fingerprint / self-test checks
answer. Only run this against systems you own or are authorized to test.
Provenance: V vendor / upstream-published · P copied from public PoC / scanner source · D derived by
IONSEC (derivation stated). V / P → VERBATIM, D → DERIVED.
| id | type | value (as documented) | provenance | covered by |
|---|---|---|---|---|
N2S-01 |
payload-bytes | 200010414141fcffff07002228f00b60f7f41f000000 | V+P VERBATIM |
logs: SEED_BYTES_N2S_01..06 (escaped bodies); --self-test feeds it to your own runtime |
N2S-02 |
payload-bytes | 200010414141fcffff07002228f00b60f7f41f00000000 | P VERBATIM |
logs: SEED_BYTES |
N2S-03 |
payload-bytes | 200018414141fcffff07002228f00b60f7f41f000000 | P VERBATIM |
logs: SEED_BYTES |
N2S-04 |
payload-bytes | 200010414141fcffff0f002228f00b60f7f41f000000 | P VERBATIM |
logs: SEED_BYTES |
N2S-05 |
payload-bytes | 1b00000000000000 + 1024 × 41 (1,032 B) | P VERBATIM |
logs: SEED_BYTES (full 1,032 B or truncated prefix) |
N2S-06 |
payload-bytes | 1b0100002479b16f50c0030500c00000000000000003 | P VERBATIM |
logs: SEED_BYTES |
N2S-07 |
http-request | POST Content-Encoding: br + JSON bodies {"ping":1} / 8b04807b2270696e67223a317d03 / N2S-01 to /, /api, /api/v1, /graphql |
P VERBATIM |
logs: OSTORLAB_PROBE_SWEEP (needs request-length fields) |
N2S-08 |
user-agent | starts with Node2ShellChecker/ |
P VERBATIM |
logs: UA_NODE2SHELL_CHECKER |
N2S-09 |
user-agent | Mozilla/5.0 (compatible; security-scanner) |
P VERBATIM |
logs: UA_SECURITY_SCANNER_GENERIC |
N2S-10 |
user-agent | cve-2026-111497-verify (source repo UNCONFIRMED) |
P VERBATIM |
logs: UA_CVE_VERIFY |
N2S-11 |
http-request | Content-Encoding containing token br (case-insensitive, also inside lists) on a request with a body |
D DERIVED |
logs: REQUEST_CONTENT_ENCODING_BR (needs request headers in the log) |
N2S-12 |
http-request | Content-Encoding: br + Transfer-Encoding: chunked, no Content-Length |
P VERBATIM |
logs: BR_CHUNKED_NO_LENGTH |
N2S-13 |
http-request | duplicate Content-Encoding request headers |
V VERBATIM |
logs: DUPLICATE_CONTENT_ENCODING |
N2S-14 |
http-request | Content-Encoding: br + Content-Type: application/octet-stream POST / |
P VERBATIM |
logs: BR_OCTET_STREAM_ROOT_POST |
N2S-15 |
http-request | very large br request body (~23 MB, 1<<24 literals) |
P VERBATIM |
logs: BR_LARGE_BODY |
N2S-16 |
log-line | ERR__ERROR_FORMAT_BLOCK_SWITCH / errno -17 — only a patched decoder emits it |
V+D VERBATIM+DERIVED |
logs: BROTLI_BLOCK_SWITCH_ERROR (an attempt reached a patched decoder) |
N2S-17 |
log-line | ERR__ERROR_FORMAT_BLOCK_LENGTH_1 / errno -9 (weak) |
P+D VERBATIM+DERIVED |
logs: BROTLI_BLOCK_LENGTH_1_ERROR (info) |
N2S-18 |
process/log | Node / Bun crash right after a br request or outbound fetch |
V VERBATIM |
logs: NODE_PROCESS_CRASH (kernel / systemd-coredump / exit 139) |
N2S-19 |
http-response | uid=<n>(…) gid=<n>(…) in a response body |
P VERBATIM |
logs: RESPONSE_ID_MARKER (critical) |
N2S-20 |
file | /tmp/pwn_marker.txt |
P VERBATIM |
posture: HOST_MARKER_FILE |
N2S-21 |
process | node / bun server spawning sh / bash -c with id, whoami, uname, curl / wget |
D DERIVED |
not covered by the host tool (process telemetry) — see the Sigma process rules in rules/ |
N2S-22 |
http-request | lab routes POST /upload, GET /health, POST /stage /tail /close, repeated GET /warm together with br |
P VERBATIM |
logs: LAB_ROUTES_WITH_BR |
N2S-23 |
binary | contains _ERROR_FORMAT_BLOCK_LENGTH_1 but not _ERROR_FORMAT_BLOCK_SWITCH → unpatched; contains _ERROR_FORMAT_BLOCK_SWITCH → fixed |
D DERIVED |
binaries: BIN_DECODER_FIXED / BIN_DECODER_VULNERABLE / BIN_DECODER_INCONCLUSIVE |
N2S-24 |
source | vendored decode.c: max_block_type <= 1 → return BROTLI_FALSE; without BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH → vulnerable |
D DERIVED |
binaries: SRC_DECODER_VULNERABLE / SRC_DECODER_FIXED |
N2S-25 |
runtime | feed N2S-01 to the runtime's own decoder: -17 = fixed; -9 or a crash = vulnerable |
V+P VERBATIM |
--self-test: SELFTEST_FIXED / SELFTEST_VULNERABLE / SELFTEST_CRASH |
N2S-23 / N2S-16 1.0.x form |
binary / log-line | bare NUL-delimited BLOCK_LENGTH_1 + EXUBERANT_META_NIBBLE or CONTEXT_MAP_REPEAT (no _ERROR_FORMAT_ prefix); ERR_BLOCK_SWITCH / ERR_BLOCK_LENGTH_1 |
D DERIVED — not in the brief; verified against the google/brotli tags (v1.0.9 / v1.0.10 use return #NAME;, v1.1.0+ return #PREFIX #NAME;) |
binaries: the same findings, bare table; logs: the same rules |
python3 -m pytest -q # from this directory (pytest is the only third-party tool; the package itself is stdlib-only)
- Synthetic benign + attack corpora live in
node2shell_detector/corpora.py(attack lines built only from catalogue values, each with its fingerprint id; client IPs are RFC 5737 documentation addresses). - Back-audit. Every catalogue UA, seed byte string and header shape must be matched by the detector (as
escaped, percent-encoded and plain-hex bodies); benign traffic — curl, browsers, python-requests, undici,
normal
Accept-Encoding: bron requests andContent-Encoding: bron responses, EEXIST / EBADFerrnolines, auditduid=0 gid=0fields — must produce nothing. Further tests keep the constants identical to the brief, assert the package imports no network module and that the trigger bytes live only infingerprints.py, and check this README against the code. - The dpkg version comparator, the version tables, each lockfile parser (small fixtures), the binary scanner
(synthetic ELF / Mach-O / fat / PE files with and without the marker strings, chunk-boundary and size-cap
cases) and the CLI have their own tests. The self-test is only ever run against stub scripts
(
examples/fixture_node*.sh-style); the suite never executes a real runtime's decoder. - Static example corpora and fixtures for manual smoke runs live in
examples/(regenerate:python3 examples/make_examples.py):
python3 -m node2shell_detector --no-discover --check posture --node-bin examples/fixture_node.sh
python3 -m node2shell_detector --no-discover --check binaries --scan-dir examples/fixture_bins --scan-dir examples/fixture_vendor
python3 -m node2shell_detector --no-discover --check posture --check deps --node-bin examples/fixture_node.sh --project examples/fixture_project
python3 -m node2shell_detector --check logs --log examples/logs/access_exploit.log --log examples/logs/app_exploit.log
python3 -m node2shell_detector --no-discover --self-test --check posture --node-bin examples/fixture_node.sh # stub only- Containers. No container / volume traversal and no image inspection — run the tool inside, or point
--node-bin/--scan-dirat an extracted root file system (--no-discoverkeeps it from also probing the host). Dockerfile and compose tags are read, but a built image's contents are not. - Managed hosts. AWS Lambda / Elastic Beanstalk / App Runner, Vercel, Netlify, Google Cloud Functions /
Run, Azure Functions and Amazon Linux have no public patch level in the brief — verify
process.versionfrom inside the function. Vercel's WAF rules cover the inbound vector only, not responses the app fetches. - Windows. The posture probes, library discovery and the self-test's process-group handling are POSIX /
macOS oriented; PE files are recognised for
--scan-dir, nothing else is Windows-aware. - Electron. Apps are only found through
--scan-dir(/Applications/Foo.app, an install directory); the marker cannot say which Brotli copy carries it (see above), and the brief lists the fix status as UNCONFIRMED — use the Node version inside the Electron release or a self-test of the app's runtime. - Deno and the Rust / Go ports. Deno and
brotli-decompressorare info — unresolved;andybalholm/brotliis judged by its version only. - Not covered: .NET (
System.IO.Compression.Nativevendors brotli 1.1.0; no fix found in the brief), PECLbrotli, curl / libcurl built against an unpatched libbrotli (covered through the library check), nginxngx_brotli/ Apachemod_brotli(encoder only — not affected), Envoy / Istio decompressor filters, workerd. Those are in the brief's §5 tables, not in this tool. - Dependency scan.
package.jsonranges are not resolved;bun.lockb(binary) is skipped with a note; usage of a library inside your code is not analysed. Walks are bounded (depth, file and package counts). - The tool is attempt-oriented on logs: it does not (and cannot) verify that a runtime was actually exploited.
- No invented fingerprints. Every fingerprint string is copied exactly from a fetched vendor or upstream
publication or public scanner source named in the brief; the catalogue (
node2shell_detector/fingerprints.py) keeps each id with its source and confidence. Two details go beyond the brief and are labelled DERIVED wherever they appear: the Brotli 1.0.x bare-name error table /ERR_BLOCK_SWITCHform, and the (conservative) derived thresholds above. - No attribution claims. Vendor and project names appear only as sources, never as claims of endorsement or contribution.
- Defanged output. IPv4 addresses in evidence are
[.]-mangled so copied output is inert.
See SECURITY.md for vulnerability reports against this tool and CONTRIBUTING.md for factual patches (fingerprints that cite a fetched, dated source; version-table corrections). The code is licensed AGPL-3.0 — see LICENSE.
IONSEC's network / WAF / host rules for the same CVE live in rules/ — read
rules/README.md for the inventory, the coverage map and the false-positive
guidance. In short:
| Content | Counts / ranges |
|---|---|
rules/sigma/ |
14 Sigma rules (web, proxy, application log, Linux / Windows process, file event, crash) |
rules/suricata/ionsec-node2shell.rules |
14 active rules in the private SID block 1006600–1006699 (1006600–1006606, 1006610–1006614, 1006620, 1006630) plus one optional commented rule |
rules/modsecurity/ionsec-node2shell-dropin.conf |
7 active rules in the ID range 1026111400–1026111499; detection-only by default |
rules/edge/ |
8 inbound reject files (nginx, Apache, HAProxy, Envoy, Cloudflare, AWS WAF, Azure Front Door and Application Gateway) and 3 egress files |
rules/app/ |
5 app-level guards (Express, Koa, Fastify, Nest 11+, aiohttp) |
rules/yara/ionsec_node2shell.yar |
7 rules + 1 private format rule |
rules/references/node2shell-sources.md |
provenance: every rule ↔ N2S id ↔ sources ↔ VERBATIM / DERIVED |
The two directions matter: inbound (an attacker sends a Content-Encoding: br body to your server) is
what WAF, edge and Suricata rules see; outbound (your service fetches from an attacker-controlled server —
webhooks, link previews, image proxies, crawlers, npm) is not visible to a WAF in front of the app, which is why
this host tool, the egress snippets and the self-test exist. The rules README states what was and was not
validated (the rule engines were not installed in the build environment), and notes that no equivalent rule
was found in Emerging Threats, Snort / Talos, SigmaHQ, Elastic detection-rules, Nuclei, OWASP CRS,
signature-base, Yara-Rules or Corelight when it was written.