AxisOps Harbour
Labs

Insights

Mitigation code available CVE-2026-111497

The one that did not get the meltdown

Six days ago the industry stopped what it was doing for PHP. CVE-2026-109618 and CVE-2026-109619, unauthenticated remote code execution on anything serving web requests, CVSS 9.2. CISA told people to take unpatched systems offline (CISA alert, 21 October), which is not language it uses often, and added both to the Known Exploited Vulnerabilities catalogue on the 26th. That was the right response.

The same day PHP was patched, Google shipped Brotli 1.0.10, 1.1.1 and 1.2.1, and Node.js shipped 22.23.4, 24.22.1 and 26.12.1 (Node.js October 2026 security releases). CVE-2026-111497: an out-of-bounds write in the Brotli decoder, CVSS 9.2 from Google's CNA, reachable by anything that decompresses Brotli it did not produce. It got a fraction of the attention.

We think the gap is a mistake. The two bugs are not equivalent, and the argument here does not need them to be. The problem is that this one is materially harder to find on an estate, because the version numbers you would naturally check give the wrong answer.

The bug, briefly

The decoder treated an invalid block-type switch as "need more input" rather than a format error. Decoding carried on, appending attacker-supplied bytes to an eight-byte internal buffer and writing past the end of it (Red Hat RHSB-2026-015). The fixed decoder returns a hard error.

Exposure is wider than the Node.js headline suggests: HTTP clients that accept br responses, servers that decode br request bodies, anything pulling Brotli out of an archive, a cache or a queue. On the Python side requests, urllib3, httpx and aiohttp all decode Brotli automatically if a backend is installed, which it often is as a transitive dependency nobody chose.

Public proof-of-concept code exists and reaches code execution under narrow lab conditions, which is where this class of bug usually sits a week in.

Why it is harder to find than PHP

With PHP the question is nearly answerable. You know where your PHP is and php -v tells you the truth about it.

Brotli does not work like that, and three separate things are wrong with the obvious check.

The upstream version is close to useless. Distributions backport the patch without changing the version. Ubuntu 24.04's 1.1.0-2build2 is vulnerable and 1.1.0-2ubuntu0.1 is fixed, and both are upstream 1.1.0. A scanner comparing against the published affected range of "1.1.0 before 1.1.1" marks the patched host as vulnerable, forever, because 1.1.0 is where it will stay.

Patched Node.js reports the version it fixed.

$ docker run --rm node:22.23.4-slim node -p \
    'process.version + " brotli=" + process.versions.brotli'
v22.23.4 brotli=1.1.0

Every scanner reading process.versions.brotli for a verdict is wrong on patched hosts and will stay wrong. The only thing that answers the question is the Node.js release number against the published fixed release per line.

Whether Node.js even uses the system library depends on where it came from. The nodejs.org builds and official node images bundle their own copy, so libbrotli on the host is irrelevant to them. Distribution packages are the other way around: Ubuntu 24.04's node reports node_shared_brotli: true, and Ubuntu marks its own nodejs "not affected" because fixing the system library fixes it. One host can have both kinds installed.

None of that is visible in a version string, which is why "we patched" settles much less here than it does for PHP.

So there is a role

github.com/axisops/CVE-2026-111497, same shape as the others. Point it at a substrate deployment and it reports what is affected before it changes anything.

It compares the installed package against the distribution's fixed version, with dpkg, rpm and apk comparison implemented in Python so one host can judge a container running a different distribution. It reads node_shared_brotli per binary and judges each by whichever thing determines it. Dry run by default; --apply backs up, upgrades and verifies.

Four verdicts, and the two unsatisfying ones carry most of the value. no_fix is the vendor saying affected with nothing published: that is Red Hat today, advisory up on 26 October, status ongoing, no errata, RHEL 8 through 10 affected, with Rocky and Alma inheriting it. unknown is a release with no published verdict at all, such as Alpine 3.22. Reporting those plainly beats inferring from the upstream version, which is the thing we just established does not work.

It will not see a decoder compiled into another binary: Electron apps, pkg single binaries, Go and Rust programs linking it statically, browsers with their own copy. Python wheels are their own problem, since pip install Brotli puts a copy in a virtualenv no package manager owns. Containers get reported, never changed, because a package upgraded inside a running container is gone when it is recreated. For our own images that means mg docker build --refresh, since a plain rebuild reuses the cached layer and the old base.

And the upgrade is not the end. The library is loaded by long-running processes, and replacing the file on disk leaves every one of them running the old code until it restarts. The role reads /proc/*/maps for the (deleted) marker, names the service each process belongs to and restarts nothing unless asked.

The part that keeps recurring

The workaround people reach for is rejecting Content-Encoding: br at the proxy. It covers inbound request bodies and nothing else: not a client fetching a br response from somewhere hostile, not Brotli read out of a queue.

Which is the same conclusion as OpenSSH in 2024 and Log4j before it. The advisory tells you the affected versions. Working out which of your hosts are in that set needs every distribution and release you run, every vendor's fixed version for each and which vendors had shipped when you looked. This time it also needs the knowledge that two of the obvious version strings are actively misleading.

PHP got a week of everyone's attention and it still took CISA telling people to pull systems offline. This one got a fraction of that, and is harder to answer. An estate that can say "what package, from what release, built how" from a declaration answers it on day one. We went looking, again.