HTTP2Shell Brotli Flaw: Find Node, Bun and Python Installs


What this means for your clients: a common piece of code that unpacks compressed web traffic has a serious flaw, and it hides inside apps and scripts that normal update checks do not see, so someone has to go looking for it.
Picture a client's customer portal, built two years ago by a contractor and running on Node.js inside a container nobody has rebuilt since. This week the compression code inside it became a real risk, and your patch dashboard may well miss it.
The flaw is CVE-2026-111497, first called Node2Shell and then HTTP2Shell, Aikido's later name once it reached well past Node.js. It is an out-of-bounds write in the Google Brotli decoder, versions 1.0.3 through 1.2.0, credited by the Node.js project to Khai Tran and Sylvie Mayer of OpenAI Codex Security.
How the Brotli flaw works
Brotli is a compression format for web traffic, marked with the header Content-Encoding: br. Per the Node.js advisory, the decoder treated one kind of invalid input as a request for more data instead of an error, so it kept appending bytes into an eight byte internal buffer and wrote past its end.
The CVE record says a remote attacker with no login can use a crafted stream to corrupt memory, leak memory contents, crash the process, or possibly run code. It scores the flaw 9.2 under CVSS 4.0. Node.js rates it high.
Aikido, citing OpenAI's report, says it cuts both ways. A malicious client can send a compressed request body to a server, and a malicious server can send a compressed response to a client that fetches it. Vercel notes its firewall rules only cover inbound traffic, so only the patch protects fetched responses.
Code execution is the part to hold loosely. Imperva reported running code against the official Node.js 24.22.0 image on Linux arm64 through Express and body-parser, and it notes the proof did not succeed every time. CISA KEV does not list the CVE as of October 23, and we found no confirmed attacks.
Why MSPs carry this one
Brotli is bundled inside runtimes, packages and container images, so every vendor ships its own fix and every copy has to be found. Node.js 20 and older lines are end of life and get no fix at all, so those apps need a runtime upgrade.
Bun is affected through 1.4.3, and its own issue tracker notes that Bun's fetch decodes Brotli responses by default. Python is the quiet one. Aikido says requests, httpx and aiohttp load the brotli or brotlicffi packages automatically when they are installed, so an ordinary script that calls an API can carry the flaw without anyone choosing it.
Not everything is exposed. Aikido says Apache and Nginx do not decode Brotli by default. Aikido also lists fixed releases for Envoy and Istio. We have not confirmed these with the projects.
The scanner gap
Aikido warned that advisory databases did not map the CVE to either PyPI package, which meant pip-audit, Dependabot and Safety would pass a vulnerable Python install without comment. GitHub's advisory GHSA-9v8h-qqgm-4m35 now covers both brotli and brotlicffi, but a clean scan on a stale database proves nothing. Rescan after the tools update, and check the installed version yourself on anything that matters.
Containers widen the gap. A host can be patched while an image built last spring still ships an old runtime, and a scan that only looks at the host will not open it.
What to change this week
Fixed versions:
Brotli library: 1.2.1, 1.1.1 or 1.0.10
Node.js: 22.23.4, 24.22.1 or 26.12.1 (released October 21)
Bun: 1.4.4
Python brotli 1.2.1 and brotlicffi 1.2.1.0
Start with internet-facing Node.js apps that parse request bodies, because that is the path Imperva demonstrated and the one a stranger can reach without help. Move anything on Node.js 20 to a supported line in the same change window.
Then rebuild container images rather than patching hosts alone, and restart services so they load the new code. If a portal cannot be upgraded today, blocking inbound requests that carry Content-Encoding: br at the proxy reduces exposure, though it does nothing for responses the app fetches.
Hunts to run (test first)
These threat hunting queries find exposure, not proof of compromise. They are hand checked but not run on a live instance, and the field names are assumptions in every language, so map them to your own logs. Each comes in Splunk, Elastic ES|QL, CrowdStrike Falcon LogScale and Microsoft KQL. The table under the hunts, "Interpret before you escalate", says what is benign and what is not.
Test query. Confirm process telemetry is searchable before trusting an empty hunt.
Splunk
index=* sourcetype=*sysmon* EventCode=1 earliest=-24h
| stats count by host
Elastic
FROM logs-endpoint.events.process-*
| WHERE @timestamp > NOW() - 24 hours
| STATS count = COUNT(*) BY host.name
CrowdStrike
// Set the time picker to the last 24 hours
#event_simpleName=ProcessRollup2
| groupBy([ComputerName])
KQL
DeviceProcessEvents
| where Timestamp > ago(24h)
| summarize count() by DeviceName
Search 1 lists hosts with recorded Node.js process starts in the last seven days, with path and command line, so you can compare versions against the fixed list. A long running service started before that window may be missing. Process events rarely record the runtime version, apart from the Windows file version the KQL pulls, so treat hits as a list of hosts to check with node --version. Editors and desktop apps that embed Node add noise; filter them by path. A quiet result on a server you know runs Node points at a telemetry gap.
Splunk
index=* sourcetype=*sysmon* EventCode=1 (Image="*\\node.exe" OR Image="*/node") earliest=-7d
| stats count, values(CommandLine) as cmd by host, Image
Elastic
FROM logs-endpoint.events.process-*
| WHERE @timestamp > NOW() - 7 days AND process.name IN ("node", "node.exe")
| STATS count = COUNT(*), cmd = VALUES(process.command_line) BY host.name, process.executable
CrowdStrike
// Set the time picker to the last 7 days
#event_simpleName=ProcessRollup2
| ImageFileName=/[\\\/]node(\.exe)?$/i
| groupBy([ComputerName, ImageFileName], function=[count(), collect(CommandLine)])
KQL
DeviceProcessEvents
| where Timestamp > ago(7d)
| where FileName in~ ("node", "node.exe")
| summarize count(), make_set(ProcessCommandLine, 20) by DeviceName, FolderPath, ProcessVersionInfoProductVersion
Search 2 looks for Bun, which tends to arrive with newer projects and single file executables that nobody inventories. A compiled Bun app may not be named bun at all, so a miss here only clears the obvious installs, and any hit should be checked with bun --version against 1.4.4.
Splunk
index=* sourcetype=*sysmon* EventCode=1 (Image="*\\bun.exe" OR Image="*/bun") earliest=-7d
| stats count by host, Image, CommandLine
Elastic
FROM logs-endpoint.events.process-*
| WHERE @timestamp > NOW() - 7 days AND process.name IN ("bun", "bun.exe")
| STATS count = COUNT(*) BY host.name, process.executable, process.command_line
CrowdStrike
// Set the time picker to the last 7 days
#event_simpleName=ProcessRollup2
| ImageFileName=/[\\\/]bun(\.exe)?$/i
| groupBy([ComputerName, ImageFileName, CommandLine])
KQL
DeviceProcessEvents
| where Timestamp > ago(7d)
| where FileName in~ ("bun", "bun.exe")
| summarize count() by DeviceName, FolderPath, ProcessCommandLine
Search 3 targets the scanner gap directly by watching for the package metadata folders that pip writes, such as brotli-1.1.0.dist-info. The version sits in the folder name, so this is the closest any hunt here gets to telling vulnerable from fixed, though a folder written months ago does not prove the package is still installed or which environment it lives in. The PyPI project is named Brotli with a capital B, and some installs keep that spelling in the folder name, so all four versions of this search ignore case. It depends on file creation logging reaching site-packages, which many EDR policies trim, so older installs that predate your retention window will not appear. Defender Advanced Hunting only keeps 30 days, which is why the KQL looks back that far.
Splunk
index=* sourcetype=*sysmon* EventCode=11 (TargetFilename="*brotli-*.dist-info*" OR TargetFilename="*brotlicffi-*.dist-info*") earliest=-90d
| rex field=TargetFilename "(?i)(?<pkg>brotli(cffi)?-[0-9.]+)\.dist-info"
| eval pkg=lower(pkg)
| stats latest(_time) as last_seen, values(TargetFilename) as paths by host, pkg
Elastic
FROM logs-endpoint.events.file-*
| WHERE @timestamp > NOW() - 90 days AND TO_LOWER(file.path) LIKE "*brotli*dist-info*"
| EVAL lpath = TO_LOWER(file.path)
| GROK lpath "%{DATA}(?<pkg>brotli(cffi)?-[0-9.]+)[.]dist-info%{GREEDYDATA}"
| STATS last_seen = MAX(@timestamp), paths = VALUES(file.path) BY host.name, pkg
CrowdStrike
// Set the time picker to the last 90 days
#event_simpleName=/^(NewFileWritten|DirectoryCreate)$/
| TargetFileName=/brotli(cffi)?-[0-9.]+\.dist-info/i
| regex("(?<pkg>brotli(cffi)?-[0-9.]+)[.]dist-info", field=TargetFileName, flags="i")
| groupBy([ComputerName, pkg], function=[max(@timestamp, as=last_seen), collect(TargetFileName)])
KQL
DeviceFileEvents
| where Timestamp > ago(30d)
| where FolderPath matches regex @"(?i)brotli(cffi)?-[0-9.]+\.dist-info"
| extend pkg = tolower(extract(@"(?i)(brotli(cffi)?-[0-9.]+)\.dist-info", 1, FolderPath))
| summarize last_seen = max(Timestamp), paths = make_set(FolderPath, 20) by DeviceName, pkg
Search 4 turns to the network and counts inbound requests that declare Content-Encoding: br. Browsers rarely compress what they send, so a burst of them from one source against a Node app is worth a look. The header field depends entirely on whether your proxy or load balancer logs request headers, which many do not by default, so an empty result often means the field is missing rather than the traffic. The KQL version is a template. Application Gateway does not log request headers on its own, so it only works once a WAF, proxy or custom log writes the request headers into a table, and you will need to swap in your own table and column names.
Splunk
index=* (sourcetype=*proxy* OR sourcetype=*access*) http_content_encoding=* earliest=-7d
| regex http_content_encoding="(?i)(^|,)\s*br\s*(,|$)"
| stats count, dc(uri_path) as paths by src_ip, dest
| sort - count
Elastic
FROM logs-*
| WHERE @timestamp > NOW() - 7 days AND TO_LOWER(http.request.headers.content_encoding) RLIKE "(.*,)?[ \t]*br[ \t]*(,.*)?"
| STATS count = COUNT(*), paths = COUNT_DISTINCT(url.path) BY source.ip, destination.domain
| SORT count DESC
CrowdStrike
// Set the time picker to the last 7 days
http.request.headers.content_encoding=/(^|,)\s*br\s*(,|$)/i
| groupBy([source.ip, destination.domain], function=[count(as=count), count(field=url.path, distinct=true, as=paths)])
| sort(count, order=desc)
KQL
AzureDiagnostics
| where TimeGenerated > ago(7d)
| where requestHeaders_s matches regex @"(?im)^content-encoding:[ \t]*(?:[^,\r\n]*,[ \t]*)*br[ \t]*(?:,|\r?$)"
| summarize count(), dcount(requestUri_s) by clientIP_s, host_s
| order by count_ desc
Interpret before you escalate
Finding | What it likely means | Next step |
Node.js 20 or older, or 22, 24 or 26 below that branch's fixed release | Exposed runtime | Upgrade, rebuild the image, restart |
Bun below 1.4.4 | Exposed runtime | Upgrade and rebuild compiled apps |
brotli 1.0.3 to 1.2.0, or brotlicffi 1.0.3 below 1.2.1.0 | Possible exposed Python install, metadata may be stale | Confirm the package and environment, upgrade and rebuild |
Bursts of Content-Encoding: br requests at a Node app | Unusual traffic, possibly probing | Check the app's version and crash logs |
Node process crash after a br request | Possible exploitation attempt | Isolate, patch, and review the host |
No results anywhere | No matches, or a logging gap | Run the test query and check fields first |
What to alert on
Search 4 is the only hunt worth a standing alert, and only after you know your baseline. Outside these searches, a Node.js service that crashes or restarts shortly after receiving Brotli encoded requests deserves a page, because a failed exploitation attempt may crash the service. The three inventory searches belong on a weekly report until the last vulnerable copy is gone.
Sources
CVE record: https://www.cve.org/CVERecord?id=CVE-2026-111497
Node.js October 2026 security releases: https://nodejs.org/en/blog/vulnerability/october-2026-security-releases
GitHub advisory GHSA-9v8h-qqgm-4m35: https://github.com/advisories/GHSA-9v8h-qqgm-4m35
Aikido: https://www.aikido.dev/blog/python-packages-vulnerable-node2shell-brotli-cve-2026-111497
Vercel bulletin: https://vercel.com/kb/bulletin/node-js-security-advisory
Imperva: https://www.imperva.com/blog/node2shell-from-brotli-data-to-remote-code-execution/
Bun issue 46352: https://github.com/oven-sh/bun/issues/46352
CISA KEV catalog: https://www.cisa.gov/known-exploited-vulnerabilities-catalog
What This Means for Your Team
Run the hunts before you book the change window, or you will patch the three apps you remember and miss the one the contractor left behind. Then patch the internet-facing Node apps first and rebuild every image the hunts turned up.
Inception Protection
Inception Protection is the Inception Security managed detection and response service, and it works with the Microsoft licenses you already have. We watch Defender endpoint and sign-in data in your Microsoft environment, including process activity on servers that run Node.js or Python, and work through containment with your team. Application upgrades and image rebuilds stay with your team.
Inception Foresight
A flaw like this often surfaces first as a crashed service or an odd process on a server nobody watches closely. To find out whether your Microsoft environment would catch an unexpected process launched by a Node.js or Python service, try the free Inception Foresight M365 Assessment: https://www.inceptionsecurity.com/m365assessment.



