Node2Shell Hunt Guide: Find, Fix and Fence Off CVE-2026-111497

A practical playbook for the Node.js Brotli RCE, from inventory to detection

Featured image

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-in fetch() responses and direct node:zlib calls.
  • 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: br help in the meantime but do not cover fetch() 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:

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:

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)

This week

Longer term

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