Node.js called it high, Google called it critical 9.2: the Brotli bug that is already in KEV
# Node.js called it high, Google called it critical 9.2: the Brotli bug that is already in KEV
On 21 October 2026, the Node.js project published security updates for its three active release lines, fixing ten vulnerabilities: four high, three medium and three low. In the official advisory, the highest severity fixed is HIGH on all three lines. That is the reading almost every article repeated.
There is another reading, and it is the one that matters. The critical vulnerability was not scored by Node.js. It was scored by Google, which acts as the CNA for the Brotli project that Node.js consumes a decoder from. Google gave it a CVSS 4.0 of 9.2, critical, with a network attack vector, no privileges and no user interaction. On 27 October, CISA added that vulnerability to its catalog of known exploited vulnerabilities, with a remediation deadline of 10 November.
Put the dates side by side. The patch shipped on 21 October. The 9.2 score was published on the 22nd. The KEV listing landed on the 27th. Real exploit traffic was observed on the 26th. The Spanish-language advisory that this story originally came from mentioned neither the 9.2 severity nor the KEV listing. Both details change the conversation.
The bug: eight bytes and a two-gigabyte displacement
The identifier is CVE-2026-111497. It is an out-of-bounds write in the Brotli decoder that Node.js itself acknowledges can permit remote code execution.
The mechanism is worth understanding because it is not a typical memory bug. Brotli is a compression format, and its decoder is a state machine that reads blocks and switches block type according to what the stream header declares. There is a construct called a block switch, which is simply a category declaring a single block type with no need for further input.
The defect is that an invalid block switch was not reported as a permanent format error. It was reported as a temporary need for more input. That distinction is the whole bug. If the decoder believes it only needs more bytes, it keeps appending them. It appends them to an eight-byte internal buffer, and it keeps appending, because the error condition never fires, until it writes past the end of that buffer.
The corruption geometry, according to ELLIO's analysis, is what turns this from a harmless denial of service into a critical flaw. The write is relative to the ring buffer and uses a signed 32-bit displacement, which means a client controlling the stream can choose a destination within roughly two gigabytes of that buffer, and copy sixteen bytes of its own content there. That is not an overflow that corrupts the process and crashes it. It is an arbitrary-write primitive with attacker-chosen content.
The upstream fix, in google/brotli, gives the block-switch helper its own error type. An invalid switch now returns BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH with the value -17. Unpatched decoders instead fail later with a block-length error.
And here is the fact that explains why this was not caught in February. The upstream commit that fixed the problem landed in Brotli's public repository on 27 February 2026. It was not shipped as a security release until 21 October, in Brotli 1.2.1, 1.1.1 and 1.0.10. In between there are almost eight months in which the fix was written, visible to everyone, and packaged by nobody. Node.js cherry-picked upstream commit 0ed4268a4401abcb4472096340477cf4af7d1a2b, "Detect and report invalid stream earlier", with its own commits for 26.12.1 and 22.23.4.
It is already being exploited
This is no longer a theoretical vulnerability. There are three public milestones inside a single week.
On 22 October, Imperva's red team demonstrated code execution on the official Node.js 24.22.0 Linux arm64 image. They reached it two ways: through the built-in fetch() consuming a Brotli-compressed response, and through Express 5 with body-parser using raw() and json(). The same inputs were rejected by 24.22.1.
ELLIO reproduced remote code execution on x86-64 against four targets using the officially published binaries under default configuration with ASLR left on: a bare node:http server, the official node:24.22.0-slim image running as root, Express 5.3.0, and Koa 3 with its body parser. In every case the result was a shell running as the service's own account.
On 26 October, ELLIO recorded exploit traffic in the wild. The observed sequence is telling. The previous day, a public exposure scanner identifying itself as node2shell-check sent twenty Brotli bodies in two bursts to map which hosts decompress Brotli. The following day, exploit traffic arrived from a different source carrying the public exploit. Bun renamed the issue HTTP2Shell on 24 October.
There are three reachable paths into the flaw, and knowing them determines which of your code is exposed. The first is inbound request bodies that your framework inflates automatically: Express 5, body-parser 2.x and the Koa body parsers. The second is outbound responses your HTTP client decompresses on its own: the built-in fetch(), undici, axios, node-fetch, got, SuperAgent, and on the Python side requests, urllib3, httpx or aiohttp if Brotli is installed. The third is direct calls into zlib: brotliDecompress, brotliDecompressSync and createBrotliDecompress. A bare node:http server does not inflate inbound Brotli by itself, and Express 4 with body-parser 1.x rejects br bodies with a 415.
The other nine, and the small print
The rest of the batch is not noise, but two of the headlines about this release are wrong, and the small print matters.
The three TLS vulnerabilities in the batch are not chain-of-trust failures. They are hostname verification failures, all classified CWE-295, and they all work the same way: the client fails to recognise a non-canonical way of writing an IP address as an IP address, normalises it, and compares the result against the certificate's names. A certificate issued for one IP can therefore authenticate a different one.
CVE-2026-105483, high, is a regression introduced after an earlier fix: the function did not recognise a host written with fullwidth digits as an IP address, so it compared it against the certificate's DNS names instead. CVE-2026-106069, high, is the elegant one. The engine normalises the host using WHATWG URL rules, which read a leading-zero octet as octal, turning 0127.0.0.1 into 87.0.0.1, while the connection is made through the operating system resolver, which on macOS reads it as decimal, 127.0.0.1. The two interpretations disagree, and the connection lands on a server the certificate does not identify. CVE-2026-106072, medium, accepts spellings such as 0177.0.0.1, 0x7f.0.0.1, 2130706433 or 127.1 whenever the certificate carries a numeric DNS SAN.
All three require the client to open a TLS connection to an address an attacker can influence, a webhook URL, a link preview, a user-supplied host, and to accept a certificate it trusts that names the target IP. Connections to fixed hostnames are unaffected. What these are not is chain-of-trust failures, and anyone presenting them that way is wrong.
Two of the remaining findings are worth attention for what they say about the platform rather than their own severity. CVE-2026-105482, high, silently truncates hostnames containing a NUL byte in dns.resolve and dns.reverse, because the name is passed to c-ares as a C string: resolving attacker.example with a NUL followed by trusted.example actually queries the first one, without error, which allows allowlist bypass. And CVE-2026-106074 is the one that should worry anyone using snapshots: the internal entropy caches behind randomUUID, randomUUIDv7 and randomInt are saved into startup snapshots and single executable applications, so every process launched from that snapshot returns the same values, up to 127 UUIDs and roughly a thousand repeated randomInt values.
The verification trap: your Node.js still tells you you are vulnerable
This is the detail that will do the most damage if it is not in your head. Node.js cherry-picked the upstream fix without bumping the version string of the Brotli library it bundles. The practical consequence is beautifully simple: process.versions.brotli still reports 1.2.0 on a patched build and on a vulnerable one. If your inventory is built on that field, you are reading a number that means nothing.
There is a check that does work. If the patched decoder exposes the new error constant, your installation carries the fix:
node -p "require('zlib').constants.BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH"
A patched binary prints -17. If your process does not recognise it, or throws, that runtime does not have the fix.
There is a second trap, different and quieter. Node.js installations delivered as distribution packages, whether Debian, Ubuntu or Homebrew, link against a shared Brotli library. In that case the fix arrives with your distribution's brotli package and not with the Node.js version number, and ldd on the binary tells you which of the two mechanisms applies to you. Official nodejs.org builds carry the bundled copy and show no such line.
For a fast inventory, three commands that cover the common case:
docker run --rm node:22 node --version
docker run --rm node:24 node --version
docker run --rm node:26 node --version
If you run containers, remember that an image built before 21 October is still vulnerable even though the base tag exists. The official Docker Hub images for node:22, node:24 and node:26 were pushed that day, but an image pinned to an older tag, node:22.23.3-alpine or node:24.22.0 for example, does not update itself. It has to be rebuilt. The same applies to single executable applications and startup snapshots, which carry their own runtime inside.
And a third thing that is not optional: restart. A running Node.js process keeps the old decoder in memory until it exits. Patching the disk does not protect the process that is still alive.
The lines that get no fix, and the delay that came with the fix
The 25.x, 23.x, 20.x and older lines receive no correction. Node.js 20, Iron, reached end of life on 30 April 2026, and Node.js 25 on 1 June. The project's position is explicit: end-of-life versions are always affected when a security release occurs. If you still have an 18 or a 20 in production, the problem is not this CVE. It is two years of accumulated vulnerabilities.
There is a second element in the same release that deserves attention on its own. On 20 October it became public that eight keys belonging to an intermediate certificate authority had been compromised, because they had been generated incorrectly and were recoverable from the certificates they issued. Node.js added a compiled-in block on those keys, removed three emSign roots and updated the bundled root store to NSS 3.131.1. But Node.js performs no revocation checks by default, which means that compiled-in block is the effective control. TLS sessions resumed before the update are not rechecked until they time out, and end-of-life lines do not receive the block at all.
The same upstream Brotli defect reaches well beyond Node.js, and nobody is exempt. Bun fixed it in 1.4.4, the Ruby brotli gem in 0.9.1, Envoy in 1.36.12, 1.37.8, 1.38.6, 1.39.3 and 1.40.0, Istio in 1.29.9, 1.30.6 and 1.31.2, Palo Alto PAN-OS in 11.2.4 with Brotli inspection hotfixes rolling out between 27 and 30 October, and in Python the Brotli 1.2.1 and brotlicffi 1.2.1.0 releases published on 23 October.
Detecting what is already happening
The community published detection rules with concrete identifiers, and they are useful because this bug does not always announce itself. Sigma rules including brotli-block-switch-probe and node-zbuf-eof, three Suricata rules numbered 2026114971 through 2026114973, a ModSecurity rule and a Falco rule.
In logs, look for a decoder under stress. The new error string, ERR__ERROR_FORMAT_BLOCK_SWITCH with code -17, is the signature of a patched system that received a malicious stream. On an unpatched system you will instead see Z_BUF_ERROR with "unexpected end of file", a "Decompression failed" string, or urllib3's message that it received a response with content-encoding br but failed to decode it. On vulnerable hosts, also look for segmentation fault crashes, restarts and 502 or 503 responses following a Brotli request.
On the network the pattern differs. Inbound requests with a Content-Encoding: br header are suspicious, because neither browsers nor typical API clients send it. So are bodies over a megabyte or sent chunked, duplicated Content-Encoding headers, and repeated Brotli requests from one address. Most importantly, outbound Brotli-compressed responses to destinations you do not control: webhooks, link previews and AI agent tools, which is exactly where an attacker able to influence the destination would ask for a malicious payload.
If you need a stopgap while patching, and this is not vendor guidance, Express 5 lets you switch off inflation with the inflate: false option on the JSON parser, axios accepts decompress: false, and a proxy can reject any response carrying Content-Encoding: br. Vercel, on its managed platform, disables Brotli handling for outbound fetch and node:http requests for exactly the same reason.
The order of operations
Inventory first. Do not trust process.versions.brotli. Use the -17 constant, check whether you link a shared library, and verify the real version of every runtime and every container. Split the inventory across live processes, built images and virtual machines, because those are three different populations with three different patch rhythms.
Patch, then restart. And rebuild the images rather than re-tagging them.
Close out the unsupported lines. It is the most expensive item on the list and the one being deferred in the most places.
Hunt in your logs for the signatures above, and search for inbound and outbound Brotli traffic over the last two weeks. If someone scanned your servers on 25 October to map who decompresses Brotli, it is in your records.
And if you run a web application firewall, Vercel and Imperva both published WAF rules on 21 and 22 October. They are not a fix, but they cover the window.
The takeaway
There are two readings of this release and both matter. The first is that Node.js published ten security fixes on 21 October, four of them high, and that on 27 October CISA added one of them to its catalog with a 10 November deadline. That is a maintenance operation with a calendar entry.
The second is that a flaw with an arbitrary-write primitive reaching two gigabytes, fixed upstream on 27 February and packaged on 21 October, was exploited in production before half the press knew it existed. And that the release notes, the source of truth for most teams, labelled it HIGH, while the CNA scored it 9.2 critical.
Most of your patching strategy depends on knowing which of the vendor's two notes you are reading. In this case both existed, and only one carried the correct number.