↓ Skip to main content
  1. Posts/

HTTP2Shell (CVE-2026-111497): Hunt Vulnerable Brotli Copies with Fleet

Author
Dhruv Majumdar
Two decades at the seam between detection theory and operational reality. Notes on MxDR, VMDR, threat intel, red teaming — what the vendor said versus what the host actually did.
Table of Contents
TL;DR. CVE-2026-111497 (CVSS 4.0 score 9.2) is a Brotli decoder overflow, not an HTTP/2 bug. Anything that decodes Content-Encoding: br with Brotli 1.0.3 to 1.2.0 is exposed: Node.js, Bun, Envoy, Python Brotli/brotlicffi, .NET, Electron. Check Fleet’s Software page first, then run the six queries. Download all of them as one file: http2shell-fleet-queries.sql.

What it is
#

The bug lives in c/dec/decode.c of Google’s Brotli library. When the decoder hits a block switch it cannot complete, vulnerable versions keep decoding instead of stopping. A crafted stream then writes past a small buffer into neighbouring decoder state. The upstream fix (google/brotli commit 0ed4268) makes it stop on the first format error.

Two ways in, both unauthenticated:

  • Inbound. A server decompresses a request body sent with Content-Encoding: br. Express 5 does this by default through express.json().
  • Outbound. An app fetches a Brotli response from a server the attacker controls. Node.js fetch(), Python requests/httpx/aiohttp (when Brotli or brotlicffi is installed) decode it automatically.

Imperva reported code execution on the stock Node.js 24.22.0 Linux arm64 image. Other researchers got crashes. Not in CISA KEV as of 26 October.

Vulnerability dashboard page for CVE-2026-111497 showing EPSS 32.78 percent, one affected host, two affected software entries, SSVC decision Attend with high exploitability, and MITRE ATT&CK mappings T1190 Exploit Public-Facing Application and T1499 Endpoint Denial of Service
SSVC says Attend. EPSS 32.78% four days after publication is high for a bug with no confirmed in-the-wild exploitation.

Fixed versions
#

ComponentVulnerableFixed
Google Brotli1.0.3 to 1.2.01.0.10, 1.1.1, 1.2.1
Node.js22.x, 24.x, 26.x before fix; 20.x and older, 23.x, 25.x are EOL22.23.4, 24.22.1, 26.12.1
Bun1.0.21 to 1.4.31.4.4
PyPI Brotli / brotlicffi1.0.3 to 1.2.0 / up to 1.2.0.x1.2.1 / 1.2.1.0
Envoy / IstioBrotli decompressor enabled1.40.0, 1.39.3, 1.38.6, 1.37.8 / 1.31.2, 1.30.6
Chrome, Edge, Firefox, Electronpre-148 / pre-150 / Electron 41 and older148+, 150 (ESR 140.10, 115.35), Electron 42+
Ubuntu, RHEL, SUSE, AL2023, Fedora 43 to 45 brotli; Ubuntu/RHEL nodejs; Kibana; .NETallno fix yet as of 24 October

Two traps. A patched Node.js still reports process.versions.brotli as 1.1.0 or 1.2.0, so never grade Node by its Brotli string. Distros backport without bumping the upstream number (Debian 12 is fixed in brotli 1.0.9-2+deb12u1), so grade distro packages against the vendor advisory, not the version.

Step 0: Fleet already knows most of it
#

Fleet inventories deb_packages, rpm_packages, homebrew_packages, apps, programs, python_packages and npm_packages every hour. Open Software, search brotli, node, bun, envoy. Open Vulnerabilities, search CVE-2026-111497; the match lands once NVD publishes CPE configuration for it.

Vulnerability list row for CVE-2026-111497 with status Missing, EPSS 32.8 percent, SSVC Attend, published and modified 10/22/2026, one affected host
Inventory gets you the row. The queries below get you the copies inventory cannot see.

The queries exist for what inventory misses: nvm, Volta and fnm binaries, macOS .pkg installs, bundled DLLs, wheels that vendor libbrotli, and which of those copies is actually reachable from the network.

Detect: six Fleet queries
#

Every query is in http2shell-fleet-queries.sql. Scope each to the platform named in its heading.

Why the version logic looks odd. SQLite compares version strings as text: '1.0.9' < '1.0.10' is false, '24.9.0' > '24.22.1' is true. The queries never use < or > on a version. Brotli has exactly nine vulnerable releases, so it is an IN list. Node.js and Bun are split into major, minor and patch with CAST, which keeps the leading digits ('22.1-1nodesource1' casts to 22), and compared as a number. A Node.js major newer than 26 returns REVIEW, not FIXED, so a future vulnerable line cannot pass the policy silently.

1. Linux: brotli and nodejs packages
#

WITH pk AS (
  SELECT name, version, 'deb' AS src FROM deb_packages
  WHERE name LIKE '%brotli%' OR name = 'nodejs'
  UNION ALL
  SELECT name, version || '-' || release, 'rpm' FROM rpm_packages
  WHERE name LIKE '%brotli%' OR name = 'nodejs'
),
k AS (
  SELECT *, CAST(version AS INT) AS maj,
    CAST(substr(version, instr(version,'.')+1) AS INT) AS mnr,
    CAST(substr(substr(version, instr(version,'.')+1), instr(substr(version, instr(version,'.')+1),'.')+1) AS INT) AS pat
  FROM pk
)
SELECT name, version, src,
  CASE WHEN name <> 'nodejs' THEN 'REVIEW-vs-advisory'
       WHEN maj>26 THEN 'REVIEW'
       WHEN (maj=22 AND mnr*1000+pat>=23004) OR (maj=24 AND mnr*1000+pat>=22001)
         OR (maj=26 AND mnr*1000+pat>=12001) THEN 'FIXED'
       ELSE 'VULNERABLE' END AS status
FROM k;

deb_packages.version already carries the Debian revision; rpm_packages keeps release separate, so it is joined on.

2. macOS: Homebrew and Node.js .pkg receipts
#

WITH src AS (
  SELECT name, version, path AS location FROM homebrew_packages
  WHERE name = 'brotli' OR name LIKE 'node%' OR name IN ('bun','envoy')
  UNION ALL
  SELECT package_id, version, location FROM package_receipts
  WHERE package_id LIKE 'org.nodejs.node%'
),
k AS (
  SELECT *, ltrim(version,'v') AS v FROM src
),
n AS (
  SELECT name, version, location, CAST(v AS INT) AS maj,
    CAST(substr(v, instr(v,'.')+1) AS INT) AS mnr,
    CAST(substr(substr(v, instr(v,'.')+1), instr(substr(v, instr(v,'.')+1),'.')+1) AS INT) AS pat
  FROM k
)
SELECT name, version, location,
  CASE WHEN name = 'brotli' AND version IN ('1.0.3','1.0.4','1.0.5','1.0.6','1.0.7','1.0.8','1.0.9','1.1.0','1.2.0') THEN 'VULNERABLE'
       WHEN name = 'brotli' THEN 'FIXED'
       WHEN name = 'bun' AND maj*1000000+mnr*1000+pat BETWEEN 1000021 AND 1004003 THEN 'VULNERABLE'
       WHEN name = 'bun' THEN 'FIXED'
       WHEN name = 'envoy' THEN 'REVIEW'
       WHEN maj>26 THEN 'REVIEW'
       WHEN (maj=22 AND mnr*1000+pat>=23004) OR (maj=24 AND mnr*1000+pat>=22001)
         OR (maj=26 AND mnr*1000+pat>=12001) THEN 'FIXED'
       ELSE 'VULNERABLE' END AS status
FROM n;

package_receipts catches the installer from nodejs.org (org.nodejs.node.pkg), which no package manager table sees.

3. All platforms: Python Brotli wheels, per user
#

SELECT u.username, p.name, p.version, p.path,
  CASE
    WHEN lower(p.name) = 'brotli' AND p.version IN ('1.0.3','1.0.4','1.0.5','1.0.6','1.0.7','1.0.8','1.0.9','1.1.0','1.2.0') THEN 'VULNERABLE'
    WHEN lower(p.name) = 'brotlicffi' AND substr(p.version,1,5) IN ('1.0.3','1.0.4','1.0.5','1.0.6','1.0.7','1.0.8','1.0.9','1.1.0','1.2.0') THEN 'VULNERABLE'
    WHEN lower(p.name) = 'brotlipy' THEN 'REVIEW'
    ELSE 'FIXED' END AS status
FROM users u CROSS JOIN python_packages p USING (uid)
WHERE lower(p.name) IN ('brotli','brotlicffi','brotlipy');

The CROSS JOIN users ... USING (uid) is what makes python_packages walk every user’s site-packages and venvs instead of only the system interpreter.

4. macOS/Linux: Node and Bun outside package managers
#

SELECT path, symlink_target_path, size, datetime(mtime,'unixepoch') AS modified FROM file
WHERE path IN ('/usr/bin/node','/usr/local/bin/node','/opt/homebrew/bin/node','/usr/local/bin/bun','/home/linuxbrew/.linuxbrew/bin/node')
   OR path LIKE '/Users/%/.nvm/versions/node/%/bin/node'
   OR path LIKE '/home/%/.nvm/versions/node/%/bin/node'
   OR path LIKE '/Users/%/.volta/tools/image/node/%/bin/node'
   OR path LIKE '/home/%/.volta/tools/image/node/%/bin/node'
   OR path LIKE '/Users/%/.local/share/fnm/node-versions/%/installation/bin/node'
   OR path LIKE '/home/%/.local/share/fnm/node-versions/%/installation/bin/node'
   OR path LIKE '/Users/%/.fnm/node-versions/%/installation/bin/node'
   OR path LIKE '/home/%/.fnm/node-versions/%/installation/bin/node'
   OR path LIKE '/Users/%/Library/Application Support/fnm/node-versions/%/installation/bin/node'
   OR path LIKE '/Users/%/.bun/bin/bun'
   OR path LIKE '/home/%/.bun/bin/bun';

The Node version is in the folder name for nvm, Volta and fnm. fnm defaults to ~/.local/share/fnm on macOS and Linux, falls back to ~/.fnm for old installs, and still reads ~/Library/Application Support/fnm on Macs that installed it before the switch (fnm source). A custom FNM_DIR or XDG_DATA_HOME is not covered. For /usr/local/bin/node, symlink_target_path usually points into one of those folders and gives the version for free.

5. Windows: Node.js, Bun and the .NET native DLL
#

SELECT 'program' AS src, name, version, install_location AS path FROM programs
WHERE name LIKE 'Node.js%' OR name LIKE '%Bun%'
UNION ALL
SELECT 'file', filename, product_version, path FROM file
WHERE path = 'C:\Program Files\nodejs\node.exe'
   OR path LIKE 'C:\Program Files\dotnet\shared\Microsoft.NETCore.App\%\System.IO.Compression.Native.dll'
   OR path LIKE 'C:\Users\%\.bun\bin\bun.exe'
   OR path LIKE 'C:\Users\%\AppData\Roaming\nvm\%\node.exe';

The native DLL is the right file. The managed System.IO.Compression.Brotli code is a thin wrapper that calls into System.IO.Compression.Native, which compiles in the Brotli C decoder (dotnet/runtime interop). .NET has no vendor statement yet. Inventory it now so the day Microsoft ships a fix you already know where it goes.

6. All platforms: who is actually reachable
#

WITH lp AS (
  SELECT pid, group_concat(DISTINCT port) AS ports, group_concat(DISTINCT address) AS addresses
  FROM listening_ports
  WHERE address NOT IN ('127.0.0.1','127.0.1.1','::1','') AND port > 0
  GROUP BY pid
),
hits AS (
  SELECT lp.pid, p.name, p.path, lp.ports, lp.addresses,
    (SELECT m.path FROM process_memory_map m WHERE m.pid = lp.pid AND m.path LIKE '%brotli%' LIMIT 1) AS brotli_lib
  FROM lp JOIN processes p USING (pid)
)
SELECT pid, name, path, ports, addresses, brotli_lib FROM hits
WHERE lower(name) IN ('node','node.exe','bun','bun.exe','envoy','envoy.exe','dotnet','dotnet.exe','nginx','java')
   OR brotli_lib IS NOT NULL;

Three things make this cheap enough to run fleet-wide. process_memory_map is only read for pids that are listening, never for every process. Ports are grouped first, so a process on 40 ports is one row and its memory map is read once, not 40 times. And the brotli lookup is a LIMIT 1 subquery, so it stops at the first hit. Statically linked decoders (Node, Bun) never show up as a library; that is what queries 1 to 5 are for.

Fleet live query results from 33 hosts, 85 rows: two vLLM worker python3.12 processes on host automater with brotli_lib set to pillow.libs/libbrotlicommon-53534446.so.1.2.0, plus java processes from wazuh-indexer and OpenJDK, and nginx on fleetdm-server, each with pid, listening port and address
An earlier one-row-per-port version of query 6 on 33 hosts. The only brotli library found mapped into a listening process came from a Pillow wheel, not from any package named brotli.

What it found on 33 hosts. The vLLM worker processes on one host had pillow.libs/libbrotlicommon-53534446.so.1.2.0 mapped. The Pillow wheel vendors its own libbrotli 1.2.0. No package named brotli on that host, no python_packages row for it, no Fleet inventory hit. Only the memory map shows it. The Java rows (Wazuh indexer and other JVM services) are listed for review because Netty stacks can pull in brotli4j (fixed in 1.24.0). Nginx is listed because ngx_brotli links libbrotlidec; the empty brotli_lib column says this build does not.

Policy
#

Wrap any status query so the host fails while a VULNERABLE row exists. One policy per platform, same shape:

SELECT 1 FROM os_version WHERE NOT EXISTS (
  SELECT 1 FROM ( <query 1, 2 or 3 here> ) WHERE status = 'VULNERABLE'
);

Fix
#

  • Update OS packages where a fix exists, then restart the processes still holding the old library (query 6 shows which).
  • Update owner-managed copies: nvm, fnm, Volta, Homebrew, Python lockfiles, Bun executables, container images. bun build --compile binaries must be rebuilt.
  • Until then: reject Content-Encoding: br at the WAF or ingress; in Express 5 set express.json({ inflate: false }) (also on urlencoded(), text(), raw()); remove the Brotli decompressor filter from Envoy and Istio; uninstall Brotli/brotlicffi from Python environments that only need it for outbound fetches.

Sources
#

Related

eMudhra CA Key Compromise: Find and Fix the Trust Stores Your Browser Doesn't Cover

Eight eMudhra intermediate CA keys were generated as tiny, guessable numbers, so anyone can forge a trusted certificate for any website. Browsers and operating systems blocked them within three days. Everything that keeps its own copy of the trusted roots did not: Linux CA bundles, macOS /etc/ssl/cert.pem, Node.js, Python certifi, and the CA bundle Fleet’s own agent uses to reach the Fleet server. This brief explains the blast radius in plain words and ships three Fleet queries and three Fleet scripts (macOS, Windows, Linux) that find and fix the exposure, tested on real hosts and containers.

SHADOW-EARTH-053 — Threat Brief, Kill Chain, and Validated Fleet Queries

Trend Micro disclosed SHADOW-EARTH-053 on 30 April 2026 — a China-aligned cyberespionage campaign exploiting ProxyLogon against unpatched Microsoft Exchange and IIS to deploy GODZILLA web shells and ShadowPad across South, East, and Southeast Asia plus one NATO target. This brief documents the campaign through Lockheed’s seven kill-chain stages with a Diamond Model rendered for each stage, consolidates the atomic indicators, and ships a vetted Fleet/osquery detection pack. Every query in the pack has been audited against fleetdm.com/tables before publication — schema bugs in the publicly circulating versions are called out and corrected inline.

ClickFix — Copy/Paste Social Engineering: Threat Brief and Fleet Detection Pack

ClickFix is the most active cross-platform initial-access technique of 2026 — fake CAPTCHAs and support prompts that silently copy a malicious command to the clipboard, instruct the user to paste it into the Windows Run dialog or macOS Terminal, and deliver infostealers (Lumma, AMOS), remote-access tools (NetSupport RAT), and AppleScript keychain stealers. No code-execution vulnerability is exploited — the victim is the delivery mechanism. This brief walks the five-stage attack flow, lists atomic indicators, and ships a Fleet/osquery detection pack with every query validated against the current Fleet table schema.