Guide
Node2Shell: Node.js Brotli decoder vulnerability (CVE-2026-111497), check and patch
By Aman Singh · Published · Updated · 10 min read
On 21 October 2026 Node.js released 22.23.4, 24.22.1 and 26.12.1 with fixes for ten vulnerabilities. The most serious, CVE-2026-111497, is an out-of-bounds write in the Brotli decoder bundled with Node.js that can lead to remote code execution. Researchers call it Node2Shell, and some write-ups use HTTP2Shell. GitHub's advisory scores it 9.2 out of 10 (Critical, CVSS 4.0); Node.js rates it High. Brotli is the br compression used all over the web, and several everyday Node.js code paths decompress it automatically.
It landed the same day as the PHP2Shell fixes and a week after the Next.js security update. If you run more than one of these, the October 2026 security roundup lists every fixed version on one page.
The official advisory is one sentence long, so this guide fills in the practical part: a checker that detects the fix inside the Node.js binary itself, tests showing which common libraries hand outside data to the decoder, and stop-gaps for the time between now and your deploy. All tests ran on 23 October 2026 against official Node.js builds 20.20.2, 24.22.0, 24.22.1, 26.12.0 and 26.12.1.
Want your Node.js servers patched and checked for you? Node.js and Next.js fixes →
What was fixed
| Details | |
|---|---|
| CVE | CVE-2026-111497 |
| Official title | Node.js Brotli decoder out-of-bounds write permits remote code execution |
| Also called | Node2Shell, HTTP2Shell |
| Severity | 9.2 out of 10, Critical (CVSS 4.0, GitHub advisory); High (Node.js project rating) |
| Affected lines | 26.x, 24.x, 22.x, and every end-of-life line |
| Fixed in | 26.12.1, 24.22.1, 22.23.4 (21 October 2026) |
| The change | Node.js cherry-picked Brotli commit 0ed4268, "Detect and report invalid stream earlier", which makes the decoder reject an invalid block switch with a new error code |
That new error code is exposed as zlib.constants.BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH. In every build tested it was present (value -17) in patched releases and missing in unpatched ones, which makes it a better test than the version number. The bundled Brotli version string, by contrast, reads 1.2.0 before and after the fix.
Which apps are reachable
The attack works in both directions: a malicious client can take over an unpatched server by sending it a Brotli-compressed request, and a malicious server can take over an unpatched client by sending it a Brotli-compressed response.
The advisory does not list affected APIs, so I tested the common ones with a harmless, valid Brotli payload: the test shows whether attacker data would reach the decoder, not the bug itself. Results were the same on patched and unpatched Node.js.
DECODES BROTLI fetch() response sends Accept-Encoding: gzip, deflate
DECODES BROTLI axios response sends Accept-Encoding: gzip, compress, deflate, br
no http.get() response no automatic decompression
DECODES BROTLI Express 5 express.json() request body HTTP 200 {"got":"brotli"}
no Express 4 express.json() request body HTTP 415 {"error":"encoding.unsupported"}- Express 5 APIs are the most exposed:
express.json()andexpress.urlencoded()(body-parser 2.x) decompressContent-Encoding: brbodies from any client, before your route code or authentication runs. - Outgoing requests matter when the server is not yours: webhooks you call, link previews, scrapers, image proxies, anything that fetches user-supplied URLs.
fetch()decoded a Brotli reply even though its request only offered gzip and deflate. - Your own zlib calls (
zlib.createBrotliDecompress,brotliDecompressSync) on uploaded or downloaded data.
Show the test script (surface.mjs)
// Which common Node.js code paths decompress Brotli data that a remote party
// controls? Uses harmless, valid Brotli data: it only shows whether the
// decoder runs, not the vulnerability itself.
import http from "node:http";
import zlib from "node:zlib";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const express5 = require("express");
const axios = require("axios");
const express4 = require("./bp1/node_modules/express");
const payload = JSON.stringify({ hello: "brotli" });
const br = zlib.brotliCompressSync(Buffer.from(payload));
const results = [];
const record = (path, decoded, note = "") => results.push({ path, decoded, note });
// 1. Outgoing requests: a server that answers with Content-Encoding: br.
let lastAcceptEncoding = "";
const evil = http.createServer((req, res) => {
lastAcceptEncoding = req.headers["accept-encoding"] ?? "";
res.writeHead(200, { "content-encoding": "br", "content-type": "application/json" });
res.end(br);
});
await new Promise((r) => evil.listen(0, r));
const url = `http://127.0.0.1:${evil.address().port}/`;
{
const text = await (await fetch(url)).text();
record("fetch() response", text === payload, `sends Accept-Encoding: ${lastAcceptEncoding}`);
}
{
const { data } = await axios.get(url);
record("axios response", data?.hello === "brotli", `sends Accept-Encoding: ${lastAcceptEncoding}`);
}
{
const body = await new Promise((resolve) =>
http.get(url, (res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c)).on("end", () => resolve(Buffer.concat(chunks)));
}),
);
record("http.get() response", body.toString() === payload, "no automatic decompression");
}
evil.close();
// 2. Incoming requests: a client that sends a Brotli-encoded JSON body.
async function serverDecodes(app) {
app.post("/", (req, res) => res.json({ got: req.body?.hello ?? null }));
app.use((err, _req, res, _next) => res.status(err.status ?? 500).json({ error: err.type ?? err.message }));
const server = await new Promise((r) => {
const s = app.listen(0, () => r(s));
});
const res = await fetch(`http://127.0.0.1:${server.address().port}/`, {
method: "POST",
headers: { "content-type": "application/json", "content-encoding": "br" },
body: br,
});
const json = await res.json();
server.close();
return { status: res.status, json };
}
{
const app = express5();
app.use(express5.json());
const r = await serverDecodes(app);
record("Express 5 express.json() request body", r.json.got === "brotli", `HTTP ${r.status} ${JSON.stringify(r.json)}`);
}
{
const app = express4();
app.use(express4.json());
const r = await serverDecodes(app);
record("Express 4 express.json() request body", r.json.got === "brotli", `HTTP ${r.status} ${JSON.stringify(r.json)}`);
}
console.log(`Node ${process.version}, express ${require("express/package.json").version}, body-parser in express 5 ${require("body-parser/package.json").version !== undefined ? require("express/node_modules/body-parser/package.json").version : "?"}`);
for (const r of results) console.log(`${r.decoded ? "DECODES BROTLI" : "no "} ${r.path.padEnd(40)} ${r.note}`);Check your runtime and code
Do not wait for Dependabot or npm audit to warn you: they check the packages in your lock file, not the Node.js runtime that runs them, so they will not raise an alert for this.
Run this with the exact node that runs your app (the one in your Dockerfile, service file or platform setting). It reports the runtime status from the fix marker and version, and, given a project folder, lists where that project decodes Brotli from outside. No dependencies.
The script and its tests are also on GitHub as AmanSin001/node-brotli-check (MIT licence). Download it with curl -O https://raw.githubusercontent.com/AmanSin001/node-brotli-check/main/node-brotli-check.mjs, or copy it from below.
#!/usr/bin/env node
// node-brotli-check.mjs: is this Node.js fixed for CVE-2026-111497 (Brotli
// decoder out-of-bounds write), and where does a project decode Brotli data?
// node node-brotli-check.mjs [project-path]
// Run it with the SAME node binary that runs your app (check `which node`,
// your Dockerfile, or your platform's runtime setting).
// Exit code: 0 = fixed, 1 = action needed.
import { existsSync, readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import zlib from "node:zlib";
const FIXED = { 22: "22.23.4", 24: "24.22.1", 26: "26.12.1" };
const parts = (v) => v.replace(/^v/, "").split(".").map(Number);
export function atLeast(version, minimum) {
const a = parts(version);
const b = parts(minimum);
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
return true;
}
/**
* The fix added a new decoder error code (-17) and Node.js exposes it as
* zlib.constants.BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH. Its presence is the
* most direct sign that the patched decoder is built in; the version number
* is the second signal.
*/
export function assessRuntime({ version, hasFixConstant, sharedBrotli }) {
const major = parts(version)[0];
const fixed = FIXED[major];
const versionOk = fixed ? atLeast(version, fixed) : false;
if (hasFixConstant) {
return {
status: "fixed",
reason: versionOk || !fixed
? `Node.js ${version} has the patched Brotli decoder.`
: `Node.js ${version} is below ${fixed} but has the patched decoder (a vendor backport).`,
};
}
if (sharedBrotli) {
return {
status: "check",
reason: `This Node.js uses the system Brotli library. Update the OS brotli package (it must include google/brotli commit 0ed4268), then Node.js itself.`,
};
}
if (!fixed) {
return {
status: "vulnerable",
reason: `Node.js ${major}.x is end of life and gets no fix. Move to 24.22.1 or 26.12.1 (or 22.23.4).`,
};
}
return {
status: "vulnerable",
reason: versionOk
? `Node.js ${version} reports a fixed version but lacks the fix marker: rebuild or reinstall from nodejs.org.`
: `Node.js ${version} is below the fixed release ${fixed}.`,
};
}
// --- project scan: where Brotli from outside gets decoded -------------------
function readJson(file) {
try {
return JSON.parse(readFileSync(file, "utf8"));
} catch {
return null;
}
}
function installedVersion(root, name) {
return readJson(path.join(root, "node_modules", name, "package.json"))?.version ?? null;
}
const SOURCE_EXT = /\.(m?[jt]sx?|cjs)$/;
export function scanProject(root) {
const findings = [];
const express = installedVersion(root, "express");
const bodyParser =
readJson(path.join(root, "node_modules", "express", "node_modules", "body-parser", "package.json"))?.version ??
installedVersion(root, "body-parser");
if (bodyParser && parts(bodyParser)[0] >= 2) {
findings.push(`body-parser ${bodyParser}${express ? ` (express ${express})` : ""}: express.json()/urlencoded() decode Brotli request bodies from any client.`);
}
const axios = installedVersion(root, "axios");
if (axios) findings.push(`axios ${axios}: asks servers for Brotli and decodes responses.`);
let usesFetch = false;
let usesZlibBrotli = false;
const walk = (dir) => {
for (const entry of existsSync(dir) ? readdirSync(dir, { withFileTypes: true }) : []) {
if (entry.name === "node_modules" || entry.name.startsWith(".") || entry.name === "dist" || entry.name === "build") continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (SOURCE_EXT.test(entry.name)) {
const text = readFileSync(full, "utf8");
if (/\bfetch\s*\(/.test(text)) usesFetch = true;
if (/createBrotliDecompress|brotliDecompress/.test(text)) usesZlibBrotli = true;
}
}
};
walk(root);
if (usesFetch) findings.push("fetch(): decodes Brotli responses even without asking for them.");
if (usesZlibBrotli) findings.push("zlib Brotli decompression called directly in your code.");
return findings;
}
// --- CLI -----------------------------------------------------------------------
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
const runtime = {
version: process.version,
hasFixConstant: zlib.constants.BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH !== undefined,
sharedBrotli: Boolean(process.config?.variables?.node_shared_brotli),
};
const result = assessRuntime(runtime);
console.log("Node.js Brotli check (CVE-2026-111497)");
console.log(`node: ${runtime.version} (bundled brotli ${process.versions.brotli}; this number does not change with the fix)`);
console.log(`fix marker: ${runtime.hasFixConstant ? "present" : "missing"}`);
console.log(`status: ${result.status.toUpperCase()}`);
console.log(`why: ${result.reason}`);
const root = process.argv[2] ? path.resolve(process.argv[2]) : null;
if (root) {
const findings = scanProject(root);
console.log("");
console.log(`Where ${path.basename(root)} decodes Brotli from outside:`);
for (const f of findings.length ? findings : ["nothing found (the runtime should still be updated)"]) console.log(` - ${f}`);
}
process.exit(result.status === "fixed" ? 0 : 1);
}# with the same node binary that runs your app
node node-brotli-check.mjs # runtime only
node node-brotli-check.mjs ./my-api # runtime + where the project decodes Brotli
echo "exit code: $?" # 0 = fixed, 1 = action needednode-v20.20.2 fix marker: missing VULNERABLE (end of life, no fix)
node-v24.22.0 fix marker: missing VULNERABLE (below 24.22.1)
node-v24.22.1 fix marker: present FIXED
node-v26.12.0 fix marker: missing VULNERABLE (below 26.12.1)
node-v26.12.1 fix marker: present FIXED
process.versions.brotli: 1.2.0 on every 24.x and 26.x build above, patched or notWhere my-api decodes Brotli from outside:
- body-parser 2.4.0 (express 5.3.0): express.json()/urlencoded() decode Brotli request bodies from any client.
- axios 1.20.0: asks servers for Brotli and decodes responses.
- fetch(): decodes Brotli responses even without asking for them.Show the tests (node-brotli-check.test.mjs, 4 passing)
// node --test node-brotli-check.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { assessRuntime, atLeast, scanProject } from "./node-brotli-check.mjs";
const status = (version, hasFixConstant, sharedBrotli = false) =>
assessRuntime({ version, hasFixConstant, sharedBrotli }).status;
test("version comparison", () => {
assert.ok(atLeast("v24.22.1", "24.22.1"));
assert.ok(atLeast("v24.23.0", "24.22.1"));
assert.ok(!atLeast("v24.22.0", "24.22.1"));
assert.ok(!atLeast("v22.9.0", "22.23.4"));
});
test("runtime status from version and fix marker", () => {
assert.equal(status("v26.12.1", true), "fixed");
assert.equal(status("v24.22.1", true), "fixed");
assert.equal(status("v22.23.4", true), "fixed");
assert.equal(status("v26.12.0", false), "vulnerable");
assert.equal(status("v24.22.0", false), "vulnerable");
assert.equal(status("v20.20.2", false), "vulnerable"); // end of life
assert.equal(status("v25.9.0", false), "vulnerable"); // end of life
assert.equal(status("v22.22.0", true), "fixed"); // vendor backport
assert.equal(status("v24.22.1", false), "vulnerable"); // odd build: version says fixed, marker missing
assert.equal(status("v24.22.0", false, true), "check"); // links system brotli
});
test("project scan finds the Brotli entry points", () => {
const root = mkdtempSync(path.join(tmpdir(), "brcheck-"));
const write = (file, content) => {
mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
writeFileSync(path.join(root, file), typeof content === "string" ? content : JSON.stringify(content));
};
write("node_modules/express/package.json", { name: "express", version: "5.3.0" });
write("node_modules/express/node_modules/body-parser/package.json", { name: "body-parser", version: "2.4.0" });
write("node_modules/axios/package.json", { name: "axios", version: "1.20.0" });
write("src/hooks.ts", "const r = await fetch(url);");
write("src/unpack.js", "zlib.createBrotliDecompress()");
const findings = scanProject(root);
assert.equal(findings.length, 4);
assert.match(findings[0], /body-parser 2\.4\.0 \(express 5\.3\.0\)/);
rmSync(root, { recursive: true });
});
test("Express 4 (body-parser 1) is not flagged for request bodies", () => {
const root = mkdtempSync(path.join(tmpdir(), "brcheck-"));
mkdirSync(path.join(root, "node_modules/body-parser"), { recursive: true });
writeFileSync(path.join(root, "node_modules/body-parser/package.json"), JSON.stringify({ name: "body-parser", version: "1.20.9" }));
assert.deepEqual(scanProject(root), []);
rmSync(root, { recursive: true });
});Update Node.js, by setup
# nvm
nvm install 24.22.1 && nvm alias default 24.22.1
# fnm
fnm install 24.22.1 && fnm default 24.22.1
# Windows
winget upgrade OpenJS.NodeJS.LTS
# Ubuntu/Debian with the NodeSource repository
sudo apt update && sudo apt install --only-upgrade nodejs
# Then restart every long-running process, for example:
pm2 restart all # or: sudo systemctl restart my-api- 1Docker. The official images were rebuilt on 21 and 22 October. Pin the fixed release, rebuild every image and redeploy; pulling alone does not change running containers.
Dockerfile # Dockerfile: pin the patched release, rebuild and redeploy FROM node:24.22.1-alpine - 2Vercel. Vercel only lets you pick the major version and rolls out patch updates itself, including for security fixes. Confirm by logging
process.versionin a function. - 3AWS Lambda. Managed runtimes receive patch updates automatically unless you switched runtime management to manual; check
process.versionin your logs. Functions on the deprecated Node.js 20 runtime get no fix. - 4Node.js 20 or older. End of life, no fix. Move to 24.22.1 (maintenance LTS, supported until April 2028) or 26.12.1, which becomes the active LTS line on 28 October 2026.
- 5Verify. Run the checker again on the deployed machine or container, not only on your laptop.
Stop-gaps for Express 5
If an Express 5 API cannot get the new Node.js today, stop Brotli request bodies from reaching the decoder. Both options were tested on unpatched Node.js 24.22.0. They do not help with fetch() or axios responses, and they are not a substitute for the update.
// Stop-gaps for Express 5 apps until the Node.js update is deployed:
// both keep Brotli request bodies away from the decoder.
import zlib from "node:zlib";
import { createRequire } from "node:module";
const express = createRequire(import.meta.url)("express");
const br = zlib.brotliCompressSync(Buffer.from(JSON.stringify({ hello: "brotli" })));
const plain = JSON.stringify({ hello: "plain" });
// Option A: body-parser's own switch. Rejects every compressed body (gzip too).
function optionA() {
const app = express();
app.use(express.json({ inflate: false }));
return app;
}
// Option B: refuse only Brotli, keep gzip and deflate working.
function rejectBrotliBodies(req, res, next) {
const encoding = String(req.headers["content-encoding"] ?? "").toLowerCase();
if (encoding.split(",").some((e) => e.trim() === "br")) {
return res.status(415).json({ error: "Brotli request bodies are not accepted" });
}
next();
}
function optionB() {
const app = express();
app.use(rejectBrotliBodies);
app.use(express.json());
return app;
}
async function probe(name, app) {
app.post("/", (req, res) => res.json({ got: req.body?.hello ?? null }));
app.use((err, _req, res, _next) => res.status(err.status ?? 500).json({ error: err.type ?? err.message }));
const server = await new Promise((r) => {
const s = app.listen(0, () => r(s));
});
const url = `http://127.0.0.1:${server.address().port}/`;
const send = async (body, encoding) => {
const headers = { "content-type": "application/json" };
if (encoding) headers["content-encoding"] = encoding;
const res = await fetch(url, { method: "POST", headers, body });
return `${res.status} ${await res.text()}`;
};
console.log(`${name}`);
console.log(` plain JSON: ${await send(plain)}`);
console.log(` brotli JSON: ${await send(br, "br")}`);
console.log(` gzip JSON: ${await send(zlib.gzipSync(plain), "gzip")}`);
server.close();
}
await probe("Option A: express.json({ inflate: false })", optionA());
await probe("Option B: rejectBrotliBodies before express.json()", optionB());Option A: express.json({ inflate: false })
plain JSON: 200 {"got":"plain"}
brotli JSON: 415 {"error":"encoding.unsupported"}
gzip JSON: 415 {"error":"encoding.unsupported"}
Option B: rejectBrotliBodies before express.json()
plain JSON: 200 {"got":"plain"}
brotli JSON: 415 {"error":"Brotli request bodies are not accepted"}
gzip JSON: 200 {"got":"plain"}Option A is body-parser's own inflate setting and rejects every compressed body; option B refuses only Brotli. Put option B before any body parser, including express.urlencoded() and express.raw().
How this site fares
codewares.in is a Next.js app on Vercel, using Vercel's default Node.js 24.x line, so Vercel applies the patch release. Its one public endpoint is the contact form API. I sent it a Brotli-compressed JSON body locally: the Next.js route handler received the raw compressed bytes and rejected them as invalid JSON (HTTP 400). It never decompressed them. The site also does not fetch user-supplied URLs.
The other nine fixes
The same releases fix nine more issues. The other high-severity ones concern TLS and DNS:
| CVE | Issue | Severity |
|---|---|---|
CVE-2026-105482 | dns.resolve truncates names containing a NUL character | High |
CVE-2026-105483 | TLS hostname check bypass when the IP is written in fullwidth digits | High |
CVE-2026-106069 | TLS hostname check bypass when the host loosely resolves to an IP address | High |
CVE-2026-105481 | Keep-alive socket can process a response written before the request was sent | Medium |
CVE-2026-106072 | Non-canonical IPv4 hostnames let DNS names in certificates authenticate IP endpoints | Medium |
CVE-2026-106074 | crypto.randomUUID() and randomInt() caches saved into startup snapshots and SEAs repeat values | Medium |
CVE-2026-106070, -106071, -106073 | ZipFile header handling and two Permission Model bypasses | Low |
One side effect to test: connections to hosts written as non-canonical IP addresses can now fail certificate checks.
The releases also update the root certificates and block the eight compromised eMudhra intermediate CAs. In tests, unpatched Node.js trusted all four of the researchers' compromised test chains; see the eMudhra CA compromise guide for a checker.
Common questions
What is Node2Shell (HTTP2Shell)?+
Which Node.js versions fix the Brotli vulnerability?+
How do I check if my Node.js has the Brotli fix?+
Does the Brotli bug affect Express apps?+
Is fetch() affected?+
Can I just disable Brotli compression on my server?+
Need your Node.js services updated?
I update Node.js runtimes, Docker images and Express APIs, and fix what the upgrade breaks. See Node.js and Next.js bug fixing or Node.js API development. Also this month: the Next.js security update.
Sources
Next steps
Keep reading
Need a hand with this?
Tell me what you are working on. Free scoping call, fixed quote within 48 hours.
Book a call