You upgraded Node.js, so why is Brotli unchanged?
HOTSecurity37 min read

You upgraded Node.js, so why is Brotli unchanged?

What does 'we upgraded to Node.js 22.23.4' prove? We split it into seven claims and tested each against official docs and our experiments.

No. “We upgraded Node.js, so we’re done” can be read seven ways, and only one of them is backed by evidence: the Brotli decoder inside the Node.js binary you upgraded is fixed. The other six may be right or wrong depending on which other copies of Brotli live on that server, which route the compressed bytes take in, and whether you restarted the process after replacing the file. All dates and times in this article are UTC.

The flaw is in the decoder of Google’s compression library Brotli. It is tracked as CVE-2026-111497 and nicknamed Node2Shell. Despite the name, it is not a Node.js-only problem. So when a report says “Node.js upgraded,” it is worth asking what that single line actually confirms. Instead of retelling the incident from the start, this article splits that line into seven claims and sets each against the evidence. For each claim it lists what is used as support, what the editorial team measured itself, and whether the claim holds.

Evidence is labeled in three kinds. Facts about the incident (dates, versions, scores, affected range) come only from official records: Node.js, Microsoft, GitHub, the google/brotli repository, the Debian Security Tracker, and the Known Exploited Vulnerabilities (KEV) catalog. Format and API behavior was read from RFC 7932 and each project’s documentation. And an Editorial experiment is a result we ran ourselves in a disposable container. The experiments used only well-formed input; we never built or ran input that touches the flaw. All commentary is labeled Editorial note.

Splitting one sentence into seven claims

A person who writes “Node.js upgrade complete” in a report usually means the risk is gone. The reader, however, pulls seven different things out of that sentence. The table below lists those seven as claims, with the evidence each would need and the verdict reached in the sections that follow.

# Claim implied by the sentence Evidence needed to call it true Verdict
1 At 22.23.4 or later, that Node.js is fixed Node.js announcement and release notes True (for that binary only)
2 The version string tells you whether it’s fixed A value that differs between fixed and unfixed builds False (depends on the value)
3 If normal requests run fine, it’s fixed The two versions react differently to normal input False
4 Upgrading Node.js upgrades Brotli too Where each copy lives Depends on how it was deployed
5 Blocking incoming requests is enough Every path that calls the decoder False
6 The file was replaced, so the running process is new too State of the running process False
7 Exploitation is confirmed, so we were breached What the exploitation record covers Cannot be said from this record alone

We show the verdicts first because this is not an article that builds to a twist ending. Each section follows the same order: the evidence commonly cited for the claim, what the editorial team measured, and the verdict.

Claim 1: 22.23.4 means fixed

The Node.js security release announcement of Oct 21 describes this flaw as the Brotli decoder bundled with Node.js letting malformed compressed input write past the end of an internal decoder buffer. The stated cause is that an invalid block switch was reported as a temporary shortage of input rather than a permanent format error, so the streaming decoder kept appending input bytes to an 8-byte internal buffer. The announcement’s assessment is that if an attacker can supply Brotli data that an application decompresses with node:zlib, process memory can be corrupted, leading to a crash, exposure of process memory, or remote code execution, and it rates the severity High. The affected lines are 26.x, 24.x, and 22.x, with fixes in 26.12.1, 24.22.1, and 22.23.4 respectively. The announcement credits Khai Tran and Sylvie Mayer of OpenAI Codex Security as reporters and mcollina and aduh95 for the fix.

The announcement has one more line: end-of-life lines are always affected whenever a security release ships. A server that does not run 22, 24, or 26 should not say “upgraded” but “changed release lines.”

The 22.23.4 release notes list two items related to this flaw under notable changes. One is “deps: brotli: cherry-pick 0ed4268a4401abcb44720963” and the other is “src: expose BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH”. The first tells us that Node.js did not swap in a new Brotli version; it applied one fix commit from Google’s repository onto its own copy. The second means the new error code is exported through zlib.constants.

Lining up the timestamps shows the order of events. In Google’s repository, the fix commit (0ed4268, titled “Detect and report invalid stream earlier.”) landed on Feb 27 at 13:10, and the commits carried onto release branches landed on Oct 21 at 10:05 and 17:16, among others. Node.js 22.23.4 was released on Oct 21 at 10:38, and Brotli’s own 1.0.10, 1.1.1, and 1.2.1 were published around 19:27 the same day. Editorial note: because Node.js applied the commit to its own copy and shipped first, Node.js users did not have to wait for a library release. Conversely, this also means “Brotli 1.2.1 is out” and “Node.js 22.23.4 is out” are different events. They are separate deployments that put the same fix into each project’s own copy.

Counting days reveals one more thing. From the fix commit on Feb 27 to the release on Oct 21 is 236 days. During that time the fixed code sat on the default branch of Google’s repository, and the commit message was a single line: “detect and report an invalid stream earlier.” Release notes and an advisory number appeared only after Oct 21. Editorial note: during that period, a tool that judges by release number alone had no way to know the fixed code was already public. By the same logic, the judgment “it isn’t 1.2.1, so it isn’t fixed” may be wrong for a copy built straight from source. A copy that took commits after Feb 27 directly may contain the fix even if its number is 1.2.0. This connects to the “version string” claim below.

The Node.js zlib documentation also tells us how this decoder gets called. The Brotli decompression class (BrotliDecompress) is a stream, it uses BROTLI_OPERATION_FINISH by default when processing the last chunk, and the example the docs give pipes a response stream into the decompression stream with pipeline. Editorial note: stream mode assumes input arrives in several chunks, so “more input needed” is a state in the middle of normal flow. When the decoder returns that state, Node.js waits for the next chunk; when it returns an error, Node.js ends the stream with an error. That the flaw occurred exactly at the point that separates these two states fits with why it surfaced on the stream API.

Verdict: true. But the scope of that verdict is exactly one thing: the copy bundled in an official Node.js binary at 22.23.4 (or 24.22.1, 26.12.1) or later. The sections below peel off the qualifier “for that binary only” one piece at a time.

One fixed line, two branches

To examine the later claims, we first need to know what was fixed. Here is how far official documents alone can explain it.

RFC 7932 defines Brotli data as a sequence of meta-blocks, and explains that within a meta-block the literals, insert-and-copy lengths, and distances each have a “block type.” A point where the block type changes is expressed by a block switch command. The specification states that the block type of the first block switch command is not encoded in the data, and that if a category has only one block type, the block count of the first block switch command is also omitted. The header holds the number of block types per category (NBLTYPESL, NBLTYPESI, NBLTYPESD), and the specification requires that this number equal the maximum block type plus one. Editorial note: what can be read from this is that input in which a switch command appears in a category with only one type is not input the specification anticipates. The decoder has to declare it a “format violation.”

Let us follow a bit further how the RFC depicts block switches. A meta-block mixes elements of three categories, insert-and-copy lengths, literals, and distances, and each category separately counts “the block type in use now” and “how many more elements to read with that type.” When the count runs out, the next block type and the next count are read from the stream just before the next element is read. That is the block switch command. In the RFC’s example, the literal category is divided into three blocks of types 0, 1, 0 like [L0, L1][L2, L3, L4][L5], and switch commands are written as pairs like LBlockSwitch(1, 3). The specification insists that consecutive blocks have different types, and explains that the first block is always type 0 with its count given in the meta-block header. Block type numbers run from 0 to at most 255, and the codes that point to a type consist of symbols meaning “return to the previous type,” “current type plus 1,” and the number of each type. The header holds the number of types per category.

Editorial note: reading the specification this way explains why “an input that requires a block switch in a category with only one type” is a format violation. Consecutive blocks must be of different types, but with only one type there is no type the next block could be. So at that point the decoder must reject the data rather than wait for more. Rejecting and waiting are signals that demand opposite behavior from the caller, and the fix writes exactly this distinction into the code.

Downloading and comparing the v1.2.0 and v1.2.1 sources from Google’s repository matches this reading. Editorial experiment: comparing the files under c/ in the two tags, the only files that differed were three, c/dec/decode.c, c/include/brotli/decode.h, and c/common/version.h, plus CHANGELOG.md at the repository root. One line changed in the header. Where the 1.2.0 header had the comment /* -17 code is reserved */, 1.2.1 defines an error code named BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH (-17). In decode.c, DecodeBlockTypeAndLength, which reads block switches, was changed to return a BrotliDecoderErrorCode instead of a boolean that only reported “did it succeed.” For a category with only one block type (max_block_type of 1 or less) it returns the new error -17, and when input runs short it returns BROTLI_DECODER_NEEDS_MORE_INPUT. In the old code those two were the same false value. A new macro, BROTLI_SAFE_WITH_STATUS, was added in three places (command, distance, and literal block switches) so the caller checks that return value. The missing return-value check is consistent with the KEV entry classifying this flaw as CWE-252 (unchecked return value) and CWE-787 (out-of-bounds write).

It is also worth looking at how the decoder’s API documentation separates “don’t know” from “wrong.” decode.h defines four results for the streaming decode function: an error (for example, corrupt input or a memory allocation problem), success, NEEDS_MORE_INPUT meaning “partly processed, give me more input,” and NEEDS_MORE_OUTPUT meaning “give me more output space.” The header also carries the list of error codes, and every error caused by bad input is grouped as a negative code prefixed with FORMAT_. A caller that gets NEEDS_MORE_INPUT feeds the next chunk as normal, and one that gets an error stops. So if some input is “wrong” but reported as “don’t know,” the caller keeps feeding it, and the decoder’s state carries on having half-processed that input.

Sequence diagram showing that for the same invalid block switch, the 1.2.0 decoder returns a shortage of input and the 1.2.1 decoder returns error -17 Figure 1. Two branches for the same kind of input. The two arrows at upper left are the 8-byte buffer path described in the Node.js announcement

What to notice in the figure is that the caller did exactly the same thing in both cases. The caller did what the API told it to, and the outcome split according to what the decoder reported. This section has two conclusions. What was fixed is a return value inside the decoder, not the caller, so no amount of careful calling code can prevent this flaw. And the trace of the fix boils down to one name: the new error code -17. That name is the key to the next claim.

Claim 2: the version string tells you

“You can tell just from the version” comes up naturally, because Node.js has a process.versions object that includes brotli. The editorial team measured whether that value points to the fix.

Editorial experiment: on the same host we started Node.js 22.13.1 (before the fix) and the official image’s 22.23.4 (after the fix) and ran the same two commands in each. process.versions.brotli is 1.1.0 in both builds. That is the expected result, since as we saw in the previous section Node.js applied a commit rather than upgrading Brotli. On the other hand, zlib.constants.BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH was undefined on 22.13.1 and -17 on 22.23.4. Counting this string in the 22.23.4 binary with grep -c -a gives 3. The official Alpine-based 22.23.4 image also showed bundled version 1.1.0 with constant -17, and ldd shows that the Node binary does not link to the system libbrotli (0 lines of output).

We asked the same question of Google’s source built directly. Editorial experiment: we built v1.2.0 and v1.2.1 from source with gcc in disposable containers and called BrotliDecoderVersion(), which gave 0x1002000 and 0x1002001. BrotliDecoderErrorString(-17), which returns the name of error number -17, gave INVALID in 1.2.0 and _ERROR_FORMAT_BLOCK_SWITCH in 1.2.1. This function returns only error names the library knows and answers INVALID for unknown numbers, so this single call separates fixed from unfixed builds. The PyPI Brotli package was different. The Python module’s __version__ came out as 1.2.0 and 1.2.1, matching the package version, and counting the FORMAT_BLOCK_SWITCH string inside the extension module file (_brotli*.so) with grep -c -a gave 0 for 1.2.0 and 2 for 1.2.1. The two extension modules are the same size (1,920,096 bytes), link only libSystem among system libraries, and do not depend on libbrotli. That means the decoder is statically included inside the extension module.

Terminal output: bundled Brotli version is 1.1.0 on both Node.js 22.13.1 and 22.23.4 but the error constant splits into undefined and -17, and the name string is present or absent in the source build and the PyPI module Figure 2. Editorial experiment output. Yellow lines are the unfixed side and green lines are the fixed side

Here is the interpretation. In Node.js the bundled Brotli version string tells you nothing, and the answer comes from the version of Node.js itself (is it 22.23.4 or later?) or from the new error constant. In the Python package the package version is the library version, so the version string is the answer. In a C project built directly from source, BrotliDecoderVersion() gives the answer. Editorial note: the same act of “checking the version” yields a trustworthy value that differs by copy type, so if you use an inspection script it has to read a separately chosen value for each copy type. A script that judges everything by one value will produce false positives or misses.

This value also helps when reading logs. Editorial experiment: searching the 22.23.4 binary for error-name strings finds BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH twice and _ERROR_FORMAT_BLOCK_SWITCH, used to build the error code as a string, once. The ERR__ERROR_FORMAT_PADDING_2 we saw earlier for a one-byte 0xff input is built by the same rule (ERR_ followed by a _ERROR_FORMAT_ name), so, Editorial note (inference): when an invalid block switch arrives at a fixed Node.js, the application log should record an error code named ERR__ERROR_FORMAT_BLOCK_SWITCH. A pre-fix build does not raise an error on this input but treats it as a shortage of input, so it cannot leave the same log. So if this name shows up in a log, it means the service is a fixed build and also that someone sent Brotli data that does not conform to the format, which makes it a string worth setting an alert rule on.

Verdict: false. “Look at the version string” is right for Python packages, wrong for Node.js if you look at the bundled Brotli version, and for system libraries it is right only if you know how the distribution numbers its fixed versions (next section).

Claim 3: if normal requests run fine, we’re done

“The production service is running well and compressed requests decompress fine, so there’s no problem” is another common way to check. If the fixed and unfixed sides reacted differently to ordinary input, this method would work, but the diff in the previous section says nothing changed on the normal path. We measured it.

Editorial experiment: we fed the same input through the streaming decode API of the two libraries we built earlier (v1.2.0, v1.2.1) and counted the result categories. First, a 36-byte stream made by compressing a repeated normal sentence (20,000 bytes) gave SUCCESS with 20,000 bytes on both versions. Cutting the same stream partway (half, 3 bytes) or giving empty input gave NEEDS_MORE_INPUT on both. Then we put in all 256 one-byte inputs and all 65,536 two-byte inputs, one at a time. These inputs are too short to reach a block switch command, so they do not touch the flaw path. The one-byte classification was 74 errors, 173 input shortages, and 9 successes, identical on both versions, and the two-byte classification was 22,140 errors, 41,069 input shortages, and 2,327 successes, also identical on both. Even the distribution of error codes for the two-byte inputs classified as errors (-2, -13, -14, -15) was the same.

Horizontal bar chart of how the v1.2.0 and v1.2.1 builds split 65,536 two-byte inputs into errors, input shortage, and success. The bars for the two builds are the same length Figure 3. Counts by category in the exhaustive two-byte test (editorial experiment). The bars for the two builds are the same length

All the figure shows is that the bars stack up and down at the same length. Normal input, truncated input, and meaninglessly short input cannot tell the two versions apart. The same thing showed up through the Node.js API. Editorial experiment: zlib.brotliDecompressSync decompressed a normal stream; threw Z_BUF_ERROR (error number -5, “unexpected end of file”) for a half-cut stream and for empty input; decompressed as-is a normal stream with 3 junk bytes appended; and threw ERR__ERROR_FORMAT_PADDING_2 (-15, “Decompression failed”) for the single byte 0xff. These reactions were the same on 22.13.1 and 22.23.4. Python’s Brotli 1.2.0 and 1.2.1 were also the same. Normal input decompressed, while truncated or empty input and the single byte 0xff all gave “brotli: decoder failed.”

It is also worth summing up how the same “wrong” and “short” show up under different names depending on language and API. The table below combines the Node.js and Python reactions read in our experiments with .NET’s reactions as described in Microsoft documentation.

Input Node.js zlib (editorial experiment) Python brotli (editorial experiment) .NET (Microsoft docs)
Normal stream Decodes successfully Decodes successfully Decodes successfully
Stream cut partway, empty input Z_BUF_ERROR, error number -5, “unexpected end of file” brotli: decoder failed The docs do not cover this case separately
Short input that does not fit the format (one 0xff byte) ERR__ERROR_FORMAT_PADDING_2, error number -15, “Decompression failed” brotli: decoder failed Brotli may throw InvalidOperationException: Decoder ran into invalid data
Normal stream followed by 3 junk bytes Decompressed as-is (20,800 bytes) brotli: decoder failed in both one-shot and streaming The docs do not cover this case separately

Editorial note: Node.js distinguishes “short” from “wrong” at the error-name level (Z_BUF_ERROR versus ERR__ERROR_FORMAT_*), Python merges the two into one message, and the .NET docs give a single exception name for bad data. For someone trying to sort out an incident from logs alone, this difference matters. The same “decompression failed” may mean “the input hasn’t all arrived” in one language and “the input is wrong” in another, so to build a rule that counts decompression errors as signs of attack, you have to match error names separately for each language. The bottom row of the table means Node.js’s zlib accepts “leftover bytes after a normal stream” while Python treats it as an error. The same input passes in one service and is rejected in another, so a rule built for input validation on one language’s terms cannot be carried over unchanged to the other. In the Node.js column, Z_BUF_ERROR is a name that can also appear in normal use when a transfer is cut off, so it is weak grounds for a sign of attack, while ERR__ERROR_FORMAT_* points directly at a format violation.

We also shook the chunk boundaries separately. A streaming decoder’s internal state can differ depending on where the input is cut, so we split the normal stream into two chunks at every one of its positions and also fed it one byte at a time. Editorial experiment: both builds fully decoded the stream when split in two at every position (37 of 37), and when fed one byte at a time they returned input shortage 35 times and then success once, producing 20,000 bytes. Repeatedly writing one byte at a time into Node.js’s createBrotliDecompress also finished the normal stream at 20,800 bytes, and a stream that was half-written and then closed ended with Z_BUF_ERROR (“unexpected end of file”) at close. The same held on both Node.js builds. This means the difference before and after the fix lies at a single boundary between “input is short” and “input is wrong,” and a normal stream never crosses that boundary.

Editorial note: what this experiment shows is that an “it’s working” check based on normal traffic is useless for judging vulnerability, because the fix was made so as not to change the behavior on normal input. Only opening the flaw path and comparing could separate the two builds, and that is something this article does not do. So the check has to be done with the fingerprints from the earlier section (the constant, the name string, the version).

Verdict: false. The fact that normal requests ran fine is observed identically on the fixed side and on the unfixed side.

Claim 4: Node and Brotli go up together

A server that runs on Node.js alone is rare. The same server has curl, git, and Python, and with containers there is a separate base image. Where each tool’s Brotli lives differs from tool to tool. Here is the set of copies the editorial team confirmed, in one place.

Editorial experiment: in a Debian 12-based container we read link relationships with ldd. curl 7.88.1 dynamically links libbrotlidec.so.1, and brotli/1.0.9 also appears on the feature line of curl --version. The git core (/usr/bin/git) does not link Brotli directly, but git-remote-http, which handles HTTP, uses the same libbrotlidec.so.1 through libcurl-gnutls. In this container dpkg had libbrotli1 1.0.9-2+b6 installed. As we saw earlier, the official Node.js binary has 0 Brotli lines in ldd and has the ERROR_FORMAT_BLOCK_SWITCH string in 3 places inside the binary. The Python PyPI package had the decoder statically included inside its extension module. For .NET, the Microsoft announcement sums it up this way: apps that share the runtime just install the new runtime and restart, while self-contained apps must be rebuilt with the new runtime and redeployed, and installing a new runtime on the host leaves the runtime bundled with the app unchanged.

A grid of seven kinds of copies against five upgrades that fix them. Each row has exactly one green cell Figure 4. For each copy there is only one upgrade that fixes it. Doing an upgrade from a column that isn’t its own leaves that row unchanged

The way to read this grid is that each row has only one green cell. A new Node.js fixes only the official Node.js binary. Upgrading the system libbrotli with apt fixes curl and git (where Debian uses the system library). pip install fixes the copy inside a Python wheel, and .NET splits into two upgrade paths depending on whether it is a shared runtime or self-contained. Editorial note: what this table means is that the act of “upgrading Node.js” worked on only one of seven copies. Even if that one is the service’s critical path, the other copies on the same server remain.

For .NET there are two more things to know. According to the Microsoft announcement, this emergency update shipped for .NET 8, 9, 10, and 11 RC3, and the same update includes the fix for an ownership-information problem in .NET archive files on Linux (CVE-2026-114148). On .NET 8 and 9, people who installed from ASP.NET Core archive files rather than building from source must also update with the newly refreshed archive files. So a host upgraded because of Brotli picks up this fix as well.

The Debian Security Tracker shows this structure in an official record. Here are the relevant rows from the table as the editorial team read it (Nov 2).

Source package Release Package version Status
brotli bookworm 1.0.9-2 vulnerable
brotli bookworm (security) 1.0.9-2+deb12u1 fixed
brotli trixie 1.1.0-2 vulnerable
brotli trixie (security) 1.1.0-2+deb13u1 fixed
brotli forky 1.2.0-4 vulnerable
brotli sid 1.2.0-4.1 fixed
nodejs bookworm (security) 18.20.4+dfsg-1~deb12u3 vulnerable
nodejs trixie (security) 20.19.2+dfsg-1+deb13u3 vulnerable
nodejs forky 24.21.0+dfsg+~cs24.13.4-1 vulnerable
nodejs sid 24.22.2+dfsg+~cs24.19.2-1 fixed
python-brotlicffi trixie 1.1.0.0+ds1-1 vulnerable
python-brotlicffi forky, sid 1.2.1.0+ds-1 fixed

The tracker’s notes say that nodejs and python-brotlicffi use the brotli library provided by the system. So a nodejs installed with apt on Debian is fixed in a completely different way from the 22.23.4 released by the Node.js project. What stands out in this table is that the Debian nodejs rows are packages of the old 18.20.4 and 20.19.2 lines. They fall outside the 26, 24, and 22 lines the Node.js project’s announcement lists as affected, yet the Debian tracker marks these packages vulnerable. The reason is that they use the system library, 1.0.9 and 1.1.0, not a bundled one, and these versions fall in the affected range (1.0.3 through 1.2.0). Editorial note: a checklist that asked “is it Node.js 22 or later?” does not apply to an apt-installed Node.js.

Let us match the libbrotli1 1.0.9-2+b6 in our container against this table. Editorial experiment: dpkg --compare-versions 1.0.9-2+b6 lt 1.0.9-2+deb12u1 returned true, so the library in this container was numbered lower than the 1.0.9-2+deb12u1 the tracker lists as fixed. Also, counting the FORMAT_BLOCK_SWITCH string in this library file (libbrotlidec.so.1.0.9) with grep -c -a gave 0. Editorial note: this container was a Debian 12-based image that had been sitting in the editorial team’s local environment, and it means only that the package state from before the security update remained as it was; it does not mean Debian was late. Packages inside an image stay as they are until the image is rebuilt or updated from inside, so such an image can be in its old state even when the host is up to date. The general principle this value teaches is that the same number 1.0.9 is separated between before and after the fix only by the tag a distribution appends (+deb12u1). Unlike copies such as Python or Node.js, where the version number itself changes, a distribution copy should not be judged by its upstream version but compared with the “fixed package number” in the distribution’s tracker.

The target numbers for the upgrade also differ by copy. google/brotli published three releases in a row within 36 seconds on Oct 21: 1.0.10 at 19:26:58, 1.1.1 at 19:27:20, and 1.2.1 at 19:27:34, and the notes for all three are the same single line. The GitHub advisory also says to move to 1.0.10, 1.1.1, or 1.2.1. Editorial note: the three came out at the same time because the 1.0.x, 1.1.x, and 1.2.x lines are each maintained separately, which lets places using a 1.0.x copy get the fix within the same line without a big version jump. Debian’s 1.0.9-2+deb12u1 for bookworm and 1.1.0-2+deb13u1 for trixie work the same way (the tracker labels these DLA-4855-1 and DSA-6582-1 respectively). So the judgment “it isn’t 1.2.1, so it isn’t fixed” can also be wrong, and you have to match against the fix number set for each line.

Verdict: depends on how it was deployed. True for a server that uses only the official Node.js binary, and false for a server where apt-installed nodejs, curl, git, Python, and .NET coexist.

What copy-finding commands catch and miss

The advice to make an inventory of copies is easy, but which command sees which copy differs from command to command. Here are the results of the editorial team running the same commands on three images.

Editorial experiment: find / -iname "*brotli*", which looks for names containing brotli, found 0 hits on the official Node.js 22.23.4 image (Debian-based slim). The Alpine-based Node.js image was the same. Yet the Node.js in this image does have the decoder. The decoder is not a separate file but sits inside the node executable (about 120 million bytes), so it simply does not show up by file name, and counting the brotli string inside the executable gives 85. By contrast, on the Debian 12-based development image the same find returned 26 hits: system library files (the .so and .a of libbrotlidec, libbrotlienc, and libbrotlicommon), development headers, and dpkg list files. These 26 do not show the separate copy of Node.js 22.13.1 that sits in that image. That the same image has Node.js could only be learned by separately searching with find / -name node.

Method Copies it catches Copies it misses
File-name search (find -iname) System libraries, package list files Copies statically included in executables (Node.js, Python extension modules)
ldd executable | grep brotli Dynamically linked copies (curl, git using libcurl) Statically included copies, dlopen use that doesn’t go through linking
Package lists from dpkg -l and apk info Copies managed by the distribution Copies that came in outside the package manager (official Node.js binary, pip, self-contained apps)
pip show Brotli brotlicffi Python packages Copies in other languages
dotnet --list-runtimes Shared runtimes installed on the host Self-contained apps and containers (the Microsoft announcement explicitly says to check these separately)
String search inside executables (grep -c -a) Whether a bundled copy is fixed (error-name string) A missing string does not mean the copy is vulnerable (when a distribution fixed it a different way)

Editorial note: in this table no single method sees every copy. So making the list should be “the right command once for each copy type,” not “one command for a full survey,” and you can check for missing types against this table’s rows. The caveat in the last row matters too. A string search is a way to mark the fixed side with certainty, not a way to declare something vulnerable just because the string is absent. A distribution may have carried the upstream commit over in a different form, so for distribution copies you should finish by matching against the package number in the distribution’s tracker.

Claim 5: just block the incoming requests

When people talk about defense, they usually picture “requests coming in from outside.” But the decoder runs anywhere there are compressed bytes and someone calls code to decompress them. Here are the calling paths the official documents describe, grouped by direction.

Diagram in which requests from outside, responses from remote servers we called, and downloaded files pass through the request body handler, the HTTP client, and tools respectively and converge on a single Brotli decoder Figure 5. Three paths that reach the decoder. The three boxes on the left are bytes from outside the service, and the red box on the right is the same decoder no matter which software the copy sits in

The first path is the request body the service receives. Node.js’s http server does not decompress request bodies automatically, so this path opens when an application or framework looks at Content-Encoding and calls zlib. In ASP.NET Core, the request decompression middleware does this job. According to Microsoft documentation, the middleware looks at the Content-Encoding header and wraps the request body in the matching decompression provider, and decompression happens when the body is read (at model binding), not up front in bulk. The default provider list includes br (Brotli), and the docs say that reading malformed data may make Brotli throw InvalidOperationException: Decoder ran into invalid data. In the same context the docs add two more things. If a request has no Content-Encoding header the middleware leaves the request alone, and a request that cannot be decompressed (an unsupported encoding or multiple header values) is passed to the next stage without decompression. Editorial note: by this description, a request that lists only a single br value passes the middleware and reaches the decoder at an endpoint that reads the body. An endpoint that does not read the body does not call the decoder, so the exposure on this path is the product of two conditions: whether the middleware is registered and whether an endpoint that reads the body exists. Registering the middleware (AddRequestDecompression, UseRequestDecompression) must be done in code, and the Microsoft announcement we saw earlier also says request decompression and automatic response decompression are not on by default.

We also measured how this path comes about on the Node.js side. Editorial experiment: on Node.js 22.23.4 we started a local HTTP server and POSTed a 29-byte normal Brotli body with a Content-Encoding: br header using fetch. The server’s http module received the header value br and the 29 bytes as they were, and did not decompress them. So for a Brotli request body arriving at a Node.js server to reach the decoder, the application or framework must have code that calls zlib. Whether that code exists cannot be told from the dependencies in package.json alone; you have to open the body-parser settings of the framework in use.

The second path is the response from a remote server we called. The Node.js docs example requests with an Accept-Encoding: br,gzip,deflate,zstd header and, if the response’s content-encoding is br, pipes the response stream into zlib.createBrotliDecompress(). Editorial experiment: we started a local server that returns 26 bytes with Content-Encoding: br and called it with three clients. Node.js’s http.get received the 26 bytes as they were (no decompression), fetch() automatically decompressed it and gave us 510 bytes of text, and curl decompressed it to 510 bytes with the --compressed option and stayed at 26 bytes without it. The results were the same on 22.13.1 and 22.23.4. Editorial note: if the code calling an external API uses fetch, the decoder gets called even when the application developer is not aware that decompression happens. HttpClient’s automatic response decompression, according to Microsoft documentation, also attaches an Accept-Encoding header to every request it sends whenever the value is not None. With this feature on, the structure is such that the calling side decides which encoding the server answers in. The same document says automatic decompression undoes only the outermost compression, so with stacked encodings like gzip, br the inner gzip remains.

The third path is tools and scripts. As we saw in the previous section, curl and git go through the system libbrotli, and on the Python package side, dependency declarations create the path. Looking at official repository records rather than an editorial experiment, urllib3’s pyproject.toml declares the optional dependency brotli as brotli>=1.2.0 (for CPython) and brotlicffi>=1.2.0.0 (for other implementations). There is something to watch for in reading this lower bound. According to the google/brotli changelog, 1.2.0 is the release that added the output_buffer_limit argument and the can_accept_more_data method to the Python Decompressor, and this change was a security item to prevent larger-than-expected output (decompression bombs). The urllib3 configuration file also has a warning filter, “Brotli >= 1.2.0 is required to prevent decompression bombs.” Editorial note: this lower bound was set for decompression bombs, so it was placed regardless of the flaw we are discussing, and above that lower bound sits the vulnerable 1.2.0 itself. Satisfying the lower bound does not make a system safe.

Verdict: false. Incoming requests are only one of three paths. The side the service calls (responses) and tools reach the same decoder. When judging exposure, the better question is not “who can give me compressed bytes?” but “where does my code receive compressed bytes and decompress them?”

Claim 6: change the file and the process changes too

If you upgrade a package and report “applied,” the file system is new. But whether the running process is looking at that file is a separate matter. It is also why the Microsoft announcement adds “install the new runtime, then restart the app” for framework-dependent apps.

Editorial experiment: we safely reproduced the restart problem. We copied the Node.js 22.13.1 binary to /tmp/nodeA, ran a script that prints repeatedly, and while it ran replaced the same path (/tmp/nodeA) with the 22.23.4 binary using mv (rename, the way package managers do it). Right after the swap, the running process’s /proc/PID/exe link showed /tmp/nodeA (deleted), and even 3 seconds later that process was still printing v22.13.1 and the error constant undefined. A process newly started from the same path printed v22.23.4 and -17. The file was already new, but the process that had been running kept using the old binary.

In the same experiment we also measured time. The process started at 08:14:28 (UTC) and the replaced file’s modification time was 08:14:30. A file time later than the process start becomes a record that “this process started before the swap.” However, file modification times depend on whether the install tool preserves times when copying, so, Editorial note: this time comparison is best used only as supporting evidence, with the (deleted) marker or service restart records as the main evidence.

This experiment gives one signal that can also be used for inspection. A process still running old code after its file was replaced shows (deleted) appended to the end of the target /proc/PID/exe points to. Editorial note: after a package upgrade, running ls -l /proc/*/exe | grep deleted and seeing whether Node.js, curl, or service processes remain on the list lets you find processes that need a restart. Process spaces differ inside and outside a container, so it must be run inside the container, and a file that was simply renamed is not caught by this method. The absence of this signal is not proof that a process is new, but its presence is proof that it is old.

Editorial note: this experiment relies on the general Linux behavior that when a file is replaced by renaming, the old file stays around while it is open. So the check has to ask the running process, not the file system’s version. In a container, docker exec container-name node -v merely starts a new process inside the container, so you may end up checking a process other than the service’s main one. It only means something if you restart the service or swap in a new image and check after the service has come up from that image. Containers go one step further. Installing a new Node.js on the host leaves the container image using the Node.js inside the image. This is the same reason the Microsoft announcement says to check self-contained apps and containers separately, because dotnet --list-runtimes shows only the runtimes installed on the host.

Verdict: false. Even if the version of the file or package is new, you need separate evidence that the service process was started from the new binary.

Claim 7: exploitation confirmed means we were breached

The last claim points the other way. While the six above shake the grounds for “we upgraded, so we’re safe,” this one checks the anxiety that “exploitation is confirmed, so it’s already too late.” Three official records bear on this claim.

The Known Exploited Vulnerabilities (KEV) entry was added on Oct 27 with a remediation due date of Nov 10. The entry calls the flaw a “Google Brotli unchecked return value vulnerability,” and says a remote, unauthenticated attacker can use a crafted Brotli stream to corrupt adjacent decoder-state memory, expose memory contents, cause a denial of service, or execute code. The related CWEs are 252 and 787, and whether it has been used in ransomware campaigns is “Unknown.” The notes contain an explanation that it is a vulnerability in an open-source component usable by multiple products, plus a link to Google’s 1.2.1 release. The same entry also shows that the official record does not contain the substance of the exploitation (who, where, against which configurations). Editorial note: being on this list means there is evidence of exploitation, but that evidence is not published in the entry, so this article does not write about exploitation paths or the scale of damage.

The GitHub advisory (PyPI’s brotli and brotlicffi) gives 9.2 Critical and publishes the vector CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N. Here is how to read it. The attack path is network (AV:N), the complexity is low (AC:L), no privileges or user interaction are needed (PR:N, UI:N), and the impact on the confidentiality, integrity, and availability of the vulnerable system itself is all high (VC:H, VI:H, VA:H). The cell that stands out is AT:P, where the attack requirement is “present.” Editorial note: in CVSS 4.0 this cell marks that for the attack to succeed, the target must have a specific deployment or conditions in place. It connects directly to Claim 5 in this article. The condition that the decoder must be called is exactly that. The difference between the Node.js announcement’s High and this 9.2 Critical is because the scales and assessing bodies differ, not because one of them is wrong.

To sum up, here is what each of these records says and does not say.

Record What it says What it does not say
Known Exploited Vulnerabilities (KEV) listing Evidence of exploitation exists, remediation due Nov 10 Exploitation path, target configurations, scale of damage, ransomware use (Unknown in the entry)
GitHub advisory 9.2 (CVSS 4.0) Size of the impact that can occur remotely, without privileges, and without interaction; an attack requirement is present Whether your service has a path that calls the decoder
Node.js announcement High Impact when an attacker can supply Brotli data decompressed with node:zlib Whether they can supply it
Google 1.2.1 release, changelog The fixed version and a one-line description Which software maps to which number

Third, the Debian tracker’s description we saw earlier points the same way. It says the vulnerable side is library 1.0.3 through 1.2.0 and that users should move up to the right branch among 1.0.10, 1.1.1, and 1.2.1. Being inside this range and having code that calls that library are two separate conditions.

Verdict: cannot be said from these records alone. Confirmed exploitation tells you only “it was used somewhere,” not “my service was breached,” and a high score does not either. Whether your server was breached has to be judged from evidence on your side, such as crash records, process behavior, and logs. For the same reason, what the alert (AL26-027) from a public agency recommends is to fix internet-exposed systems that use the vulnerable component first; if you cannot fix right away, to stop affected applications from processing Brotli content from untrusted sources; and to watch for abnormal crashes or unfamiliar process behavior. You also cannot conclude it was an attack just because a process died during decompression, so look together at the runtime version, application logs, and the source of the compressed data.

Temporary mitigations do not replace the update. Here are three editorial notes for services that cannot upgrade. A service whose clients do not send Brotli request bodies can reject requests with Content-Encoding: br at the proxy (this does not block the response-side path). Code that calls external servers can set Accept-Encoding itself and leave out br to narrow the response-side path. Running the service under a low-privilege account narrows the scope of damage even if memory is corrupted. None of them removes the flaw itself.

Four things to check next

Across the seven claims, the remaining checks boil down to four. Each earlier section gave the reasons, so here we list only the items.

  • For each server, we listed the kinds of Brotli copies (official Node.js binary, apt-installed nodejs, curl and git using the system libbrotli, Python wheels, .NET shared and self-contained, container images)
  • For each copy, we checked with the value that fits its kind (for Node.js, whether zlib.constants has the new block switch error constant and its value is -17; for Python, the package version; for distribution copies, the fixed package number in the distribution's tracker)
  • The service process was restarted from the new binary or new image, and we re-read the same value inside that running container
  • We found the code that calls the decoder (request body decompression, automatic decompression in fetch and HTTP clients, direct use of BrotliStream), wrote down the exposure paths, and attached an upgrade date to anywhere we could not upgrade
#Node2Shell#CVE-2026-111497#Brotli#Node.js#KEV

Sources