top of page

HTTP2Shell Brotli Flaw: Find Node, Bun and Python Installs

Writer: Inception Security
Inception Security
13 hours ago
8 min read

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

 

 

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.

 

bg-map-white.png

INCEPTION SECURITY™

A cybersecurity company with in depth knowledge of the threat landscape and security controls.

NAVIGATION

GET IN TOUCH

© 2025 All Rights Reserved by INCEPTION SECURITY™ .

bottom of page