Node2Shell and Python2Shell: One Brotli Bug, Two Ecosystems

-Sebastian Tschöpel

On 21 October 2026 Node.js published a security release with ten fixes. One of them, CVE-2026-111497, is an out-of-bounds write in Google’s Brotli decoder, which Node bundles into its own binary. Node.js says the flaw permits remote code execution.

The two Brotli packages on PyPI shipped patched builds on 22 October, and by the next day the Python side had its own name. Node2Shell, Python2Shell and the later HTTP2Shell all point to the same CVE.

The two exposures

Node.js Python
CVE CVE-2026-111497 CVE-2026-111497
Vulnerable artifact Brotli decoder bundled in 22.x, 24.x and 26.x before the fix, and in every end-of-life line PyPI Brotli below 1.2.1, brotlicffi below 1.2.1.0
Fixed in 22.23.4, 24.22.1, 26.12.1 Brotli 1.2.1, brotlicffi 1.2.1.0
Severity High in the Node.js advisory; CVSS v4.0 9.2 (critical) in Google’s CVE record Same CVE record, tracked for PyPI as PYSEC-2026-4322 and PYSEC-2026-4323
Exploited in the wild None on record as of 23 October None on record as of 23 October

Inside the flaw

A Brotli stream is built from blocks and can switch between block types as it goes. The decoder in older versions treated an invalid block switch as “the input ended early, send more bytes” instead of as a format error. It kept the stream open and appended new data to an internal buffer that holds eight bytes.

Enough malformed input overflows that buffer and corrupts adjacent memory. The outcome then depends on heap layout: a crash, a leak of process memory, or control over the instruction pointer that turns into code execution. The CVE record lists two weaknesses, an unchecked return value (CWE-252) and an out-of-bounds write (CWE-787). Node.js credits OpenAI Codex Security, Khai Tran and Sylvie Mayer with the report.

Google committed the fix to the Brotli repository on 27 February 2026. The commit message called it earlier detection of invalid streams and made no mention of security. The patched releases 1.0.10, 1.1.1 and 1.2.1 appeared on 21 October, nearly eight months later. Google’s CVE record scores the flaw 9.2 under CVSS v4.0; Node.js rates its own advisory high. The affected range starts at 1.0.3, which dates to early 2018, so the flaw shipped for more than eight years.

Node2Shell: three ways in

Brotli data reaches a Node process through request bodies, through responses it fetches, and through direct calls to the library.

Request bodies are the obvious way in. Express 5 ships body-parser 2, and its parsers (express.json(), express.raw() and the others) decompress bodies sent with Content-Encoding: br by default. Any client can hand the decoder arbitrary bytes without an account. Express 4 uses body-parser 1.x, which rejects Brotli bodies with a 415, and a bare node:http server does not decode request bodies on its own.

Responses get less attention. fetch() decompresses Brotli automatically and picks the decoder from the response header, whatever your request advertised. A reply from a server the attacker controls reaches the decoder before any parsing code of yours runs. Webhook deliveries, URL previews, image proxies and every “fetch this link for me” feature carry the exposure.

The third path is a direct call to node:zlib, in your code or in a dependency. A grep finds those in a minute.

Imperva’s red team reproduced code execution in fresh containers running the official Node.js 24.22.0 image on Linux arm64, through a response consumed by fetch() and through request bodies parsed by Express 5 with body-parser 2. The exploit took several attempts, 24.22.1 rejected the same input, and Imperva has not released the code.

Public repositories appeared within hours of the release. The published proofs of concept crash unpatched Node.js; no public code has achieved remote execution so far. CISA’s assessment in the CVE record listed exploitation as none on 23 October, and the CVE is not in CISA’s Known Exploited Vulnerabilities catalog.

The fixed releases are 22.23.4, 24.22.1 and 26.12.1. Bun bundles the same decoder and shipped its fix in 1.4.4. Node.js 25.x, 23.x, 20.x and older are end of life and will not receive one.

An image pinned to node:24.22.0 stays vulnerable however often you rebuild. Move the pin first.

Python2Shell: the same flaw, a day later

No CPython interpreter is vulnerable here. Google’s Brotli binding and the CFFI alternative brotlicffi compile the same C decoder into an extension module. Both projects uploaded patched builds to PyPI on 22 October: Brotli 1.2.1 and brotlicffi 1.2.1.0. The brotlicffi changelog names the CVE, and so do Google’s release notes for 1.2.1. The Brotli project page on PyPI does not, so a glance at PyPI tells you nothing.

Exposure follows the HTTP clients. When Brotli or brotlicffi is installed, urllib3 advertises Brotli support and decodes what comes back; requests inherits that through urllib3. httpx with the brotli extra, aiohttp and Scrapy behave the same way. Your code never has to import the package. urllib3 also decodes a Brotli response when the request sent Accept-Encoding: identity, because it reads the response header. A Python service that fetches pages, images or webhooks from servers you do not control passes attacker data to the decoder.

For about a day neither package appeared in the advisory databases that scanners read, so a clean report proved nothing. On 23 October GitHub added both packages to GHSA-9v8h-qqgm-4m35, and PYSEC-2026-4322 (Brotli) and PYSEC-2026-4323 (brotlicffi) followed. pip-audit now flags vulnerable versions.

The upgrade is pip install -U "Brotli>=1.2.1" "brotlicffi>=1.2.1.0", followed by a rebuild of every image or wheel that bundles them. If you use Poetry, uv or pip-tools, raise the constraint and regenerate the lockfile.

Where shipping today is impossible, uninstalling both packages removes the vulnerable decoder. The HTTP clients keep gzip and deflate, but a server that only answers in Brotli will return a body your code cannot read. Look for extras such as httpx[brotli] that would reinstall the package on the next build.

Python from a distribution package may take the fix as a system libbrotli update rather than a pip release. Check what the interpreter links before you decide you are done.

The same decoder elsewhere

Every product that carries its own copy of Brotli has to ship its own fix. Bun did so in 1.4.4. Envoy fixed it in 1.40.0, 1.39.3, 1.38.6 and 1.37.8, and Istio in 1.29.9, 1.30.6 and 1.31.2. curl builds linked against Brotli decode it when you pass --compressed, and Git builds whose libcurl supports Brotli ask for it on HTTPS fetches. Git for Windows 2.56.0.windows.3 still bundled Brotli 1.2.0 on 24 October.

On Debian, Node.js and Python packages from the distribution link the system Brotli library, so the fix arrives through the brotli package: 1.0.9-2+deb12u1 for bookworm and 1.1.0-2+deb13u1 for trixie.

Why the scanners stayed quiet

The bug sits in a native library below your application. Static analysis reads your source, and dependency scanners read manifests and lockfiles. Neither sees a decoder compiled into a Node binary or a Python wheel.

An ordinary request reaches it. With the body parser mounted globally, as it usually is, the vulnerable code runs before your route handler and your authentication middleware.

The remediation is a version bump plus a restart or a rebuild. Application code does not change, which makes the fix look easy until you have to prove that the process answering traffic runs the new version.

Google committed the change in February and published releases in October. A commit in a repository does nothing for the binaries already deployed, and no advisory pointed at it for eight months.

The ten-minute check

Read the version from the binary that runs in production. A manifest only tells you what someone meant to deploy.

Terminal window
node --version
node -p "require('zlib').constants.BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH"

Compare the first line with 22.23.4, 24.22.1 and 26.12.1. The second line prints -17 on a fixed build and undefined on a vulnerable one, because the fix added that error code.

Skip process.versions.brotli for this. Node.js cherry-picked the upstream commit without bumping the bundled version, so a patched 22.23.4 still reports 1.1.0 and a patched 24.22.1 still reports 1.2.0. The string looks the same before and after.

In containers, run the same commands against the image you deploy:

Terminal window
docker run --rm --entrypoint node <image> --version
docker run --rm --entrypoint node <image> -p "require('zlib').constants.BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH"

Distribution-packaged Node.js may link the system Brotli library. Then the decoder fix arrives with the distribution’s brotli package, and the Node version number tells you nothing about it:

Terminal window
ldd "$(command -v node)" | grep -i brotli

For the request-body path:

Terminal window
npm ls express body-parser
grep -rnE "express\.json|express\.raw|bodyParser|brotliDecompress|createBrotliDecompress" --include=*.js --include=*.mjs --include=*.ts .

For Python, ask the interpreter that serves production traffic:

Terminal window
python -c "import brotli; print(brotli.__version__)"
python -c "import brotlicffi; print(brotlicffi.__version__)"
pip show Brotli brotlicffi
pipdeptree -r -p Brotli
pip-audit

An ImportError on either import means that package is not installed in that environment. pipdeptree shows which dependency pulled Brotli in.

If the check turns up a host you cannot patch this week, or you cannot say which runtime a service runs, book a free 30-minute call. We go through the results with you and sort the hosts by exposure.

What DevOps should do this week

Patch in the order of exposure. Start with internet-facing services that decode Brotli from untrusted sources, then API gateways and reverse proxies that terminate compression, then anything that fetches URLs on behalf of users. Internal batch jobs come last.

Move image tags and rebuild. A rebuild that reuses a cached layer with the old runtime changes nothing, so verify the version inside the new image before it reaches a registry.

Restart the process. A patched package on the host does not update a container that started last week, and Node keeps the vulnerable decoder in memory until the process exits.

Confirm the running version on the deployed instance, not on your laptop, using the same one-liner.

When the upgrade cannot land today, reduce exposure while you plan it. Reject Content-Encoding: br at the edge, matching br case-insensitively and inside lists such as gzip, br. In Express, express.json({ inflate: false }) refuses compressed bodies until you upgrade; it rejects gzip bodies too, so test your clients first. Several WAF and hosting providers shipped rules for this CVE, and they cover inbound traffic only.

Outbound calls need a different fix. Removing br from Accept-Encoding does not help, because fetch(), urllib3 and curl decode whatever the response header announces. Route outbound traffic through an egress proxy that rejects responses with Content-Encoding: br, or turn decoding off where the client allows it, as urllib3 does with decode_content=False. For Python, uninstalling the two packages is faster than a proxy change and costs some bandwidth. Each of these buys a few days; the upgrade still has to follow.

What SecOps should do this week

Control the edge first. A rule on Content-Encoding: br costs one config line and holds until the runtime upgrade lands. Test it against legitimate clients before you switch it to blocking mode, because some SDKs and mobile apps send Brotli bodies.

Restrict egress from application servers. A compromised process that cannot download a second stage or open a tunnel is worth far less to an attacker. An allowlist breaks hobby projects; it fits most production services.

Harden the process, since exploitation lands inside a web worker or a Python interpreter. Run containers as non-root with a read-only root filesystem, drop Linux capabilities, apply a seccomp profile and leave the shell out of the image. None of this stops the memory corruption, but it raises the cost of every step after it.

Then watch for behaviour that follows a successful hit:

Signal Where to look Why it matters
node or python spawning sh, curl, wget, nc or base64 EDR, auditd First move after code execution
Worker exits or restarts during decompression Container logs, supervisor The decoder aborting on malformed input
New outbound connections from app servers Firewall, flow logs Payload download, command and control, tunnelling
Executables in /tmp, new listening ports, new cron entries EDR, file integrity monitoring Persistence
Sudden interest in metadata endpoints or internal hosts Egress logs, DNS Credential theft from the host

No single signal proves exploitation. Correlate them with the deployed runtime version and with the source of the compressed traffic.

Set deadlines by exposure first and by score second:

Situation Deadline
Internet-facing, decodes Brotli from untrusted sources 72 hours
Internet-facing, Brotli reachable only through trusted upstreams 7 days
Internal, untrusted input reaches the decoder 14 days
Internal, no untrusted input, compensating controls in place Next maintenance window
End-of-life runtime Isolate now, migration date in writing

One more rule for the week: do not run binaries that arrive with a vulnerability name attached. Fake scanners and proof-of-concept archives spread alongside every high-profile flaw. Fetch tooling from the Node.js release page, PyPI or the project’s own repository.

What the GDPR adds

If attacker code runs inside a process that handles personal data, treat the incident as a possible personal data breach. Article 33 GDPR requires you to notify your supervisory authority without undue delay and, where feasible, within 72 hours of becoming aware of a breach, unless it is unlikely to put people’s rights and freedoms at risk. Article 32 asks for security measures appropriate to the risk, taking the state of the art into account. Once a fix for a remote code execution flaw is public, running the old build gets hard to defend.

Write down when you patched each system and why any of them waited. That record answers the first question your data protection officer or an auditor will ask. Your DPO makes the legal call; this section is not legal advice.

What continuous monitoring has to cover

A quarterly scan answers the question “were we vulnerable in March”. This flaw needed an answer the same week, and the fix had been in the upstream repository since February.

Continuous monitoring rests on four pieces. Inventory has to cover images, hosts and CI runners, because a lockfile does not tell you which runtime answers traffic. Exposure mapping records which services accept or fetch compressed data from outside. Verification after every deploy turns the running version into a fact. Drift detection on base image tags catches the pin that nobody updated.

Someone also has to read the source advisories. Node.js publishes to the nodejs-sec mailing list, and Python packages land in PYSEC and OSV, sometimes a day after the release. If your only input is a scanner dashboard, you will hear about the next one late.

What we handle

We keep runtimes under watch for our clients. Our support contracts cover patch management for your stack, Node.js, Python and PHP included, monitoring for the services that depend on them, and restore checks on the backups. A written procedure covers the day a release like this one lands. Every patch gets a date and a reason in the log, and that log is what your GDPR documentation needs.

Go, Java, Ruby and .NET services get the same three steps: inventory what answers traffic, compare it with the fixed release, then prove the fix on the running process.

If you are not sure which of your servers run an affected build, book a free 30-minute call. We go through the runtimes on your servers with you, whatever language they are written in, tell you which ones are exposed and what to patch first. You do not need to send us an inventory in advance; a rough count of servers and services is enough to start. More on how we work is on our services page.

Is your runtime patched?

Free 30-minute patch check for your servers, in any language. We reply within one business day.

Sources

Frequently Asked Questions

Yes. All three names describe CVE-2026-111497, an out-of-bounds write in Google's Brotli decoder. Node.js bundles the decoder in its binary. The PyPI packages Brotli and brotlicffi compile the same C source into a Python extension. Only the delivery path differs.

No exploitation was on record as of 23 October 2026. CISA's assessment in the CVE record lists exploitation as none, and the CVE is not in CISA's Known Exploited Vulnerabilities catalog. Imperva reproduced code execution against the official Node.js 24.22.0 image on Linux arm64. Public proof-of-concept code crashes unpatched Node.js; claims of remote code execution in public code are disputed. Patch as if an exploit were close.

Brotli below 1.2.1 and brotlicffi below 1.2.1.0. On PyPI the fixed releases are Brotli 1.2.1 and brotlicffi 1.2.1.0, both uploaded on 22 October 2026. Any HTTP client that loads them decodes Brotli responses, including urllib3, requests, httpx with the brotli extra, aiohttp and Scrapy. pip-audit flags vulnerable versions through PYSEC-2026-4322 and PYSEC-2026-4323.

No. Rejecting Content-Encoding: br at the edge closes the inbound path and buys time. Removing br from Accept-Encoding does not protect outbound calls, because fetch(), urllib3 and curl decode whatever the response header announces. Only a runtime or library upgrade removes the flaw.

Check what your middleware accepts. In Express 5, express.json(), express.raw() and the other body-parser 2 parsers decompress Brotli request bodies by default. Express 4 uses body-parser 1.x, which rejects Brotli bodies with a 415 error. fetch() decompresses Brotli responses in every case. Serving gzip does not mean the decoder is unreachable.

Run node --version and compare it with 22.23.4, 24.22.1 and 26.12.1. As a second check, node -p "require('zlib').constants.BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH" prints -17 on a fixed build and undefined on a vulnerable one. Do not rely on process.versions.brotli: Node.js cherry-picked the fix without changing the bundled Brotli version string.

Node.jsPythonBrotliHTTP2ShellRemote Code ExecutionPatch ManagementDevOpsSecOpsGDPRSupply ChainServer Security