Mastodon
Go back

Node2Shell (CVE-2026-111497): how to find out if you're exposed

Node2WAT?

This was a bad week for cyber on the internet. Along with the slightly delayed PHPocalypse (there are some serious, um, exaggerations by some folks I once thought better of — that was me being way too kind — on how generalizable an exploit can be for it, and gosh is this a super noisy attack), we have an impending wave of attacks trying to take advantage of a flaw in a compression library most folks have never heard of.

Brotli is a compression format used on the web (and many other places). Node.js (this is why you’re here, so no ‘splainer) ships its own copy of the Brotli decoder. That decoder has a memory bug: a malformed compressed stream can write past the end of a buffer. The worst case scenario is remote code execution; a more realistic case is a crash or leaked process memory.

To reiterate: the flaw is in the runtime’s compression library, not in your application code. Updating npm packages, Express, or body-parser does not fix it. Only the Node.js version (or the Brotli library, if something else provides it) fixes it.

The CVE (weakness) is in the upstream Google Brotli library (1.0.3 through 1.2.0), so anything that bundles or links that decoder is affected, not just Node.js. That wider set is sometimes called HTTP2Shell.

In terms of severity, the Node.js project rates it High (👋); the CVE record scores it at 9.2 (Critical, CVSS 4.0). Public proof of concept exists, with no exploitation in the wild had been confirmed as of 24 October 2026.

Fixed versions

What you runFixed in
Node.js 22.x22.23.4
Node.js 24.x24.22.1
Node.js 26.x26.12.1
Node.js 25, 23, 20 and olderNo fix. End of life. Move to 24 or 26.
Bun1.4.4
Google Brotli (C / system library)1.0.10, 1.1.1, or 1.2.1
Python Brotli1.2.1
Python brotlicffi1.2.1.0
Envoy1.37.8, 1.38.6, 1.39.3, 1.40.0
Istio1.29.9, 1.30.6, 1.31.2

The Node.js rows come from the Node.js security release and GHSA-9v8h-qqgm-4m35; every other row is that project’s own released fix (i.e., check it against the project’s advisory before you trust the number here). Distro packages are the exception in both directions: packagers backport the fix without changing the version string, so a package that looks vulnerable may already be patched.

The only reliable question to ask is “which version actually runs?”

This is not (necessarily) the version on your laptop (though you should update that too). And, you might not want to trust what’s in a given package.json in a JS project. The binary that is executing right now in any given environment holds the truth.

Some commands to run in servers and VMs to see if Node is lurking:

node --version
which -a node # ids every node on PATH
bun --version 2>/dev/null
ps -eo pid,command | grep -E '[n]ode|[b]un' # ids what is actually running

If you upgrade a binary: great! That does not, however, help a process that is still running the old one (i.e., restart the service after upgrading).

We should check containers as well:

docker ps --format '{{.ID}} {{.Image}}'
docker exec <container> node --version
docker run --rm --entrypoint sh <image> -c 'node -v' # poke at stored images not necessarily currently running

For containers, a docker pull may not be enough. It an image was built on a base image with the old runtime you should rebuild it on a fixed base and redeploy. Here’s a one-liner to check across running containers:

docker ps -q | while read -r c; do
  printf '%s ' "$(docker inspect -f '{{.Config.Image}}' "$c")"
  docker exec "$c" node -v 2>/dev/null || echo 'no node'
done

If Podman is installed instead of Docker (which is what you should really be doing) use the same commands with sudo podman.

For CI, build machines, you should check their configurations as well. Examine the workflow’s node-version, the engines field, .nvmrc.

For PaaS environemnts, check the platform setting (Vercel, Netlify, Render, Fly.io, Heroku), then redeploy. Hopefully they did this for you or at least WAF’d you into temporary safety.

Python, curl, git, proxies

This Brotli debacle unfortunately goes beyond Node:

pip show Brotli brotlicffi 2>/dev/null | grep -Ei '^(Name|Version)'
curl --version | grep -i brotli
dpkg -l | grep -i brotli # or: rpm -qa | grep -i brotli
envoy --version 2>/dev/null; istioctl version 2>/dev/null
# there are more but if you're running them you should know what to do

Python matters (at least in this context) because requests, httpx, aiohttp and Scrapy use Brotli/brotlicffi automatically when installed, even if your code never imports them.

So, do you actually decode data you did not create?

The bug needs attacker-controlled compressed input to reach the decoder which can come you from two different directions:

If neither path exists, your exposure is lower. It is still not zero, and patching remains the fix.

What you cannot learn from outside

You cannot truly see this from a “light-touch” scan. Node.js by default does not send its version and the Brotli library almost never appears in a response banner, so any service promising a “light-touch” remote version check is guessing.

A behavioural probe can only show whether a URL decodes Brotli request bodies, which is exposure, not vulnerability. The safe way is to send the same valid Brotli body twice, once with Content-Encoding: br and once without, and compare:

# valid brotli of {"probe":true}, then the same bytes with and without the header
node -e 'process.stdout.write(require("node:zlib").brotliCompressSync(Buffer.from("{\"probe\":true}")))' > body.br
curl -s -o r1 -w '%{http_code}\n' -X POST -H 'Content-Encoding: br' -H 'Content-Type: application/json' --data-binary @body.br https://target/api/echo
curl -s -o r2 -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' --data-binary @body.br https://target/api/echo

Different responses mean the first request was decompressed, and identical responses mean no evidence of inbound decoding on that path.

Do not send the malformed stream or the multi-megabyte “wants more input” prefix to test for the bug. That is a denial-of-service test, and doing it to systems you do not own is both an “HULK SMASH” risk and a potential legal problem.

There are usually limits of this type of probing. One is that folks usually cover a singular path, usually /, which says nothing about other routes or about the outbound path. If your app is ehind a CDN or PaaS (Fastly, Platform.sh, and similar), the edge may do the decoding and you cannot patch that (again, hopefully you have a decent/capable provider).

A legit worked example from my fleet: two Node sites on one Caddy host

We have a couple subdomains off of inferenceandin.tel that (sadly) have Node in a Podman container (thanks, Glenn) behind a Caddy proxy.

Step 1 — find what is actually running. node was not installed on the host at all, so a PATH check would have found nada. The running processes are where we need to look:

ps -eo pid,user,args | grep -E '[n]ode|[b]un'
# node server/api/app.js           <- kev web
# node server/ingest/run.js        <- kev ingest
# node /app/ingest/refresh.mjs     <- agenda ingest

Step 2 — versions inside the containers. We need to check each container’s runtime for version ground truth:

sudo podman ps --format '{{.Image}} {{.Names}}'
for c in kev-dashboard_web_1 kev-dashboard_ingest_1 unprompted-agenda_web_1 unprompted-agenda_ingest_1; do
  printf '%-28s ' "$c"; sudo podman exec "$c" node -v 2>/dev/null || echo 'no node'
done
kev-dashboard_web_1          v22.23.4
kev-dashboard_ingest_1       v22.23.4
unprompted-agenda_web_1      no node
unprompted-agenda_ingest_1   v22.23.4

Since I know what these are, we have:

SiteWhat actually serves itNode versionStatus
kev.inferenceandin.telExpress app, Node in web + ingest containers22.23.4Fixed
unprompted-agenda.inferenceandin.telnginx 1.27.5 serving files; a Node ingest worker beside it22.23.4 in the workerFixed

(FWIW we did apply the fix after the initial notice went out.)

Absolutely use pinning in the Dockerfile so you can keep track of versions (i.e. FROM node:22.23.4-alpine).

Step 4 — showing the limits of an outside prove. Posting the probe body to https://kev.inferenceandin.tel/ returned an identical Express 404 (Cannot POST /) with and without Content-Encoding: br. The app is configured to refuse unknown routes before it ever parses a body, so the probe was more like “inconclusive” rather than “reassuring”. The agenda site answered 405 from nginx both ways, which only says the front end serves files. The version check (above) was the only way to answer the question.

”What if I cannot patch today?”

Well, you should at least try to reduce exposure. NOTE: the following guidance is a band-aid, not a fix:

A web application firewall (WAF) is not 100% reliale here as the body is decoded inside the runtime, after the perimeter.

Free tools worth using

Limits and gotchas

FIN

The answer to “am I exposed” is boring, likely tedious, and very local: the version string printed by the binary that is actually serving traffic. If it is on the fixed list above, this CVE is closed for you until the next one. If it is not, nothing else in this post substitutes for the upgrade — not the probe, not the dependency scan, and not a vendor’s assurance that the perimeter handles it.

Sources used for authoritative info:



Previous Post
Bulletproof Hosting Watch: Week of 2026-10-26
Next Post
Bulletproof Hosting Watch: Week of 2026-10-19