16 min to read
Node2Shell Hunt Guide: Find, Fix and Fence Off CVE-2026-111497
A practical playbook for the Node.js Brotli RCE, from inventory to detection
On the same day PHP shipped emergency fixes for PHP2Shell, Node.js quietly released a security update that deserves just as much attention. CVE-2026-111497, now nicknamed Node2Shell, is an out-of-bounds write in the Brotli decoder that ships inside Node.js. Feed a vulnerable process the right malformed compressed data and, in the words of the official advisory, it “can corrupt process memory, which can lead to a crash, disclosure of process memory, or remote code execution.”
Within a day, Imperva researchers showed code execution against an unmodified official Node.js 24.22.0 image. The hard part for most teams isn’t the patch itself: it’s finding every place a Node binary is hiding (base images, CI runners, bundled Bun executables) and understanding which services actually let attacker-controlled Brotli reach the decoder. This guide is a hands-on playbook for exactly that.
TL;DR / Key takeaways
- CVE-2026-111497 is an out-of-bounds write in the Brotli decoder bundled with Node.js (and Bun). Google, the CNA, scores it 9.2 Critical (CVSS 4.0); Node.js rates it High.
- Fixed in Node.js 22.23.4, 24.22.1 and 26.12.1, and Bun 1.4.4. End-of-life lines (20.x, 23.x, 25.x and older) get no upstream fix.
- Exposure needs two things: a vulnerable runtime and a path for untrusted Brotli data. Common paths are Express 5 / body-parser 2.x request bodies, Koa body parsers,
@fastify/compress, built-infetch()responses and directnode:zlibcalls.- Public proof-of-concept work exists; no in-the-wild exploitation had been reported as of 23 October, and the CVE was not in CISA KEV at that point.
- Upgrade, rebuild every image and restart every process. Edge rules blocking
Content-Encoding: brhelp in the meantime but do not coverfetch()responses.
What happened (quick timeline)
| Date | Event |
|---|---|
| 27 Feb 2026 | Fix committed to the upstream google/brotli repository, with no release or advisory at the time |
| 21 Oct 2026 | Node.js ships 22.23.4, 24.22.1 and 26.12.1 (ten CVEs, four rated High); Brotli 1.0.10, 1.1.1 and 1.2.1 released; Vercel adds platform firewall rules |
| 22 Oct 2026 | CVE published; first public PoC (on a deliberately weakened build); Imperva demonstrates RCE on stock Node.js 24.22.0 (arm64); Bun 1.4.4 released |
| 23 Oct 2026 | No in-the-wild exploitation reported; CVE not yet in CISA KEV |
The bug was reported by Khai Tran and Sylvie Mayer of OpenAI Codex Security and fixed by Matteo Collina and Antoine du Hamel, according to the Node.js advisory.
How the flaw works (conceptually)
Brotli is the compression format behind Content-Encoding: br. A Brotli stream describes its own structure, including how many “block types” it uses. Node2Shell abuses a stream that declares a single block type and then tries to switch blocks, something its own structure makes impossible.
Instead of rejecting this as a permanent format error, the vulnerable decoder reported it as a temporary “need more input” condition. The streaming decoder then kept appending input bytes to an eight-byte internal buffer, writing past its end (Node.js, Imperva). The upstream fix simply makes the decoder return a format error at that transition.
Two important realities about exploitability:
- It is not trivially reliable. Imperva notes that “execution took multiple attempts in our setup”, and it tested only Linux arm64. The first public PoC got code execution only on a weakened test build; on a standard build its author reported a crash and a limited memory read (The Hacker News).
- Crashes alone are an attack. Even without RCE, a request that kills a Node.js worker is a cheap denial-of-service, and a memory read can leak secrets such as tokens and keys.
Am I affected?
You are exposed if both of these are true (Security Boulevard / Cycode):
1. You run a vulnerable runtime — on a server, in a container base image, on a build host or a developer laptop:
| Component | Vulnerable | Fixed in |
|---|---|---|
| Node.js 26.x | below 26.12.1 | 26.12.1 |
| Node.js 24.x (LTS) | below 24.22.1 | 24.22.1 |
| Node.js 22.x (LTS) | below 22.23.4 | 22.23.4 |
| Node.js 25.x, 23.x, 20.x and older | all | No fix: move to a supported line |
| Bun | below 1.4.4 | 1.4.4 |
| Google Brotli (C library) | 1.0.3–1.0.9, 1.1.0, 1.2.0 | 1.0.10, 1.1.1, 1.2.1 |
2. Attacker-controlled Brotli data can reach the decoder. Known paths:
- Express 5 and body-parser 2.x (
json(),urlencoded(),text(),raw()) inflate Brotli request bodies on routes where they are applied. - Koa body parsers: koa-bodyparser, @koa/bodyparser, koa-body, co-body.
- @fastify/compress with request decompression enabled.
- Built-in
fetch()automatically decodes Brotli responses, so a malicious or compromised server you call can reach the decoder. Webhooks, link previews, “import from URL” features and integrations are the classic examples. - Direct
node:zlibuse:brotliDecompress,brotliDecompressSync,createBrotliDecompress, or libraries that call them.
Not exposed by request bodies alone: Express 4, body-parser 1.x and a bare http server, which “does not decode incoming Brotli bodies simply by receiving them” (Imperva). Note the npm package called brotli is not the vulnerable component; the runtime is.
Step 1: Inventory every Node.js and Bun runtime
Package inventories routinely miss runtimes bundled in images, so check the binary itself. Start with the obvious (Aikido):
node ~|-|~|-|version
bun ~|-|~|-|version
docker run ~|-|~|-|rm ~|<|image> node ~|-|~|-|version
Then sweep hosts and running containers. This read-only script finds Node binaries on a Linux host and prints their versions:
~|#| Every executable named node/bun on local filesystems
find / ~|-|xdev \( ~|-|name node ~|-|o ~|-|name bun \) ~|-|type f ~|-|perm ~|-|u+x 2>/dev/null |
while read ~|-|r b; do printf '%s\t%s\n' "$b" "$("$b" ~|-|~|-|version 2>/dev/null)"; done
~|#| Node version inside every running container
docker ps ~|-|~|-|format ' ' | while read ~|-|r id img; do
printf '%s\t%s\n' "$img" "$(docker exec "$id" node ~|-|~|-|version 2>/dev/null || echo 'no node')"
done
For Kubernetes, query the running pods rather than your manifests, because a pinned tag or digest can hide an old runtime:
kubectl get pods ~|-|A ~|-|o jsonpath~|=|'{range .items[*]}{.metadata.namespace}{"/"}{.metadata.name}{"\n"}{end}' |
while IFS~|=|/ read ~|-|r ns pod; do
v~|=|$(kubectl exec ~|-|n "$ns" "$pod" ~|-|~|-| node ~|-|~|-|version 2>/dev/null) && printf '%s/%s\t%s\n' "$ns" "$pod" "$v"
done
Drop this tiny check into CI so a build fails on a vulnerable runtime:
// check~|-|node2shell.js ~|-| exits 1 on a runtime vulnerable to CVE~|-|2026~|-|111497
const [maj, min, pat] ~|=| process.versions.node.split('.').map(Number);
const fixed ~|=| { 22: [23, 4], 24: [22, 1], 26: [12, 1] };
const f ~|=| fixed[maj];
const ok ~|=| f && (min > f[0] || (min ~|=|~|=|~|=| f[0] && pat >~|=| f[1]));
if (!ok) {
console.error(`Node ${process.version} is vulnerable or end~|-|of~|-|life (CVE~|-|2026~|-|111497)`);
process.exit(1);
}
console.log(`Node ${process.version} includes the CVE~|-|2026~|-|111497 fix`);
Don’t forget Dockerfiles that install Node via nvm install or NodeSource scripts, standalone binaries built with bun build --compile (they embed the runtime and must be rebuilt), and Linux distribution packages, which may link a system Brotli library and receive the fix through the distro’s own advisory.
Step 2: Map where untrusted Brotli enters
Patch order should follow exposure. In each repository:
~|#| Body parsers and middleware that inflate Brotli request bodies
npm ls express body~|-|parser koa~|-|bodyparser @koa/bodyparser koa~|-|body co~|-|body @fastify/compress 2>/dev/null
~|#| Direct decoder use in your code and dependencies
grep ~|-|rnE "brotliDecompress(Sync)?|createBrotliDecompress" \
~|-|~|-|include~|=|'*.js' ~|-|~|-|include~|=|'*.mjs' ~|-|~|-|include~|=|'*.cjs' ~|-|~|-|include~|=|'*.ts' .
~|#| Outbound fetch() calls ~|-| review any that take a user~|-|supplied or third~|-|party URL
grep ~|-|rnE "\bfetch\(" ~|-|~|-|include~|=|'*.js' ~|-|~|-|include~|=|'*.ts' src/ | grep ~|-|v node_modules
Prioritise in this order: internet-facing Express 5 / Koa / Fastify services on vulnerable runtimes, then services that fetch() URLs supplied by users or partners, then internal services and build infrastructure.
Step 3: Fence it off while you patch
Node.js lists no workaround; upgrading is the fix. While rollouts happen, you can reject Brotli-encoded request bodies at the edge. Match case-insensitively and inside lists such as gzip, br (Security Boulevard / Cycode).
Nginx (in the http block and server block respectively):
map $http_content_encoding $reject_br {
default 0;
"~*(^|[\s,])br([\s,;]|$)" 1;
}
server {
~|#| ...
if ($reject_br) { return 415; }
}
HAProxy (req.hdr() evaluates each comma-separated value):
http~|-|request deny deny_status 415 if { req.hdr(content~|-|encoding) ~|-|i ~|-|m str br }
Express middleware (register before any body parser):
app.use((req, res, next) ~|=|> {
const enc ~|=| String(req.headers['content~|-|encoding'] || '')
.toLowerCase().split(',').map(s ~|=|> s.trim());
if (enc.includes('br')) return res.status(415).send('Unsupported Media Type');
next();
});
Verify the rule safely against staging with a harmless request; it carries no payload at all:
curl ~|-|s ~|-|o /dev/null ~|-|w '%{http_code}\n' ~|-|X POST https://staging.example.com/api/health \
~|-|H 'Content~|-|Type: application/json' ~|-|H 'Content~|-|Encoding: br' ~|-|~|-|data~|-|binary ''
~|#| Expect 415 once the rule is live
Be clear about the limits: these rules don’t protect against Brotli responses read by fetch(). For that path, restrict outbound egress for services that fetch third-party URLs to known destinations where you can, and patch those services first. Don’t rely on sending a narrower Accept-Encoding header either: a hostile server is free to ignore it. Imperva and Vercel have added WAF/firewall rules for inbound requests, which are useful but, as Vercel’s own scope makes clear, are not a substitute for updating.
Step 4: Hunt for signs of abuse
There are no published IOCs from Node.js, Google, Imperva or national CERTs so far (Rescana), so hunt on behaviour. Imperva advises treating unexpected process exits during decompression and decoder errors as prioritisation signals, noting “Neither is a reliable stand-alone sign of exploitation.” Correlate them.
1. Start logging the header. Most access logs don’t record Content-Encoding by default:
log_format node2shell '$remote_addr [$time_local] "$request" $status '
'ce~|=|"$http_content_encoding" len~|=|$request_length ua~|=|"$http_user_agent"';
access_log /var/log/nginx/access_ce.log node2shell;
2. Find Brotli-encoded requests (Splunk):
index~|=|web sourcetype~|=|nginx:access ce~|=|"*br*"
| stats count min(_time) as first max(_time) as last values(uri_path) as paths by src_ip
| sort ~|-| count
3. Find worker crashes. A segfault shows up as exit code 139 (128 + SIGSEGV) in containers and as a kernel segfault line on hosts:
journalctl ~|-|k ~|-|~|-|since "2026~|-|10~|-|20" | grep ~|-|iE "segfault" | grep ~|-|E "node|bun"
kubectl get pods ~|-|A ~|-|o json | jq ~|-|r '.items[] | .metadata.namespace + "/" + .metadata.name + " " +
([.status.containerStatuses[]?.lastState.terminated? | select(.exitCode~|=|~|=|139) | .finishedAt] | join(","))' |
awk 'NF>1'
On patched runtimes, rejected streams surface the new error code BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH; searching application logs for it can show who is sending malformed Brotli after you upgrade.
4. Watch for post-exploitation behaviour. If RCE succeeds, the next step is usually a shell, a downloader or a reverse connection launched by the Node process. Microsoft Defender XDR (KQL):
DeviceProcessEvents
| where Timestamp > datetime(2026~|-|10~|-|21)
| where InitiatingProcessFileName in~ ("node", "node.exe", "bun", "bun.exe")
| where FileName in~ ("sh", "bash", "dash", "cmd.exe", "powershell.exe", "pwsh.exe", "curl", "wget", "nc", "ncat")
| project Timestamp, DeviceName, InitiatingProcessCommandLine, FileName, ProcessCommandLine
| order by Timestamp desc
And a Sigma rule for Linux EDR/auditd pipelines:
title: Shell or Downloader Spawned by Node.js or Bun Process
status: experimental
description: Possible post~|-|exploitation after Node2Shell (CVE~|-|2026~|-|111497) or other RCE in a JS runtime
logsource:
product: linux
category: process_creation
detection:
parent:
ParentImage|endswith: ['/node', '/bun']
child:
Image|endswith: ['/sh', '/bash', '/dash', '/curl', '/wget', '/nc', '/ncat']
condition: parent and child
falsepositives:
~|-| npm/yarn lifecycle scripts, build tools and apps that legitimately use child_process
level: medium
Baseline first: build servers and some apps legitimately spawn shells. In production web workers, though, a Node process launching curl or bash is rare and worth a look, especially if it follows a Brotli request or a crash.
Prioritised action checklist
Immediate (today)
- Upgrade internet-facing services to Node.js 22.23.4 / 24.22.1 / 26.12.1 (or newer) and Bun 1.4.4+.
- Rebuild and redeploy images; images pinned to older tags stay vulnerable. Restart processes, because running workers keep the old decoder in memory.
- Block
Content-Encoding: brrequest bodies at the proxy/WAF for anything you can’t patch today.
This week
- Complete the runtime inventory: hosts, containers, Kubernetes, CI runners, developer machines, serverless/managed platform runtime versions.
- Patch services that
fetch()user-supplied or partner URLs; tighten their egress. - Apply distribution Brotli updates on hosts and base images; check Python and other software that bundles Brotli.
- Add the CI version gate and start logging
Content-Encoding. - Remember the same release fixed nine other CVEs, including three High-rated TLS/DNS validation flaws (TuxCare), which is one more reason not to sit on it.
Longer term
- Retire end-of-life lines (20.x, 23.x, 25.x and older) or put them under a supported extended-support arrangement.
- Make base-image rebuilds automatic, so a runtime patch becomes a pipeline run rather than a project.
- Treat decompression as an attack surface: only accept compressed request bodies where you need them.
OffensioX perspective
Node2Shell and PHP2Shell landing on the same day is no coincidence of calendars; it’s a sign of where vulnerability discovery is heading. Both were surfaced with help from AI-assisted research, and the Node.js fix sat publicly in the upstream Brotli repository for almost eight months before anyone attached a CVE to it. Patch-diffing that kind of “silent” fix is exactly what attackers, increasingly with AI tooling of their own, are getting faster at. The deeper lesson is that runtime and dependency hygiene is now a frontline control: the riskiest Node binaries in most organisations aren’t on the servers someone manages by hand, but baked into container images, CI runners and vendor appliances nobody has rebuilt in a year. For startups and e-commerce teams in Pakistan and across South Asia, many of whom run API backends, payment callbacks and admin panels on Node.js with lean DevOps staffing, an automated inventory-and-rebuild loop is worth more than any single WAF rule. If you’d like help validating your exposure, OffensioX offers penetration testing and application and cloud security assessments that can test your Node.js services and the infrastructure around them.
References
- Node.js — October 2026 Security Releases (official advisory)
- Imperva — Node2Shell: From Brotli Data to Remote Code Execution
- The Hacker News — Researchers Demonstrate Node2Shell RCE Exploit on Node.js Build Using Crafted Brotli Data
- Security Boulevard (Cycode) — node2shell (CVE-2026-111497): What to Patch and How to Find Exposed Services
- Cycode — node2shell (CVE-2026-111497): What to Patch in Node.js
- Aikido — Node2Shell: Node.js Brotli vulnerability allows RCE and the fix
- TuxCare — 10 Node.js CVEs, a Brotli RCE, and What to Do If Your Version Is EOL
- Rescana — Node2Shell: Node.js Brotli Flaw CVE-2026-111497
- Bun v1.4.4 release notes
- CVE-2026-111497 record
- Google Brotli releases