node2shell (CVE-2026-111497): remote code execution in the Node.js Brotli decoder
node2shell (CVE-2026-111497) is an out-of-bounds write in the Brotli decoder bundled with Node.js, patched on 21 October 2026. From compressed data to code execution: what the flaw means for your fleet, the three entry points, and how to check your exposure without breaking anything.

The day Node and PHP patched at the same time
On 21 October 2026, two major security releases landed on the same day. On one side PHP, with its php2shell flaw taking up all the air. On the other Node.js, quietly shipping ten fixes at once. Buried in the list, a dry line about a Brotli decoder. Easy to miss, wedged between two TLS bypasses and a DNS bug.
The next day, that line had a name, node2shell, and a demonstration that was less amusing: researchers at Imperva ran code on an official, unmodified Node.js image. Because this flaw does not live in an application you wrote badly. It lives in the engine, underneath millions of applications, from the small internal microservice to the API that absorbs your production traffic.
That shift in target is what makes node2shell interesting. wp2shell and xss2shell went after WordPress. php2shell went after the PHP interpreter. node2shell goes after the decoder Node.js bundles into its own binary. You do not fix that by touching your code, and that is exactly what makes it easy to forget.
Inside the bug: one error mistaken for another
Brotli is a Google compression algorithm, used across the web to shrink HTTP responses. Node.js bundles a copy in its binary, exposed through the node:zlib module. The defect hides in that copy, and it comes down to an almost trivial confusion.
A Brotli stream is read block by block, and the decoder moves byte by byte as the data arrives. Everything turns on one inconsistency: when the stream asks for a block-type switch that its own structure makes impossible, the vulnerable decoder does not decide the input is broken. It decides it is simply short of data, and it keeps going.
That is the whole difference between two signals that look alike and mean nothing alike: "come back when you have more data" and "these bytes are invalid, stop". The first is normal in a streaming decoder. The second must halt everything. By conflating them, the decoder keeps stacking the attacker's bytes into a buffer that only expects eight.

Past the eighth byte, the write spills into neighboring memory. That is the out-of-bounds write, classed CWE-787, and it is from that one byte too many that everything else follows, from a plain crash to code execution.
The fix matches its cause: it comes from an upstream Brotli commit (0ed4268a, from February 2026) that Node.js cherry-picked. It teaches the decoder to return a genuine format error on that transition, instead of a wait-for-more signal. The same fix lives in library versions 1.0.10, 1.1.1 and 1.2.1. Worth keeping in mind: any software that bundles Brotli may be affected, not just Node.js. On 23 October the flaw even picked up a second name, HTTP2Shell, to underline that reach: right down to the Python brotlicffi binding, anything that decodes Brotli received over HTTP shares the same blind spot.
Three doors, one decoder
Here is the point worth stopping on, rather than a version number to patch. The flaw is in node:zlib, but node:zlib does not talk to the internet on its own. For exploitation to happen, attacker-controlled data has to reach the decoder. Three paths lead there, and they do not share the same risk profile.

The first is the most concerning, and the one that touches external exposure. Express 5 and body-parser 2.x decompress, by default, request bodies sent with Content-Encoding: br. An anonymous attacker only has to POST a crafted Brotli body to a route that reads the body, and their data reaches the decoder. No account, no third party. A useful detail for detection: Express 4 and body-parser 1.x do not decompress inbound Brotli and answer 415. The vector is specific to the Express 5 line.
The second is sneakier. fetch(), built into Node, automatically decompresses Brotli responses. If your application calls a URL the attacker controls, that URL answers with a crafted body, and it is your process that decodes it. Outbound webhooks, integrations that follow a user-supplied URL, anything resembling an SSRF: the request leaves from the inside and sidesteps your perimeter defenses.
The third is the most direct. If your code itself passes untrusted data to brotliDecompress or createBrotliDecompress, you open the door with no detour. This is the vector the official advisory highlights, because it is the raw primitive. The first two paths are only ways of reaching it without having written it.
From data to a shell: what is proven, and what is not
Let us be precise, because this is where rushed articles go off the rails. node2shell is not a guaranteed "one packet, one shell" RCE. The official CVSS vector says as much: a v4.0 score of 9.2, but an AT:P metric signaling that success depends on conditions not always met. Some databases also publish a CVSS 3.1 of 9.8.
What the proofs of concept establish, step by step:
- The crash and memory leak are reliable. Making the decoder write outside its buffer is enough to take the process down or read neighboring bytes. Settled, reproducible.
- Code execution is demonstrated, but probabilistic. Imperva achieved it on an official Node.js 24.22.0 image on Linux arm64, with a marker file created and no debugger. But the team notes the exploit "took multiple attempts" and did not succeed every time. 24.22.1 rejects the same input.
- Another proof of concept is more modest. According to The Hacker News, a competing PoC only reached code execution on a deliberately weakened test build; on a standard binary, its author reported only a crash and a limited memory read.
As of 23 October 2026, no real-world exploitation has been reported, and the flaw is not in CISA's KEV catalog. But that is a snapshot, not a conclusion. The mechanism is public, proofs of concept are circulating, and the gap between "reliable crash" and "occasional RCE" narrows with every iteration. The pragmatic read for a CISO fits in one sentence: treat the crash and leak as certain, code execution as plausible, and patch before the nuance goes stale.
The inbound vector, from anonymous request to memory write
- 1
Anonymous request with Content-Encoding: br
an attacker POSTs a Brotli body to an Express 5 / body-parser 2.x route.
- 2
Automatic decompression
the framework decodes the body without asking, and the data reaches node.
- 3
Invalid block transition
the stream asks for a block-type switch its structure forbids.
- 4
Error mistaken for missing data
the decoder keeps going instead of stopping, and stacks the bytes into its eight-byte buffer.
- 5
Out-of-bounds write
past the eighth byte, the decoder corrupts neighboring memory.
Crash, leak or code execution
depending on memory layout and the attacker's luck.
Which versions to fix
The flaw affected every supported line before 21 October. Each maintained line got its fix floor; the end-of-life lines will never get one.

Two subtleties are worth stating. First, the bundled-runtime trap: Node.js bundles its copy of Brotli in its binary. A container image frozen on an old tag, a single executable or a managed-platform runtime stay vulnerable until they are rebuilt on a fixed Node.js. What counts is the binary that runs, not the version on the machine that built it.
Second, what you read. A floating tag like node:24 will point to a fixed image after a rebuild, whereas a strict pin like node:24.22.0 stays frozen on the vulnerable one. And a distribution backport (Debian, Ubuntu, RHEL, Alpine) may fix the system Brotli package without changing the Node version number: on those systems, node --version does not tell the whole story.
How to test without breaking anything
Mind the trap here. node2shell does not test like php2shell, where a harmless payload confirms the flaw. The only payload that would truly prove it is a malformed Brotli input, which is exactly the one that triggers the bug and can crash a production worker. You do not do that to your own servers, let alone someone else's.
The right approach runs in two parts: read versions on one side, verify the inbound vector is open on the other, without ever sending dangerous input.
Read the version actually in service
This is the only thing that settles it with certainty. Read it where the code really runs, not on your laptop:
# On a host or CI runner
node --version
# Inside a container image, without starting it as a service
docker run --rm your-image node --versionA version below its line's floor (24.22.0, say) is vulnerable; an end-of-life version is vulnerable and will stay that way.
Find the dependencies that open the inbound vector
Even on a vulnerable runtime, a door still has to be open. Look for Express 5 and body-parser 2.x, then direct calls to the decoder:
npm ls express body-parser
grep -rn "brotliDecompress\|createBrotliDecompress" --include=*.js --include=*.ts .And inventory your outbound fetch() calls to servers you do not control. These are the three doors from the diagram, to be checked one by one.
Verify the inbound vector is open, safely
You can confirm a server decompresses inbound Brotli without ever sending it malformed input: just POST a valid stream and watch whether it gets decoded. That is the principle of our template.
# Body = well-formed Brotli of {"a":1}, sent with Content-Encoding: br.
# If decompressed, the route answers 200/204/404, never 415.
printf '\x0b\x03\x80{"a":1}\x03' \
| curl -s -o /dev/null -w "with br -> %{http_code}\n" \
-X POST https://your-domain/ \
-H 'Content-Type: application/json' \
-H 'Content-Encoding: br' --data-binary @-
# Same bytes, WITHOUT the header: raw binary, the JSON parser must answer 400.
printf '\x0b\x03\x80{"a":1}\x03' \
| curl -s -o /dev/null -w "no encoding -> %{http_code}\n" \
-X POST https://your-domain/ \
-H 'Content-Type: application/json' --data-binary @-If the first command returns 200, 204 or 404 and the second returns 400, the server really does decompress inbound Brotli: the door is open. A 415 on the first, and the stack refuses inbound Brotli (Express 4). This test proves the vector is reachable, not that the runtime is vulnerable: Node.js publishes its version nowhere over HTTP. That is why we always pair it with the version read.
The remediation plan, in order of effectiveness
- Update the runtime to 26.12.1, 24.22.1 or 22.23.4 (or newer). It is the only measure that closes all three doors. On an end-of-life line, you have to migrate.
- Rebuild whatever bundles the runtime. Container images, single executables, serverless functions: a host update does not touch a runtime frozen into an artifact. Check the version inside each deployed image.
- Patch the inbound vector while you deploy: reject
Content-Encoding: bron exposed paths, at the reverse proxy or in a middleware. Vercel, for one, added inbound firewall rules as early as 21 October. - Do not forget
fetch()ornode:zlib. The inbound filter protects neither your outbound calls nor your in-code decompressions. For those, only the update counts.
Spotting attempts in your logs
You have nothing to send to look for traces. Three signals deserve a query: POST requests carrying Content-Encoding: br on routes that have no reason to receive it, especially in bursts; unexpected process exits or Brotli decoder errors, bearing in mind that an isolated crash is not proof; and on the outbound side, abnormal Brotli responses on your fetch() calls to third parties.
The lesson: node2shell and php2shell, same day, same blind spot
Back to that 21 October. Node.js and PHP shipped their fixes on the same day, each with its own "2shell" flaw. The common thread is not technical, it is organizational: neither one is fixed by touching your application code. These are foundation flaws, and the foundation is exactly what inventories forget.
The annual pentest does not see them: a flaw disclosed on 21 October does not exist in a spring report. The application scan looks for vulnerabilities in your code, not in the version of the engine that runs it. And dependency management catches Express and body-parser, but not always the runtime version frozen into an image built three months ago.
To close that blind spot, you need two things working together: a component inventory that reads the versions actually bundled, and an external surface map that finds exposed services, including the forgotten Node microservice on a subdomain. It is the same reflex as for the whole series: map your information system and inventory your external attack surface before the next foundation flaw lands.
Detecting node2shell continuously with Flawfence
At Flawfence, the logic does not change from one flaw to the next, and that is what lets you react in hours rather than weeks. The engine maps the perimeter, finds exposed services, Shadow IT included, and places each asset within the known vulnerable ranges, without ever sending a destructive payload.
Facing node2shell, Flawfence:
- discovers your exposed Node services, across all your domains and subdomains, spotting the Express front;
- checks whether the inbound vector is open, using well-formed Brotli only, never the malformed input that triggers the bug;
- places the version when an artifact reveals it, reading the exact pins in your Dockerfiles,
.nvmrcor CI workflows, and ignoring floating tags; - monitors continuously, so a newly exposed service or a version rollback raises an alert.
The Nuclei templates we wrote
To cover both sides of the problem, the vector's reachability and the real version, we maintain two templates. They are designed to never trigger the bug: the network one sends only well-formed Brotli, the file one only reads.
The first does not settle for seeing "some Express". It requires three simultaneous signals to rule out false positives: a JSON parser that rejects raw binary (400), Brotli decompression actually performed on the same well-formed bytes (200/204/404, never 415 or 5xx), and a confirmed Express/Node identity. The payload is always a valid stream: it confirms an exploitable configuration, it does not exploit it.
id: CVE-2026-111497-flawfence
info:
name: Node.js Brotli Decoder OOB Write (CVE-2026-111497, "node2shell") - Exploitable Inbound Config
author: Flawfence
severity: high
description: |
Blackbox, non-destructive confirmation of the exploitable inbound configuration of
node2shell. Requires three simultaneous signals; sends only WELL-FORMED Brotli, never
malformed input, so it cannot trigger the bug or crash a worker.
classification:
cvss-metrics: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
cvss-score: 9.2
cve-id: CVE-2026-111497
cwe-id: CWE-787
tags: nodejs,node2shell,express,brotli,cve,cve2026
http:
- raw:
- |
GET / HTTP/1.1
Host: {{Hostname}}
Accept: */*
# Valid Brotli of {"a":1} WITH Content-Encoding: br -> must be decompressed
- |
POST / HTTP/1.1
Host: {{Hostname}}
Content-Type: application/json
Content-Encoding: br
{{base64_decode("CwOAeyJhIjoxfQM=")}}
# Same bytes, WITHOUT encoding -> raw binary, must break the JSON parser (400)
- |
POST / HTTP/1.1
Host: {{Hostname}}
Content-Type: application/json
{{base64_decode("CwOAeyJhIjoxfQM=")}}
# Random path -> Express/finalhandler emits "Cannot GET /<rand>"
- |
GET /{{randstr}} HTTP/1.1
Host: {{Hostname}}
Accept: */*
req-condition: true
matchers-condition: and
matchers:
- type: dsl
name: json-parser-rejects-raw-binary
dsl:
- "status_code_3 == 400"
- type: dsl
name: brotli-body-decompressed
dsl:
- "status_code_2 == 200 || status_code_2 == 204 || status_code_2 == 404"
- type: dsl
name: express-node-identity
dsl:
- "contains(to_lower(header_1), 'x-powered-by: express') || contains(to_lower(header_2), 'x-powered-by: express') || contains(to_lower(header_3), 'x-powered-by: express') || contains(to_lower(header_4), 'x-powered-by: express') || contains(body_2, 'Cannot POST /') || contains(body_4, 'Cannot GET /')"A match proves the inbound decode path is live, pre-authentication. It does not prove the patch level: hence the host-side version check that accompanies it.
The second template touches nothing on the network. It reads the version pinned in a project's artifacts (FROM node:x.y.z, .nvmrc, .node-version, the engines field of a package.json, a node-version in a CI workflow) and compares it to the affected ranges. A match here is a certainty. Floating tags like node:24 are ignored on purpose: after a rebuild, they point to a fixed image.
id: CVE-2026-111497-flawfence-pinned-version
info:
name: Node.js Brotli Decoder OOB Write (CVE-2026-111497, "node2shell") - Affected Runtime Pinned
author: Flawfence
severity: high
tags: nodejs,node2shell,brotli,cve,cve2026,file
file:
- extensions:
- all
extractors:
- type: regex
name: node_version
internal: true
group: 1
regex:
- "(?i)FROM\\s+[^\\n]*node:v?([0-9]+\\.[0-9]+\\.[0-9]+)"
- "(?i)\"node\"\\s*:\\s*\"[~^>=\\s]*v?([0-9]+\\.[0-9]+\\.[0-9]+)\""
- "(?i)node-version:\\s*[\"']?v?([0-9]+\\.[0-9]+\\.[0-9]+)"
- "(?m)^v?([0-9]+\\.[0-9]+\\.[0-9]+)\\s*$"
matchers-condition: or
matchers:
- type: dsl
name: affected-supported-below-fix
dsl:
- "compare_versions(node_version, '>= 22.0.0', '< 22.23.4') || compare_versions(node_version, '>= 24.0.0', '< 24.22.1') || compare_versions(node_version, '>= 26.0.0', '< 26.12.1')"
- type: dsl
name: affected-eol-no-fix
dsl:
- "compare_versions(node_version, '>= 23.0.0', '< 24.0.0') || compare_versions(node_version, '>= 25.0.0', '< 26.0.0') || compare_versions(node_version, '< 22.0.0')"Together, they answer the only two questions that matter: is the vector reachable, and is the bundled version affected. Neither sends the malformed input that triggers the flaw.
The node2shell timeline
From the upstream fix to the proof of concept
Before disclosure
February 2026
Upstream fix
the Brotli library fixes the invalid block transition (commit 0ed4268a), released in versions 1.0.10, 1.1.1 and 1.2.1.
Report
OpenAI Codex Security
Khai Tran and Sylvie Mayer report the flaw to the Node.js project.
Disclosure
21 October 2026
Release
Node.js ships 22.23.4, 24.22.1 and 26.12.1, fixing ten vulnerabilities including node2shell. PHP publishes its own fixes the same day.
21 October 2026
First stopgaps
managed platforms add inbound firewall rules against Content-Encoding: br.
22 October 2026
Proofs of concept
several public PoCs appear; Imperva reports code execution on an official 24.22.0 image, with partial success.
23 October 2026
No mass exploitation
no real-world exploitation reported, flaw absent from CISA's KEV catalog.
Frequently asked questions about node2shell
What is node2shell?
node2shell is the nickname for CVE-2026-111497, an out-of-bounds write (CWE-787) in the Brotli decoder that Node.js bundles into its binary. Malformed input makes the decoder mistake a format error for missing data: it writes past an eight-byte buffer. Possible outcome: crash, memory leak, or remote code execution, with no authentication.
Which Node.js versions are vulnerable?
Every supported line before 21 October 2026. The fix arrived with 22.23.4, 24.22.1 and 26.12.1. End-of-life lines (25.x, 23.x, 21.x, 20.x and earlier) will get no fix: you have to migrate. Bun is affected before 1.4.4.
Is node2shell being actively exploited?
As of 23 October 2026, no real-world exploitation has been reported and the flaw is not in CISA's KEV catalog. But proofs of concept have been circulating since 22 October, one of which executed code on an official, unmodified image, with partial success. The crash and memory leak, for their part, are reliable. Patch without waiting for mass exploitation.
Am I affected if I don't use Express?
Possibly. Express and body-parser 2.x are the most exposed door, but not the only one. fetch() automatically decompresses Brotli responses from the servers you call, and any direct call to brotliDecompress on untrusted input opens the flaw too. The vulnerability is in the runtime, not in Express.
Is updating my server's Node.js package enough?
Not if your application runs in a container, a single executable or a serverless function. Node.js bundles its copy of Brotli: you have to rebuild the artifact on a fixed runtime, then check the version inside the deployed image. An image frozen on an old tag stays vulnerable even if the host is up to date.
Is the detection template intrusive or dangerous?
No. The network template sends only well-formed Brotli (it decodes to {"a":1}, with no amplification), never the malformed input that triggers the bug: it can neither exploit it nor crash a worker. It POSTs a small body and observes status codes, nothing more. The file template only reads, with no network traffic at all.
Does blocking Content-Encoding: br at the firewall protect me?
Partially, and temporarily. That filter closes the inbound vector, but touches neither your outbound fetch() calls nor your in-code decompressions. It is a holding measure, never a substitute. The only complete remediation is updating the runtime.
In summary
node2shell is a reminder of a truth that code inventories forget: the gravest flaw is not always in your application, it is sometimes in the engine that runs it. A format error mistaken for missing data, an eight-byte buffer that overflows, and the Brotli decoder at the core of Node.js becomes a memory-write primitive reachable with no authentication.
The good news is the same as for the rest of the series: protecting yourself takes neither a destructive exploit nor a 20k pentest. It takes reading the versions actually bundled, finding the exposed services your inventories lost track of, and monitoring continuously rather than once a year. That is exactly what Flawfence does, for node2shell as for the next foundation flaw that does not yet have a name.
Check your exposure now, it will only take 5 minutes. And start with the one thing that truly settles it: the Node.js version actually running in production.
Let’s discuss your external exposure
Request a personalized Flawfence demo and discover your real exposure level.
