SevyDevy

Guide · Backend

October 2026 Brotli Vulnerability Explained: Find the Decoder Your App Runs

22 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • Brotli is a compression format servers use for web pages, and a decoder is the code that unpacks it. The Brotli vulnerability, CVE-2026-111497, lets bad compressed data make Google's decoder write past the end of a small internal buffer. The advisories warn of crashes, leaked memory and possible remote code execution. Fixed releases are Brotli 1.2.1, 1.1.1 and 1.0.10.
  • In Python, urllib3, requests, httpx and aiohttp unpack Brotli automatically whenever the brotli or brotlicffi package is installed. Your own code never has to import it.
  • In our tests, all four Python clients we tried decoded Brotli even when the request asked for no compression. Changing a header does not switch the decoder off.
  • Upgrade the PyPI packages to brotli 1.2.1 and brotlicffi 1.2.1.0, Node.js to 22.23.4, 24.22.1 or 26.12.1, and track your Linux distribution's libbrotli fix for curl and git.
  • Beginners can stop after the patching section. Engineers then get how the bug works and how a version lies: pip saying 1.2.1 over a 1.1.0 decoder, and patched Node.js builds whose Brotli number did not change.

What goes wrong when an HTTP client quietly decompresses a stranger's data?

The Brotli vulnerability turns a routine step, unpacking a compressed HTTP body, into a memory-corruption bug. Your code never calls the decoder by name, yet the HTTP library runs it on bytes that someone else chose.

Picture a common setup. A Python service fetches supplier price feeds over HTTPS with requests and receives order webhooks through an aiohttp server. It runs in a container built on Ubuntu 24.04 that also ships curl and git. The setup is made up, but every client behaviour below was tested on the versions named.

Nobody on the team ever wrote import brotli. The package arrived as an optional extra of another dependency, an add-on you request in square brackets, such as pip install aiohttp[speedups]. Since then, every response and every webhook marked as Brotli has gone through a decoder written in C, the low-level language where a write past a buffer can corrupt memory.

The fix shipped on 21 October 2026 in both Brotli and Node.js security releases, and the bug picked up the nickname Node2Shell. It is not only a Node.js bug. The same decoder code sits inside Python packages, system libraries, curl, git and Node.js itself, and each copy has to be found and patched on its own.

Which words do you need before reading the Brotli advisory?

You need seven words. Each one comes with a plain meaning and how it shows up in the running example.

  • Brotli: a compression format from Google, used mainly to shrink web pages and API responses. Servers label such a body with the header Content-Encoding: br.
  • Decoder: the code that turns compressed bytes back into the original bytes. This bug lives only in the decoder, not in the compressor.
  • Accept-Encoding: the request header where a client lists the formats it can unpack, for example gzip, deflate, br.
  • Out-of-bounds write: code writes data past the end of the memory it was given, onto whatever sits next to it. That can crash a process or let an attacker steer it.
  • Binding: a small Python layer that lets Python code call a C library. The brotli package is Google's binding, and brotlicffi is a second binding written with the CFFI tool. Both wrap the same C decoder.
  • Wheel: the ready-built package file pip downloads from PyPI. A wheel for brotli or brotlicffi contains compiled C code, including its own copy of the decoder.
  • Bundled or system copy: a bundled copy is compiled inside a package or program. A system copy is a shared library such as libbrotlidec.so.1 that many programs load from the operating system.

What is the Brotli vulnerability CVE-2026-111497?

The Brotli vulnerability CVE-2026-111497 is a bug in Google's Brotli decoder where malformed compressed input can write past the end of an internal eight-byte buffer. Anyone who can make your program decode Brotli data they control can trigger it.

The GitHub advisory GHSA-9v8h-qqgm-4m35 rates it Critical, with a CVSS v4 base score of 9.2. CVSS is the common 0 to 10 severity scale. The Node.js project rates the same bug High for Node.js.

Both list a crash or denial of service, leaked process memory and possible remote code execution. Remote code execution means an attacker runs their own instructions inside your process. The GitHub advisory's description names Brotli 1.0.3 through 1.2.0 as affected, and its entry is marked unreviewed.

Where the fixed decoder ships, as of 24 October 2026. Dates come from each project's release page or PyPI upload time.
Where the decoder livesVulnerableFixed inReleased
Google Brotli C library1.0.3 to 1.2.0 (GitHub advisory description)1.2.1, 1.1.1, 1.0.1021 October 2026
PyPI brotli (Google's Python binding)1.2.0 and older1.2.122 October 2026
PyPI brotlicffi (CFFI binding)1.2.0.2 and older1.2.1.022 October 2026
Node.js zlib and fetch()22.x, 24.x and 26.x before the fix22.23.4, 24.22.1, 26.12.121 October 2026
Debian and Ubuntu libbrotli1Listed as affected or under evaluationNo fixed package listed yetCheck your distribution's tracker

The Node.js advisory adds that end-of-life release lines are always affected when a security release occurs. If you run one from nodejs.org, move to a fixed 22, 24 or 26 release. A distribution's nodejs package is different: Debian's uses the system Brotli library, so its fix comes with libbrotli1.

SevyDevy's Node.js October 2026 release guide covers the Node.js upgrade in depth. This guide follows the decoder itself. First question: which Python clients run it without being asked?

Which Python HTTP clients decode Brotli automatically in 2026?

Every popular Python client we tested decodes Brotli on its own once a Brotli binding is installed. We tested requests 2.34.2, urllib3 2.8.0, httpx 0.28.1 and aiohttp 3.14.5 on Python 3.13, against a local server that sent harmless Brotli text.

Tested on 24 October 2026 with brotli 1.2.0 installed and a localhost server. "Unasked br" means the server sent br after the client asked for identity. The last column was checked with both bindings installed.
ClientAccept-Encoding it sentDecoded normal brDecoded unasked brPrefers which binding
requests 2.34.2gzip, deflate, brYesYesbrotlicffi first (through urllib3)
urllib3 2.8.0, urllib3.request()identityNot offered, so no br sentYesbrotlicffi first
httpx 0.28.1gzip, deflate, brYesYesbrotli first
aiohttp 3.14.5 clientgzip, deflate, brYesYesbrotlicffi first

Without any Brotli binding, the picture changes. requests and httpx sent only gzip and deflate, and urllib3 sent identity. All three handed back the raw compressed bytes when a server sent br anyway. The aiohttp client raised its own ClientResponseError with status 400, saying it cannot decode brotli.

Asking for no compression does not turn the decoder off

A server can send Content-Encoding: br even when the client never asked for it. In our tests all four clients still ran the decoder on that body. So a malicious or compromised server you fetch from controls whether the decoder runs, not your Accept-Encoding header.

Servers decode request bodies too

Exposure is not only outbound. An aiohttp web server read a request body that a client sent with Content-Encoding: br, and request.read() returned the unpacked bytes. In the running example, that is the webhook endpoint: anyone who can reach it can feed the decoder.

Two bindings, two different choices

When both bindings are installed, the clients disagree. urllib3, and so requests, and aiohttp import brotlicffi first. httpx imports brotli first. A service using requests and httpx side by side therefore runs two separate copies of the decoder, and both need the fix.

Which code unpacks a Brotli body in the running example

Two entry points, outbound fetches and inbound webhooks, both end in a C decoder. Notice the last box: the decoder can sit inside the wheel or in the operating system.

Diagram connections:

  • Supplier price server to requests.get() (HTTPS response)
  • requests.get() to urllib3 response
  • urllib3 response to brotlicffi or brotli
  • Webhook sender to aiohttp web server (HTTP request)
  • aiohttp web server to brotlicffi or brotli
  • brotlicffi or brotli to Brotli C decoder
  • Supplier price server: Supplier → requests: body with br
  • requests.get(): requests → urllib3: hands off the raw body
  • urllib3 response: urllib3 → binding: decode br
  • Webhook sender: Sender → aiohttp: POST with br body
  • aiohttp web server: aiohttp → binding: decode request body
  • brotlicffi or brotli: Binding → decoder: C call
  • Brotli C decoder: Bundled in the wheel or system libbrotlidec

How do you find which copy of Brotli each Python app loads?

Ask the running Python, not the requirements file. Brotli usually arrives through an extra, and the usual dependency tools hide that link, so inspect the environment the app actually starts in.

  1. List the bindings

    Run pip list and look for brotli and brotlicffi. Run it inside the container or virtual environment the app uses, not on your laptop.

  2. Find who pulled them in

    Install pipdeptree, a tool that prints which package needs which, and run pipdeptree -r -p brotli -x active. With pipdeptree 4.2.9 it listed aiohttp, httpx and urllib3 in our test. Plain pip show brotli showed an empty Required-by line.

  3. Know that tool's blind spot

    pipdeptree lists a package only when its whole extra is installed. With aiohttp and brotli but not the rest of aiohttp[speedups], aiohttp vanished, yet it still decoded br. Trust the script in the next step over the tree.

  4. Ask each client which binding it uses

    Run the short script below with the app's own Python. It prints the package versions, the runtime decoder version and the binding each client picks.

  5. Check what the binary links

    On Linux, run ldd, a command that lists the shared libraries a binary loads, on each printed file. Run it inside the container. No libbrotli line means the decoder is compiled inside.

  6. Read a libbrotli line correctly

    A libbrotlidec.so.1 line means the operating system's library does the decoding, so the Python package version no longer tells you what runs.

  7. Check the system library

    For a system copy, read the distribution package version, for example with dpkg -l libbrotli1 on Debian or Ubuntu, then compare it with your distribution's security tracker.

python
# which_brotli.py: run it with the same Python your app uses
import importlib
from importlib.metadata import version, PackageNotFoundError

for pkg in ("brotli", "brotlicffi"):
    try:
        print(f"{pkg}: package {version(pkg)}")
    except PackageNotFoundError:
        print(f"{pkg}: not installed")

try:
    import brotli, _brotli
    print("brotli decoder reports:", brotli.__version__)
    print("brotli binary:", _brotli.__file__)
except ImportError:
    pass
try:
    from brotlicffi import _brotlicffi
    print("brotlicffi binary:", _brotlicffi.__file__)
except ImportError:
    pass

# Internal module names, true for urllib3 2.8.0, httpx 0.28.1, aiohttp 3.14.5
checks = [("urllib3 and requests", "urllib3.response"),
          ("httpx", "httpx._decoders"),
          ("aiohttp", "aiohttp.compression_utils")]
for client, modname in checks:
    try:
        mod = importlib.import_module(modname)
    except ImportError:
        continue
    used = getattr(mod, "brotli", None)
    print(f"{client}: br decoded by", used.__name__ if used else "nothing")

On our patched test environment it printed brotli package 1.2.1, brotlicffi package 1.2.1.0 and a decoder report of 1.2.1. It also showed requests and aiohttp decoding with brotlicffi while httpx used brotli. Those module names are internal, undocumented as of 24 October 2026, so pin your versions before relying on them.

How do you patch Brotli and what can break after the upgrade?

Upgrade every copy, rebuild the images, and then prove the running process loads the new code. The brotlicffi upgrade can reject data it used to accept, as the note after the steps explains. A requirements file change alone does not reach a container that was built last week.

  1. Pin the fixed bindings

    Set brotli==1.2.1 and brotlicffi==1.2.1.0, or at least those minimums, in every requirements or lock file that installs them, including indirect installs through extras.

  2. Upgrade the runtimes

    Move Node.js to 22.23.4, 24.22.1 or 26.12.1. Install your distribution's libbrotli update when it appears, then restart the services that load it.

  3. Rebuild and redeploy

    Rebuild container images and any wheels you build from source, so no old copy survives in a cached layer.

  4. Verify inside the running environment

    Run the check script and ldd inside the deployed container. Expect brotli package 1.2.1 and decoder report 1.2.1 on a plain wheel.

  5. Scan, and know the scanner's limits

    pip-audit 2.10.1 flagged brotli 1.2.0 and brotlicffi 1.2.0.2 and passed the fixed pins on 24 October 2026. It cannot see a source build that links an old system decoder, covered below.

One behaviour change can surprise you. brotlicffi 1.2.1.0 now raises an error when extra bytes follow a complete Brotli stream, as its changelog says. In our test 1.2.0.2 accepted four trailing bytes, 1.2.1.0 rejected them, and the brotli package rejected them in both versions.

Scanner data moved fast. Aikido's 23 October post said pip-audit would not flag these versions until the advisory databases mapped the CVE to PyPI packages. By 24 October, pip-audit did flag them in our test.

If you cannot upgrade today and do not need Brotli, uninstalling both bindings removes the Python decoder path. Our tests above show what clients do then: raw bytes from three clients and an error from aiohttp.

That workaround has a cost. Code that expects text or JSON from requests or httpx then gets compressed bytes and fails later, often far from the HTTP call. The next install of an extra such as httpx[brotli] also brings the binding back.

A second stopgap covers the webhook side only. Set your reverse proxy to reject requests that carry Content-Encoding: br. It does nothing for responses your service fetches, such as the supplier feeds.

How does an invalid block switch become an out-of-bounds write?

The decoder mistook a broken stream for an unfinished one. It kept asking for more input, and each extra byte went into a fixed eight-byte holding buffer until writes ran past its end.

A Brotli stream is cut into blocks, and a block switch is a small command inside the stream that says which kind of block comes next. The Node.js advisory says an invalid block switch was reported as a temporary need for more input instead of a permanent format error.

Think of a parcel counter with an eight-slot tray. The tray is the eight-byte buffer, and the clerk is the decoder. A broken label reads like "the rest of this parcel is coming", so the clerk keeps adding pieces. Where the picture breaks: a real clerk would notice a full tray, while compiled C code simply writes into the next object in memory.

The 1.2.0 source shows how. When input may run short, the decoder uses a slower, careful mode. In that mode, any failed step in the command-decoding loop becomes the status NEEDS_MORE_INPUT.

The step that reads a block switch returned that same plain failure for an impossible switch. So the decoder treated nonsense as data still arriving.

When input runs short, the streaming loop copies one more byte into a holding area called buffer, which is eight bytes long, and tries again. The only guard on that copy is a debug check, and release builds compile debug checks away.

Why a broken block switch kept the decoder writing

Follow the No arrow. Before 1.2.1 a bad switch looked like a short read, so the loop kept adding bytes to an eight-byte buffer. The fix sends it to the error box instead.

Diagram connections:

  • Brotli bytes arrive to Read block switch
  • Read block switch to Is the switch valid?
  • Is the switch valid? to Decoded body to app (Yes)
  • Is the switch valid? to Before fix: need more input (No, before 1.2.1)
  • Is the switch valid? to 1.2.1: error code -17 (No, from 1.2.1)
  • Before fix: need more input to Add byte to 8-byte buffer
  • Add byte to 8-byte buffer to Read block switch (retry with one more byte)
  • Add byte to 8-byte buffer to Write past the buffer (after eight bytes)
  • Brotli bytes arrive: Response or request body marked br
  • Read block switch: Which kind of block comes next
  • Is the switch valid?: The question the old code got wrong
  • Before fix: need more input: Broken data treated as unfinished data
  • Add byte to 8-byte buffer: Only a debug check guards the size
  • Write past the buffer: Neighbouring decoder memory is overwritten
  • 1.2.1: error code -17: New BLOCK_SWITCH format error
  • Decoded body to app: Normal data carries on as before

The upstream fix is commit 0ed4268, titled "Detect and report invalid stream earlier", dated 27 February 2026. It makes the block-switch steps return a real status and adds a new error code, BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH, with the value -17.

The commit is dated about eight months before the October releases, and its title does not mention security. The 1.2.0 release, which pip installed until 22 October, does not contain it. Track releases and advisories, not commit dates.

Why can the reported Brotli version be misleading?

Because three different numbers exist: the package version pip knows, the version the decoder reports, and the code that was actually patched. They agree on a plain PyPI wheel, and drift apart as soon as a build uses the system library or a vendor cherry-picks the fix. A cherry-pick copies one fix commit into an older copy of the code, without taking the whole new release.

The first three rows were run on Ubuntu 24.04 with libbrotli1 1.1.0-2build2 on 24 October 2026. The last row is the general rule for distribution packages.
SetupWhat pip or the runtime saysDecoder that really runsPatched?
brotli 1.2.1 built with USE_SYSTEM_BROTLI=1pip: 1.2.1; brotli.__version__: 1.1.0System libbrotlidec 1.1.0No, until the system library is fixed
brotlicffi 1.2.1.0 built with USE_SHARED_BROTLI=1pip and __version__: 1.2.1.0; pip-audit: cleanSystem libbrotlidec 1.1.0No, and nothing in Python says so
Node.js 22.23.4 official buildprocess.versions.brotli: 1.1.0Bundled copy with the fix cherry-pickedYes, despite the old number
Distribution package such as libbrotli1Upstream number plus a distro suffixWhatever patches the distro appliedOnly the security tracker can say

The PyPI wheels we installed on Linux x86_64 carry their own decoder. ldd showed no libbrotli for either wheel. nm, a command that lists the functions inside a binary, showed the main decoding function defined inside the module. Upgrading the wheel is therefore enough for those two copies.

A source build can link the old system library

Both bindings can be built against the operating system's Brotli instead of their bundled copy. The brotli package does this when USE_SYSTEM_BROTLI=1 is set at build time, and brotlicffi when USE_SHARED_BROTLI=1 is set.

brotli at least tells the truth at run time, because brotli.__version__ is read from the decoder itself. brotlicffi's __version__ is a fixed string in its Python code, and the binding exposes no call that asks the decoder for its version.

Searching the brotlicffi binary for the new error name is also misleading. Our shared build still contained BLOCK_SWITCH, because it compiles against the bundled headers while calling the system decoder. Only ldd and the system package tell you which decoder runs.

A cherry-picked fix keeps the old number

Node.js fixed the bug by cherry-picking commit 0ed4268 into its bundled copy, so the bundled version number did not move. Our tests showed 22.23.3 and 22.23.4 both report 1.1.0, while 24.22.0, 24.22.1, 26.12.0 and 26.12.1 all report 1.2.0.

The Node.js release notes also list a change that exposes the new error code. On the fixed builds, zlib.constants.BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH equals -17, and it is missing on the earlier ones. Treat it as a hint and check the Node.js version itself.

What about curl, git and Node.js on the same machine?

They each decode Brotli through their own route, and none of them is fixed by upgrading a Python package. On our Ubuntu 24.04 machine, curl 8.5.0 and git 2.43.0 both reach the system libbrotlidec through libcurl.

  • curl command line: it decoded br only with --compressed. Without the flag it sent no Accept-Encoding and printed the raw 24 compressed bytes. libcurl's documentation says an unset accept-encoding option leaves the body as received.
  • git over HTTP: git's http.c sets the libcurl encoding option to an empty string, which the libcurl documentation says means every encoding the build supports. git 2.43.0 asked our test server for deflate, gzip, br and zstd.
  • Node.js: the official builds bundle their own decoder, and fetch() in 22.23.4 decoded an unasked br response in our test. Debian's tracker notes that Debian's nodejs package uses the system library instead.
  • Other system packages: on our machine libcurl4t64, libcurl3t64-gnutls and libfreetype6 all depend on libbrotli1. A container image can carry the library even when your code never mentions Brotli.

For the running example this splits the work in two. Upgrading the Python packages fixes requests and aiohttp. The curl and git inside the same image wait for Ubuntu's libbrotli1 update, which was still listed as needing evaluation as of 24 October 2026.

How do you explain the Brotli vulnerability in a system design interview?

Treat it as a question about trust boundaries and inventory: where untrusted bytes meet native code, and how you know every copy of that code is patched. Use this frame.

  1. Name the boundary

    Compressed bodies from the internet reach a C decoder before any of your validation runs. Both outbound fetches and inbound requests cross that line.

  2. Inventory the copies

    List bundled copies in wheels and runtimes, plus system libraries used by curl, git and font code. A software bill of materials, a list of every component and version, should include bundled C code.

  3. Verify at run time

    Check the loaded binary and its links, because package versions and reported library versions can both mislead after rebuilds and cherry-picks.

  4. Shrink the blast radius

    Decompress only where needed, reject Content-Encoding on endpoints that never need it, and run parsers of untrusted input in processes with few privileges.

  5. Close the loop

    Add a dependency scanner to CI, watch for decoder crashes, and rebuild images when distribution fixes land.

Interviewers listen for the difference between "we bumped the version" and "we proved the running process loads fixed code". The second answer shows you understand bundled and system libraries.

Questions people ask

Is my Python app affected if it never imports brotli?
Yes, it can be. urllib3, requests, httpx and aiohttp import a Brotli binding on their own when one is installed, then decode br bodies automatically. Optional extras such as httpx[brotli], urllib3[brotli] and aiohttp[speedups] install brotli on standard CPython, and brotlicffi on other interpreters such as PyPy. Run pip list inside the real environment and look for brotli or brotlicffi. If either is below 1.2.1 or 1.2.1.0, upgrade it.
Does sending Accept-Encoding: identity protect my client?
No. In our tests requests, urllib3, httpx and aiohttp all decoded a br body that the server sent after the client asked for identity. The header is a request, not a filter. Only a fixed decoder, or having no Brotli binding installed, keeps the vulnerable code from running on that response.
Can I trust brotli.__version__ to tell me I am patched?
Partly. It reports the version of the decoder actually loaded, so a source build linked to an old system library shows that old number, which is useful. It cannot show a distribution's backported patch, because distributions usually keep the upstream number. brotlicffi's __version__ only names the Python package, so check ldd as well.
What is Node2Shell?
It is a nickname, not a name from Google or the CVE record. Aikido, a security vendor, describes CVE-2026-111497 as dubbed node2shell. It points to the same bug and the same fixed Brotli releases as every advisory here. Search for the CVE number, not a nickname, to find official advisories.
Will pip-audit or Dependabot catch the vulnerable versions?
pip-audit 2.10.1 flagged brotli 1.2.0 and brotlicffi 1.2.0.2 on 24 October 2026, using PyPI's vulnerability data. We did not test Dependabot. The GitHub advisory page said that day that Dependabot alerts are not supported for it, because it lists no package. pip-audit and other Python dependency scanners do not see system libraries. Trivy's documentation says it detects installed OS packages in container images, so such a scanner can flag libbrotli1 from your distribution's security data.
Has anyone exploited the Brotli vulnerability in the wild?
Aikido's 23 October 2026 post, citing The Hacker News, said that for Node.js no exploitation in the wild had been reported as of 22 October. We found no report either way for the Python packages. Absence of reports is not proof of safety. The official advisories describe possible remote code execution, so treat the upgrade as urgent rather than waiting for exploitation news.
Do I need to patch if my code only compresses data with Brotli?
The bug is in the decoder file, c/dec/decode.c, and the fix changes only decoder code and the decoder header. Code that only compresses does not run that path. In practice the same package or library provides both directions, so upgrading is still the simple and safe choice.

Sources and further reading

Practise this with Lala

Practise