Node2Shell: Exploiting CVE-2026-111497 Through Malformed Brotli Streams in Node.js
Node2Shell (CVE-2026-111497) is a Brotli decoder out-of-bounds write in Node.js with a public RCE exploit. Analysis, IOCs, detection queries and fixes.
Node2Shell: Exploiting CVE-2026-111497 Through Malformed Brotli Streams in Node.js
Summary: CVE-2026-111497, nicknamed Node2Shell (later also HTTP2Shell and Broccoli2Shell), is a memory-corruption flaw in Google's Brotli decompression library, which Node.js compiles directly into its binary. A crafted Brotli stream drives the decoder into a block-type switch that the stream's own header makes impossible. The vulnerable decoder misreads that format error as "I need more input", keeps appending attacker bytes into an 8-byte internal buffer, and writes past it into adjacent decoder state.
Independent researchers have turned this into code execution on unmodified official Node.js 24.22.0 builds on both arm64 and x86-64, reaching the decoder through the built-in fetch() (a malicious server's response), through Express 5 / body-parser 2 and Koa 3 request bodies sent with Content-Encoding: br, and even through a plain node:http server that calls node:zlib. A public RCE exploit released by researcher dimasma0305 is circulating, exposure scanning has started, the first in-the-wild exploitation attempt was recorded on October 26, 2026, and CISA added the CVE to its Known Exploited Vulnerabilities (KEV) catalog on October 27, 2026 with a federal remediation due date of November 10, 2026. No authentication, no user interaction.
Fix: Node.js 22.23.4, 24.22.1, 26.12.1. EoL lines (20.x, 23.x, 25.x) receive nothing. The same decoder ships in Python Brotli, brotlicffi, Bun, Envoy, .NET and curl builds linked against libbrotli, all of which need their own updates.
Overview
AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:Nc/dec/decode.c, function DecodeBlockTypeAndLength(), reached via BrotliDecoderDecompressStream()mcollina) and Antoine du Hamel (aduh95), per the official Node.js advisory.Why This Bug Matters: Decompression Is an Attack Surface
Brotli (Content-Encoding: br) is the default high-ratio compression on the modern web. Every CDN, browser and most HTTP clients speak it, which means it is also something servers and clients decode automatically, often without application code ever asking for it. Node.js exposes the bundled decoder through node:zlib (brotliDecompress, createBrotliDecompress), and higher-level code reaches it implicitly: fetch() (Undici) transparently decompresses responses, and Express 5 / body-parser 2 decompress br request bodies before the JSON or raw parser ever sees them.
That makes CVE-2026-111497 unusual among runtime bugs. The attacker does not need a vulnerable application route or a logic bug in your code. They need one of two things: a way to send a compressed body to a parser that inflates it, or a way to make your service fetch something from a server they control. Webhooks, URL previews, OAuth discovery, SSRF-ish "import from URL" features, link unfurlers, scraper services and server-side rendering all qualify.
Root Cause Analysis
Brotli in 90 seconds
A Brotli stream is a sequence of meta-blocks. Each meta-block header declares, for three categories (literals, insert-and-copy commands, distances), how many block types exist (NBLTYPES, 1 to 256) and, when more than one exists, a prefix code for block-type switch commands and block lengths. During decoding, when the current block's counter runs out, the decoder calls DecodeBlockTypeAndLength() to read the next block type and its length from the bitstream, then swaps in the matching Huffman tree group and context map.
If a category declares exactly one block type, a block switch is structurally impossible: there is nothing to switch to, and no switch codes were ever decoded. A conforming decoder must treat any attempt to perform one as a format error.
The flaw: a format error that looks like "need more input"
DecodeBlockTypeAndLength() returns a boolean-style result. In vulnerable versions, the path that encounters the impossible switch returned the same failure value as the path that simply ran out of buffered bits. The caller, the streaming state machine in BrotliDecoderDecompressStream(), interprets "false" as BROTLI_DECODER_NEEDS_MORE_INPUT: it saves its position, stashes the unconsumed tail of input into a small internal ring and returns to the host asking for more bytes. That is the CWE-252 half of the bug.
The CWE-787 half follows directly. The decoder keeps a tiny internal buffer (8 bytes, sized for the largest bit-reader refill it should ever need) to carry partial input across calls. In the "need more input" path it appends new caller data into that buffer, expecting a successful parse to drain it. Because the parse can never succeed on this stream, every subsequent chunk from the attacker is appended past the end of the 8-byte buffer, overwriting whatever decoder state follows it in the BrotliDecoderState structure: pointers, counters, ring-buffer sizes and table references the decoder will later dereference and write through.
Memory view of the corruption
Call path inside Node.js
# Simplified native call chain for an inbound Brotli body (vulnerable runtime) #0 DecodeBlockTypeAndLength() // decode.c: impossible switch, returns FALSE #1 DecodeLiteralBlockSwitch / Command / Distance ... #2 ProcessCommands() // result treated as NEEDS_MORE_INPUT #3 BrotliDecoderDecompressStream() // appends next chunk into 8-byte buffer → OOB write #4 BrotliDecoderContext::DoThreadPoolWork() #5 CompressionStream<...>::Write() // node_zlib.cc #6 zlib.createBrotliDecompress() ← body-parser 2.x / Undici fetch() / user code
The fix
Upstream commit 0ed4268 ("Detect and report invalid stream earlier") landed in the public Brotli source tree on February 27, 2026. It makes DecodeBlockTypeAndLength() return a dedicated error, BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH, (numeric value -17) and propagates that error out of the decoder instead of collapsing it into "need more input". Genuine input shortfalls still return NEEDS_MORE_INPUT. Node.js cherry-picked the change and also exposed the new decoder error code to JavaScript. Critically, the commit was not flagged as a security fix, and no Brotli release shipped it until October 21. Any project vendoring Brotli from a release tarball stayed vulnerable for roughly eight months while the fix sat in plain sight in the public repository, a textbook "silent fix" window for anyone diffing upstream.
// Conceptual diff (not the literal patch) - if (!SafeReadBlockType(s, br, &block_type)) return BROTLI_FALSE; // ambiguous + if (s->num_block_types[tree_type] < 2) + return BROTLI_FAILURE(BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH); + /* ...callers now distinguish a format error from NEEDS_MORE_INPUT + and move the decoder into a terminal error state */
Exploit Maturity Assessment
Exploit Status
- No known exploit
- Theoretical
- PoC Available (public)
- Functional Exploit (public RCE by
dimasma0305) - Weaponized (in the wild): first exploitation attempt recorded October 26, 2026; added to CISA KEV October 27, 2026
| Attribute | Assessment |
|---|---|
| Exploit Availability | Public: full RCE exploit released by researcher dimasma0305 |
| Source of Exploit | Public code repository (dimasma0305); independent private research chains |
| DoS Reliability | High: deterministic worker crash on any reachable decoder |
| RCE Reliability | Medium: reliable against the exact build it targets (Node.js 24.22.0), but each build needs its own memory offset |
Proof of Concept (PoC)
| Item | Detail |
|---|---|
| Publicly Available | Yes: a public remote code execution exploit is available |
| PoC Source | Public exploit repository by researcher dimasma0305 (created October 22, 2026). Review any third-party exploit code in an isolated lab before running it. |
| Purpose | Deliver a crafted Brotli body that triggers the block-switch overflow, pivots execution and spawns a shell inside the Node.js process. |
| Prerequisites | Vulnerable Node.js runtime (the public chain targets the official Node.js 24.22.0 x86-64 binary); a path that decodes attacker-controlled Brotli; knowledge of the accepted socket file descriptor, which is predictable on a quiet service. |
| Expected Outcome | Vulnerable 24.22.0: shell running as the service account, wired to the attacker's own connection. Patched 24.22.1: request rejected with HTTP 400 and error ERR__ERROR_FORMAT_BLOCK_SWITCH; process keeps running. |
Independently validated targets
| Target | Path into decoder | Node.js 24.22.0 | Node.js 24.22.1 |
|---|---|---|---|
Plain node:http server | Application code calling node:zlib Brotli functions | Shell (x86-64) | Rejected |
Official node:24.22.0-slim image as root | Restart policy enabled | Root shell (x86-64) | Rejected |
| Express 5.0.0–5.3.0 | body-parser 2.x json() / raw() | Code ran (arm64 and x86-64) | Rejected, server stayed up |
| Koa 3 | @koa/bodyparser 6 | Shell (x86-64) | Rejected |
| Outbound HTTP client | Built-in fetch() to attacker server | Code ran (arm64) | Rejected |
node:http server does not inflate Brotli bodies on its own, but many plain servers call zlib.createBrotliDecompress() themselves. Any such call on client data is exploitable without a framework.PoC Reproducibility
Can a PoC be built from available information? Yes. A complete public exploit exists for Node.js 24.22.0 x86-64.
- Required tools: an isolated lab with the official vulnerable
node:24.22.0image, the public exploit, a Brotli bitstream generator, and an ASan-instrumented Brotli build for root-cause triage. - High-level steps (lab only): declare a single block type for one category; let that block's counter run out mid-stream; force a block switch; keep streaming chunks so each append extends the overflow; observe the fault or shell on the vulnerable build and a clean
-17format error on the patched build. - Porting limits: official Node.js binaries are not position-independent, so code addresses are fixed per build, but the heap, stacks and allocator arenas are randomized. The exploit needs a build-specific offset and the correct socket descriptor, which becomes less predictable on busy services. Expect offsets for other popular builds to appear quickly.
Time-to-Exploit Analysis
| Interval | Duration | Notes |
|---|---|---|
| Upstream silent fix → public release | ~236 days (Feb 27 → Oct 21) | Fix was diffable the whole time; attackers watching upstream had a long head start |
| Disclosure → public PoC release | 1 day (Oct 21 → Oct 22) | Public RCE exploit by dimasma0305 |
| Disclosure → RCE demonstrated on official build | 1 day (Oct 21 → Oct 22) | arm64 on Oct 22; x86-64 reproduced by Oct 25 |
| Disclosure → exposure scanning | ~4 days (by Oct 25) | node2shell-check scanner mapping Brotli-decoding hosts |
| Patch → exploit in the wild | 5 days (Oct 21 → Oct 26, 15:08 UTC) | First exploitation attempt against Node.js 24.22.0 x86-64 |
| Disclosure → CISA KEV listing | 6 days (Oct 21 → Oct 27) | CISA cites evidence of active exploitation; federal remediation due date November 10, 2026 |
| Risk Interpretation | Immediate (0–2 days): a public exploit exists, scanning has started, exploitation attempts are being recorded and CISA has listed the CVE as known exploited. | |
Vulnerability Timeline
| Event | Date |
|---|---|
| Vulnerable code ships in Brotli 1.0.3 | March 2018 |
Upstream fix committed to the Brotli source (commit 0ed4268, not labeled as security) | February 27, 2026 |
| Vulnerability discovered and reported (Khai Tran and Sylvie Mayer, OpenAI Codex Security) | Not publicly disclosed |
| Public disclosure: Node.js October 2026 security release, Brotli 1.0.10 / 1.1.1 / 1.2.1 | October 21, 2026 |
| Patch released: Node.js 22.23.4, 24.22.1, 26.12.1 | October 21, 2026 |
Public RCE exploit released by dimasma0305; RCE shown on official arm64 build | October 22, 2026 |
Python Brotli 1.2.1 and brotlicffi 1.2.1.0 published (PYSEC-2026-4322 / GHSA-9v8h-qqgm-4m35; PYSEC-2026-4323) | October 22, 2026 |
| Bun (GHSA-663w-cv8g-jj2p) and Envoy (GHSA-3wvj-j6qm-62mr) advisories | October 23, 2026 |
Exposure scanner node2shell-check observed probing Brotli decode paths | October 25, 2026 |
| Microsoft .NET advisory | October 25, 2026 |
| Exploitation in the wild began (first recorded attempt, 15:08 UTC) | October 26, 2026 |
| CISA adds CVE-2026-111497 to the Known Exploited Vulnerabilities (KEV) catalog; federal due date November 10, 2026 | October 27, 2026 |
Affected and Fixed Versions Across the Ecosystem
Because Brotli is a library, this is a supply-chain-shaped vulnerability. Patching Node.js does not fix the Python worker next to it, the Envoy sidecar in front of it, or a curl binary in the same image.
| Component | Vulnerable | Fixed In | Notes |
|---|---|---|---|
| Node.js 26.x (Current) | < 26.12.1 | 26.12.1 | Statically bundled; OS libbrotli updates do not help |
| Node.js 24.x (Active LTS) | < 24.22.1 | 24.22.1 | Line targeted by the public exploit |
| Node.js 22.x (Maintenance LTS) | < 22.23.4 | 22.23.4 | |
| Node.js 20.x, 23.x, 25.x | All | None (EoL) | Migrate to a supported line; commercial EoL support is the only patch route |
| Google Brotli (libbrotli) | 1.0.3–1.0.9, 1.1.0, 1.2.0 | 1.0.10, 1.1.1, 1.2.1 | Distros may backport without a version bump; follow distro advisories |
Python Brotli | ≤ 1.2.0, including older releases such as 0.5.2, 0.6.0 and 1.0.1 | 1.2.1 | Advisories: PYSEC-2026-4322 (lists these older releases as well as the 1.0.3 to 1.2.0 range), GHSA-9v8h-qqgm-4m35. urllib3, Requests, HTTPX, aiohttp and Scrapy auto-decode br when installed |
Python brotlicffi | ≤ 1.2.0.2, including older releases such as 0.8.0 | 1.2.1.0 | Advisory: PYSEC-2026-4323 (separate PyPA advisory from the Brotli package; lists the older releases as well) |
Ruby brotli gem | Earlier releases | 0.9.1, 0.9.2 | Streaming decoder hangs instead of crashing |
Debian / Alpine brotli | Distro builds | bookworm 1.0.9-2+deb12u1 (DLA-4855-1); trixie 1.1.0-2+deb13u1 (DSA-6582-1); Alpine 1.2.1-r0 / 1.1.1-r0 | Matters where Node.js links the system library |
| Bun | 1.0.21 to < 1.4.4 | 1.4.4 | Rebuild bun build --compile executables |
| Envoy | With Brotli decompressor configured | 1.40.0, 1.39.3, 1.38.6, 1.37.8, 1.36.12 | Proxy-layer exposure |
| .NET | 8, 9, 10 runtimes; 11 previews | 8.0.33, 9.0.22, 10.0.14, 11.0.0-rc.3 | Only via opt-in br request decompression, HttpClient auto-decompression or BrotliStream; .NET Framework unaffected |
| curl and others | Builds linked to affected libbrotli | Vendor-specific | --compressed decodes br |
Active Exploitation & Threat Actors
| Question | Status |
|---|---|
| Exploitation observed in the wild | Yes: first recorded attempt on October 26, 2026 at 15:08 UTC. |
| CISA KEV status | Listed: added to the Known Exploited Vulnerabilities catalog on October 27, 2026 based on evidence of active exploitation. Federal civilian agencies must remediate by November 10, 2026. Known ransomware campaign use: Unknown. |
| Associated threat actors | No named APT or ransomware group. The source IP (94.154.43.7) has a history of mass exploitation, including PHP2Shell exploitation, Palo Alto GlobalProtect and FortiOS SSL VPN probing, and Kubernetes API scanning, consistent with an opportunistic mass-exploitation operator. |
| Reconnaissance | A public exposure checker identifying as node2shell-check/1.0 (AS44620, registered in Turkey) sent 20 Brotli POST bodies in two bursts to map which hosts decompress Brotli. |
| Initial access method | Single Brotli-encoded HTTP POST body of 23,069,983 bytes aimed at Node.js 24.22.0 x86-64 |
| Payload delivery | Delivery stage copied from public exploit code (first 23,069,727 bytes reproduce byte for byte); original ROP chain that plants /bin/sh in its own data and calls Node's execvp |
| Outcome of observed attempt | Chain pivoted and crashed the worker but did not reach execvp in lab replay (0 of 50). Treat it as a first-generation attempt; the next iteration will likely work. |
| Persistence | None observed; the chain wires stdin only, so follow-on actions (reverse shell, downloader) would happen in a second stage |
| Target sectors / geography | Not targeted; opportunistic, internet-wide |
Post-Exploitation Details
| Aspect | Assessment |
|---|---|
| Execution context | Whatever user runs node. In many containers that is root because images do not drop to USER node. |
| Privilege Escalation | Possible, not inherent: root containers, mounted Docker sockets, privileged pods and over-scoped service accounts turn app RCE into host or cluster compromise |
| Lateral Movement | Likely via secrets in process.env (DB URLs, cloud keys, JWT signing keys) and the cloud metadata service (169.254.169.254) |
| Persistence | Modified node_modules or startup scripts on writable volumes, cron/systemd units on VMs, malicious images pushed with stolen registry credentials |
| Data Exfiltration | Possible; memory-disclosure variant can leak in-process secrets even without full RCE |
| Crash side effect | Failed attempts crash the worker. On hosts where systemd-coredump is active, each forced crash writes a core file under /var/lib/systemd/coredump containing other users' session cookies, tokens and keys. Restrict and expire core files. |
| Behavior to expect | node spawning sh/bash/curl/wget, new outbound connections from the runtime, reverse shells, miner downloads, credential harvesting from env and mounted secrets |
MITRE ATT&CK Mapping
| Tactic | Technique | ID | Node2Shell Context |
|---|---|---|---|
| Reconnaissance | Active Scanning: Vulnerability Scanning | T1595.002 | Fingerprinting Express/Node.js services (headers, error pages) and probing whether Content-Encoding: br bodies are accepted |
| Resource Development | Acquire Infrastructure: Server | T1583.004 | Standing up a malicious server that returns crafted Brotli responses for the fetch() path |
| Initial Access | Exploit Public-Facing Application | T1190 | Crafted Brotli request body to Express 5 / body-parser 2 endpoints |
| Initial Access | Trusted Relationship / Supply Chain | T1199 | Compromised upstream API or partner webhook returning malicious Brotli to services that trust it |
| Execution | Exploitation for Client Execution | T1203 | Server-side outbound fetch() decoding an attacker response (the Node process acts as the "client") |
| Execution | Command and Scripting Interpreter: Unix Shell | T1059.004 | Shellcode pivoting to /bin/sh after control-flow hijack |
| Persistence | Server Software Component: Web Shell | T1505.003 | Injecting a route or middleware into application code on writable volumes |
| Privilege Escalation | Escape to Host | T1611 | From root-in-container with privileged mounts or Docker socket |
| Defense Evasion | Impair Defenses / Indicator Removal | T1070 | Crash-and-restart loops look like ordinary instability; orchestrators recycle containers and their logs |
| Credential Access | Unsecured Credentials: Cloud Instance Metadata API | T1552.005 | Pulling IAM credentials from IMDS from inside the compromised process |
| Credential Access | Unsecured Credentials: Credentials In Files | T1552.001 | Reading .env, mounted Kubernetes secrets, ~/.npmrc tokens |
| Command & Control | Application Layer Protocol: Web Protocols | T1071.001 | HTTPS beacons blending with the service's normal outbound API traffic |
| Impact | Endpoint Denial of Service: Application or System Exploitation | T1499.004 | Public PoC crashes the Node.js process; repeatable per request |
| Impact | Resource Hijacking | T1496 | Cryptominer deployment, the most common monetization of runtime RCE |
Vulnerability Chaining Opportunities
Can this be chained? Yes. Node2Shell is an excellent second stage for anything that lets an attacker choose a URL your server fetches.
| Chain | Enabler | Outcome |
|---|---|---|
| Open URL-fetch features + Node2Shell | Webhooks, OAuth/OIDC discovery, oEmbed, link unfurling, image proxies | Unauthenticated RCE through an outbound request |
| Next.js 15/16 apps (CVE-2026-96889 and others patched Oct 14) + Node2Shell | Unpatched framework and unpatched runtime in the same image | Multiple independent RCE paths in one deployment |
| Node2Shell + root container | Missing USER directive, privileged pod, Docker socket mount | Host or node takeover |
| Node2Shell + exposed instance metadata | IMDSv1 left enabled (no session token requirement), or a metadata endpoint that containers can reach with no network restriction | Cloud credential theft and account pivot |
| Memory disclosure variant + session secrets | Secrets in process memory | Token forgery without needing reliable RCE |
Patch / Mitigation
| Item | Detail |
|---|---|
| Patch Available | Yes |
| Patch Versions | Node.js 22.23.4, 24.22.1, 26.12.1 (or later in each line) |
| Download | Node.js October 2026 Security Releases | Google Brotli 1.0.10 / 1.1.1 / 1.2.1 |
| Notes | Restart is mandatory: running processes keep the old decoder in memory. Rebuild every container image, including multi-stage builds that copy a Node binary from an older base. Node.js states there is no workaround; everything below is a stopgap. |
Verify the runtime you actually run
# Host / VM node --version # Do NOT rely on process.versions.brotli: the fix was cherry-picked without a version bump, # so it reports 1.2.0 on both patched and vulnerable builds. Check the Node.js release instead. # Patched runtimes expose the new decoder error constant (expect a number, not undefined) node -p "require('zlib').constants.BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH" # Every image in your registry / cluster, not just the app server docker run --rm --entrypoint node <image> --version kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}{"/"}{.metadata.name}{"\t"}{.spec.containers[*].image}{"\n"}{end}' # Python environments that silently enable br decoding pip show brotli brotlicffi 2>/dev/null | grep -E "Name|Version"
Interim mitigation while rolling out
# NGINX: refuse Brotli-encoded request bodies at the edge if ($http_content_encoding ~* "br") { return 415; } # HAProxy http-request deny deny_status 415 if { req.hdr(content-encoding) -i -m sub br }
// Express 5: reject br bodies BEFORE any body-parser middleware runs app.use((req, res, next) => { const ce = (req.headers['content-encoding'] || '').toLowerCase(); if (ce.split(',').some(v => v.trim() === 'br')) { return res.status(415).send('Unsupported Content-Encoding'); } next(); }); app.use(express.json());
Accept-Encoding: gzip from fetch() does not protect you: a hostile server can return Content-Encoding: br anyway and the client will decode it. Until patched, route untrusted outbound fetches through an egress proxy that strips or rejects br responses, or temporarily disable the URL-fetching features.Indicators of Compromise (IOCs)
IOCs Available: Yes. Indicators below come from the first recorded in-the-wild exploitation attempt and the reconnaissance that preceded it. The exploit body varies per target, so behavioral indicators remain essential.
Network and payload indicators
| Type | Indicator | Context |
|---|---|---|
| IP address | 94.154.43.7 | Exploitation source, Oct 26, 2026 15:08 UTC. Rated malicious with high confidence; first seen Dec 15, 2025; active on 47 of the prior 90 days |
| SHA-256 (request body) | 7fcbdaaecd4419e2930927844f3b772668204737a4e7602a2290c991c93c5c4b | Brotli-encoded exploit body |
| Payload size | 23,069,983 bytes | Single POST body with Content-Encoding: br |
| Payload structure | 1031-byte memory-layout stage + 256-byte displacement stage | Delivery copied from public exploit; ROP chain original |
| User-Agent | Mozilla/5.0 (compatible; node2shell-check/1.0) | Public exposure checker; source network AS44620 (Turkey) |
| Scan pattern | GET / then 20 POST bodies with Content-Encoding: br in two bursts ~2 minutes apart | 5 malformed 1032-byte bodies padded with repeated A; 15 valid Brotli bodies in four sizes, largest 65,743 bytes |
| Target | Official Node.js v24.22.0 Linux x86-64 release binary | Chain addresses are specific to this build |
# Structured IOC list (CVE-2026-111497 / Node2Shell) ipv4 94.154.43.7 # exploitation sha256 7fcbdaaecd4419e2930927844f3b772668204737a4e7602a2290c991c93c5c4b # br exploit body asn AS44620 # node2shell-check scanner source user_agent Mozilla/5.0 (compatible; node2shell-check/1.0) http POST, Content-Encoding: br, Content-Length: 23069983 http POST, Content-Encoding: br, Content-Length: 1032 (A-padded)
Host and behavioral indicators
| Indicator Type | What to look for | Confidence |
|---|---|---|
| HTTP request | Very large (multi-MB) Content-Encoding: br POST bodies to API endpoints; browsers almost never send br request bodies | High |
| HTTP response (patched) | HTTP 400 with ERR__ERROR_FORMAT_BLOCK_SWITCH: an exploit attempt blocked by a patched runtime | High (attempt) |
| Process | node exits with SIGSEGV / SIGBUS / SIGABRT (exit codes 139, 135, 134); container restart loops | High (attempt) |
| Process tree | node → sh/bash/dash/curl/wget/python/nc | High (post-exploitation) |
| Host network | The node process opening connections to 169.254.169.254, mining pools or new IPs after a crash and restart (needs host or eBPF telemetry) | High |
| Filesystem | New files in /tmp, /dev/shm; modified files under node_modules; new core files in /var/lib/systemd/coredump | High |
Detection & Monitoring
Log sources
| Log Source | Key Data | Default? | Detects |
|---|---|---|---|
| Reverse proxy / WAF / LB logs | Request Content-Encoding header, status, bytes | Partial: header often not logged; add it to log format | Inbound exploit attempts |
| Container runtime / Kubernetes events | OOMKilled vs Error, exit codes, restart counts | Yes | Crash loops from DoS or failed RCE attempts |
| systemd journal / PM2 logs | Signals, core dumps | Yes | Crashes on VMs |
| Falco / Tetragon / auditd / Sysmon for Linux | execve, connect, file writes | No | Shell spawn and C2 after successful exploitation |
| Egress proxy logs | Response Content-Encoding to internal services | No | Malicious Brotli responses on the fetch() path |
| VPC flow logs | Connections between workloads and the internet | Partial | C2 and exfiltration. Not IMDS access: AWS excludes traffic to and from 169.254.169.254 from VPC Flow Logs, so use host-level telemetry (eBPF, auditd, EDR) for metadata access |
Splunk
# ── QUERY 1: Brotli-encoded request bodies to app endpoints ───────────── index=web sourcetype IN ("nginx:plus:kv","nginx:access","aws:elb:accesslogs") http_method IN ("POST","PUT","PATCH") http_content_encoding="*br*" | stats count values(uri_path) AS paths values(status) AS statuses max(bytes_in) AS max_body by src_ip | where count > 3 OR max_body > 10000000 | eval risk="HIGH - Possible CVE-2026-111497 probing / exploitation" # ── QUERY 2: Node.js crash bursts per host ────────────────────────────── # Aggregate with stats by time bucket AND host, filter on the plain count field, # then chart if needed. (timechart ... by host pivots hosts into separate columns, # so a later "where count > 2" has no count field to test.) index=k8s OR index=linux (process_name="node" OR container_command="*node*") (exit_code IN (134,135,139) OR signal IN ("SIGSEGV","SIGBUS","SIGABRT")) | bin _time span=5m | stats count AS crashes by _time host | where crashes > 2 | xyseries _time host crashes ``` optional: chart only the filtered rows ``` # ── QUERY 3: Node.js spawning a shell or downloader (post-RCE) ────────── index=edr (parent_process_name="node" OR parent_process_name="nodejs") process_name IN ("sh","bash","dash","curl","wget","nc","ncat","python","python3","perl") | eval risk="CRITICAL - Node.js child shell" | table _time host user parent_process_name process_name process_cmdline risk # ── QUERY 4: br request FOLLOWED BY a crash on the same host (≤ 60 s) ── # Ordered correlation: request must precede the crash. src_ip identifies the # client that sent the preceding request; it is a candidate, not proof of # attribution, unless request-level IDs (e.g. X-Request-ID) link the two events. (index=web http_content_encoding="*br*" http_method="POST") OR (index=k8s exit_code IN (134,135,139)) | eval etype=if(index="web","req","crash") | eval req_time=if(etype="req",_time,null()), req_src=if(etype="req",src_ip,null()) | sort 0 host _time | streamstats last(req_time) AS last_req_time last(req_src) AS last_req_src by host | where etype="crash" AND isnotnull(last_req_time) AND (_time - last_req_time) >= 0 AND (_time - last_req_time) <= 60 | eval gap_s=_time - last_req_time | table _time host last_req_src gap_s | rename last_req_src AS "preceding br request src (candidate)" # ── QUERY 5: Known IOCs ───────────────────────────────────────────────── index=web (src_ip="94.154.43.7" OR http_user_agent="*node2shell-check*") | stats count earliest(_time) AS first latest(_time) AS last values(uri_path) by src_ip http_user_agent
host value (for example, the pod or instance name behind the load balancer). If the proxy log only records the load balancer, correlate on the upstream address field instead, and attribute a source only when request-level evidence links the request to the crashing worker.Elastic / KQL
# ── br request bodies ──────────────────────────────────────────────────── http.request.method:(POST or PUT or PATCH) and http.request.headers.content-encoding:*br* # ── node spawning shells ───────────────────────────────────────────────── event.category:process and event.type:start and process.parent.name:(node or nodejs) and process.name:(sh or bash or dash or curl or wget or nc or python3) # ── node talking to instance metadata (endpoint telemetry, not flow logs) ─ event.category:network and process.name:node and destination.ip:"169.254.169.254" # ── Known IOCs ─────────────────────────────────────────────────────────── source.ip:"94.154.43.7" or user_agent.original:*node2shell-check*
Microsoft Sentinel (KQL)
// ── Linux endpoint (MDE): node spawning a shell ───────────────────────── DeviceProcessEvents | where InitiatingProcessFileName in~ ("node", "nodejs") | where FileName in~ ("sh","bash","dash","curl","wget","nc","python3","perl") | project Timestamp, DeviceName, AccountName, InitiatingProcessCommandLine, ProcessCommandLine | extend Risk = "CRITICAL - Possible Node2Shell post-exploitation" // ── Container crash loops (Container Insights) ────────────────────────── KubePodInventory | where ContainerRestartCount > 3 | where ContainerLastStatus has_any ("Error", "139", "134") | summarize Restarts = max(ContainerRestartCount) by Name, Namespace, bin(TimeGenerated, 15m) // ── Patched runtime rejecting exploit input (app logs) ────────────────── AppTraces | where Message has "ERR__ERROR_FORMAT_BLOCK_SWITCH" | summarize Attempts = count() by AppRoleName, bin(TimeGenerated, 15m) // ── App Gateway / Front Door: br-encoded request bodies ───────────────── AzureDiagnostics | where Category in ("ApplicationGatewayAccessLog", "FrontDoorAccessLog") | where requestHeaders_s has "content-encoding: br" | summarize count() by clientIP_s, requestUri_s, bin(TimeGenerated, 10m)
Falco (runtime)
- rule: Node.js Spawns Shell (Node2Shell post-exploitation)
desc: A Node.js process started a shell or network utility
condition: >
spawned_process and proc.pname in (node, nodejs) and
proc.name in (sh, bash, dash, curl, wget, nc, ncat, python3, perl)
output: "node child process (parent=%proc.pname cmd=%proc.cmdline container=%container.name image=%container.image.repository)"
priority: CRITICAL
tags: [CVE-2026-111497, T1059.004]
Suricata (inbound, cleartext or after TLS termination)
alert http any any -> $HOME_NET any (msg:"Possible CVE-2026-111497 Node2Shell - Brotli request body"; flow:established,to_server; http.method; content:"POST"; http.header; content:"Content-Encoding|3a 20|br"; nocase; threshold:type both, track by_src, count 5, seconds 60; classtype:attempted-admin; sid:9261114; rev:1;)
Behavioral indicators for EDR
- Any interactive shell whose ancestor is
nodein a container that normally never executes child processes. - Memory pages becoming writable and executable in a
nodeprocess outside V8's JIT regions. - A
nodeprocess that crashed and restarted several times, then begins outbound connections it never made before. - Reads of
/proc/self/environ,/var/run/secrets/kubernetes.io/serviceaccount/tokenor~/.aws/credentialsby the runtime.
Risk Assessment
| Factor | Rating | Rationale |
|---|---|---|
| Impact | Critical | Full code execution in the server process; confidentiality, integrity and availability all high |
| Likelihood | Very High | Public RCE exploit, active exposure scanning, in-the-wild exploitation attempts and a CISA KEV listing; Node.js is everywhere and versions leak through headers and image tags |
| Exploit maturity influence | Raises priority sharply | Public exploit within a day of disclosure, in-the-wild attempts within five days and a CISA KEV listing within six; per-build offsets enable version-targeted waves |
| Mitigating factors | Limited | Not every app decodes untrusted Brotli; each build needs its own offset; the first observed in-the-wild chain failed |
| Overall Risk Level | Critical for exposed paths; High otherwise | Patch within 24–72 hours for internet-facing Express 5 services and any service fetching attacker-influenced URLs |
Exposure decision flow
Remediation Recommendations
node: base tags, pin by digest, redeploy and restart. Include CI runners, serverless layers, Electron-free build tools and SSR workers. Confirm with node --version inside the running container, not the Dockerfile. The CISA KEV federal due date of November 10, 2026 is an outer limit for agencies, not a target; internet-facing services should be patched well before it.Brotli to 1.2.1 and brotlicffi to 1.2.1.0 (regenerate lockfiles). Treat every installed version at or below 1.2.0 (Brotli) and 1.2.0.2 (brotlicffi) as affected, including old pinned 0.x and 1.0.x releases, Bun to 1.4.4, Envoy to a fixed release, .NET runtimes to 8.0.33 / 9.0.22 / 10.0.14, and the OS libbrotli package per your distribution's advisory. Use SBOMs (Syft, Trivy) to locate vendored copies inside images.Content-Encoding: br on inbound requests at the WAF, load balancer or reverse proxy, or with an Express middleware mounted before body parsers. Few legitimate browser clients send compressed request bodies, so the business impact is usually small.br responses until runtimes are patched. Remember that request headers you send do not control what a hostile server returns.USER node, read-only root filesystems, allowPrivilegeEscalation: false, dropped capabilities, seccomp RuntimeDefault, and no Docker socket mounts. These turn a successful RCE into a contained incident.node → sh is cheap, high-signal and catches this and every future runtime RCE. Treat repeated node segfaults as a security event, not just an SRE ticket.SecPod Coverage
| Capability | CVE-2026-111497 Coverage |
|---|---|
| Vulnerability Detection | OVAL-based checks identifying vulnerable Node.js 22.x, 24.x and 26.x installations on Windows, macOS and Linux, plus detection of end-of-life Node.js lines that will not receive a fix |
| Patch Assessment | Saner flags endpoints and servers running Node.js below 22.23.4, 24.22.1 or 26.12.1 as critical-priority remediation |
| Remediation | Patch deployment for supported Node.js installations via Saner; EoL runtime migration advisory workflow |
Key Takeaways
- The bug is in the runtime, not your code. Express, body-parser and
fetch()are delivery routes into a native decoder compiled into Node.js. - Outbound traffic is attack surface. A server that fetches a URL an attacker controls can be exploited through the response, and edge WAFs never see it.
- One error code, two meanings. Collapsing "malformed" into "incomplete" is a recurring parser bug class; streaming decoders are especially prone to it.
- This is now exploited. A public exploit, in-the-wild attempts and a CISA KEV listing mean the window for routine patching has closed.
- Silent fixes are an attacker head start. Eight months between the upstream commit and a release is time defenders did not know they had lost.
- Patch, rebuild, restart, verify. Updating a package without restarting the process or rebuilding the image leaves the vulnerable decoder running.
Node2Shell is a reminder that the lowest layers of the web stack, compression, parsing and TLS, are now being mined with AI-assisted tooling at a pace that compresses the gap between disclosure and a public exploit to about a day, and to in-the-wild attempts and a CISA KEV listing within a week. The patch is available and drop-in. The fastest way to remove this risk is to replace every vulnerable Node.js runtime you run and restart it today.
Instantly Fix Risks with Saner Patch Management
Saner patch management is a continuous, automated, and integrated software that helps security teams fix risks exploited in the wild. The software supports major operating systems such as Windows, Linux, and macOS, along with 550+ third-party applications.
It allows teams to set up a safe testing area to validate patches before deploying them in production. Saner patch management also supports patch rollback in case of patch failure or system malfunction.
Experience the fastest and most accurate patching software here.




