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
CVECVE-2026-111497
Official titleNode.js Brotli decoder out-of-bounds write permits remote code execution
Also calledNode2Shell, HTTP2Shell
Severity9.2 out of 10, Critical (CVSS 4.0, GitHub advisory); High (Node.js project rating)
Affected lines26.x, 24.x, 22.x, and every end-of-life line
Fixed in26.12.1, 24.22.1, 22.23.4 (21 October 2026)
The changeNode.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

Tested on Node.js 24 and 26: anything that decompresses data from the network can feed the decoder. The fix is the Node.js update; the other boxes only change who can reach it.

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.

Output (Node.js 26.12.0, Express 5.3.0 and 4, axios 1.20.0)
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() and express.urlencoded() (body-parser 2.x) decompress Content-Encoding: br bodies 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)
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.

node-brotli-check.mjs
#!/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);
}
Terminal
# 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 needed
Results on official Node.js builds
node-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 not
Project scan (an Express 5 API)
Where 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-brotli-check.test.mjs
// 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

Terminal
# 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
  1. 1
    Docker. 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
  2. 2
    Vercel. Vercel only lets you pick the major version and rolls out patch updates itself, including for security fixes. Confirm by logging process.version in a function.
  3. 3
    AWS Lambda. Managed runtimes receive patch updates automatically unless you switched runtime management to manual; check process.version in your logs. Functions on the deprecated Node.js 20 runtime get no fix.
  4. 4
    Node.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.
  5. 5
    Verify. 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.

mitigation.mjs
// 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());
Output (Node.js 24.22.0)
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:

CVEIssueSeverity
CVE-2026-105482dns.resolve truncates names containing a NUL characterHigh
CVE-2026-105483TLS hostname check bypass when the IP is written in fullwidth digitsHigh
CVE-2026-106069TLS hostname check bypass when the host loosely resolves to an IP addressHigh
CVE-2026-105481Keep-alive socket can process a response written before the request was sentMedium
CVE-2026-106072Non-canonical IPv4 hostnames let DNS names in certificates authenticate IP endpointsMedium
CVE-2026-106074crypto.randomUUID() and randomInt() caches saved into startup snapshots and SEAs repeat valuesMedium
CVE-2026-106070, -106071, -106073ZipFile header handling and two Permission Model bypassesLow

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)?+
Node2Shell, also called HTTP2Shell, is the nickname for CVE-2026-111497: an out-of-bounds write in the Brotli decoder that can lead to remote code execution. It is scored 9.2 out of 10 (Critical, CVSS 4.0). Node.js fixed it in 26.12.1, 24.22.1 and 22.23.4.
Which Node.js versions fix the Brotli vulnerability?+
Node.js 26.12.1, 24.22.1 and 22.23.4, released on 21 October 2026. End-of-life lines, including Node.js 20, get no fix.
How do I check if my Node.js has the Brotli fix?+
Run node -p "require('zlib').constants.BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH". Patched builds print -17; unpatched builds print undefined. The checker in this guide does this and also scans your project.
Does the Brotli bug affect Express apps?+
Express 5's express.json() and express.urlencoded() decompress Brotli request bodies from any client, so an Express 5 API on an unpatched Node.js is reachable without authentication. Express 4 rejected Brotli bodies in tests.
Is fetch() affected?+
fetch() decompresses Brotli responses automatically, even if the request did not offer br. If your code fetches URLs from servers you do not control, update Node.js.
Can I just disable Brotli compression on my server?+
Turning off Brotli compression for your own responses does not help: the risk is in decompressing incoming data. Use the stop-gaps above for request bodies and update Node.js.

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

← All guides