Node2Shell (CVE-2026-111497) lets a single malicious compressed message corrupt the memory of an unpatched Node.js server, and Miggo’s security team traced it back to one line of code written in February 2018. It can crash the service, leak memory, and under the right conditions run an attacker’s code.

Brotli: The Compression You Didn’t Know You Switched On

Brotli is Google’s compression format. It shrinks web traffic so pages and APIs load faster, and most of the internet speaks it without anyone giving it a second thought.

Here is the part that matters for this story: Node.js doesn’t borrow Brotli from the operating system. It ships its own copy of Google’s C decoder, compiled right into the runtime. Every Node.js app with Brotli support has that decoder sitting in its process, ready to unpack whatever shows up.

And plenty shows up. Express 5 and body-parser 2 unpack Brotli request bodies automatically. Node’s built-in fetch(), axios, SuperAgent and got unpack Brotli responses automatically. Most teams never decided to use Brotli. Their framework decided for them.

For a sense of scale: in the year to 18 October 2026, npm served axios almost 5 billion times and Express 4.7 billion times, and the official node image on Docker Hub has passed 7 billion pulls.

So when that decoder can be tricked by the data it unpacks, every internet-facing Node.js service that handles compressed input is in play, whether the data arrives as a request or as a response the server went and fetched itself.

Node2Shell in 30 Seconds

  • What it is: a memory corruption bug in the Brotli decoder bundled with Node.js and Bun. Google rates it critical; Node.js rates it high.
  • What an attacker needs: no login. One malicious compressed request, or one malicious response your server fetches, is enough.
  • What happens: at minimum, the Node.js process hangs or crashes. Memory can leak too. Imperva’s researchers went further and ran their own code on the official Node.js image, through both an incoming request and a fetched response.
  • Is anyone exploiting it? Nobody has reported attacks in the wild as of 23 October 2026, and it isn’t on CISA’s Known Exploited Vulnerabilities list. Public proofs of concept are already out, though.
  • The fix: Node.js 22.23.4, 24.22.1 or 26.12.1, and Bun 1.4.4. Node.js 20 and other end-of-life versions get no fix.

How Miggo Traced It Back to 2018

OpenAI Codex Security (Khai Tran and Sylvie Mayer) found and reported the flaw. Ron Masas of Imperva Threat Research gave it the Node2Shell name. Google’s Brotli maintainers fixed the decoder, and Node.js maintainers Matteo Collina and Antoine du Hamel shipped the fix to every supported version.

Miggo’s security team chased a different question: how long has this door been open?

A long time. The bug arrived on 26 February 2018, tucked inside a bigger Brotli feature update. That update added one small shortcut: when a compressed stream claims to contain only one type of data block, the decoder simply answers “no.” Brotli 1.0.2, from November 2017, doesn’t have that line. Brotli 1.0.3, released four days after the change on 2 March 2018, does, and so does every Brotli release after it until the fix: nine in all. Node.js picked up the affected decoder when it first added Brotli support in January 2019, and every Node.js release with Brotli has carried it since.

The fix landed in Brotli’s public code on 27 February 2026: eight years and one day after the bug went in.

Eight years in plain sight: the bug went in on 26 February 2018, the fix went public on 27 February 2026, and patches shipped almost eight months after that.

Then came the practical question: where does this actually run? Miggo checked the Node.js version inside the images of every deployment running in its customers’ environments, compared it with the fixed releases, and flagged which affected services face the internet. Internet-facing is only half the picture: some exposed services never accept a compressed request at all. They fetch pages from outside websites, which is exactly the back door described below.

Inside the Bug

Here is the counterintuitive part: your server doesn’t have to accept a malicious request to get hit. If it builds a link preview, calls a webhook URL a customer configured, or fetches any URL an attacker can influence, the poisoned data arrives as the answer to a question your own code asked. Node.js unpacks it deep inside the process, where an inbound web application firewall (WAF) never looks.

What goes wrong

A Brotli stream is a series of chunks, and each chunk says up front how many kinds of data blocks it contains. A malicious stream says “just one kind,” then asks to switch to another kind anyway. By the stream’s own rules that’s impossible, so the decoder should stop right there.

It doesn’t. It keeps going with bookkeeping that no longer matches the data, and eventually writes past the end of its own buffer, scribbling over the memory it uses to track its own state. Scribbled-over state is how a crash becomes a hijack.

Why it happened: one word, two meanings

Inside the decoder, the helpers that handle block switches answer with a simple yes or no. “No” had always meant one thing: “I need more input, call me again.” When more data arrives, the decoder tries again. In its fastest code path, where all the input is already in hand, it doesn’t even check the answer.

The 2018 shortcut used that same “no” to mean something very different: “this stream is broken.” Nothing downstream could tell the two apart. So the decoder politely waited for more data, or ignored the answer altogether, and carried on with a stream it should have thrown away.

The fix gives the decoder a proper way to say “this stream is invalid,” and every block switch now stops the moment it hears it.

One word, two meanings: the decoder heard “wait for more data” when the stream was actually broken.

From zero to code execution

  1. The attacker finds a Node.js service that unpacks Brotli data the attacker controls: an Express 5 API, a Koa app, or a server that fetches URLs the attacker can supply.
  2. The attacker sends, or serves, a malformed Brotli stream. No login needed. One HTTP request will do.
  3. The decoder accepts the impossible block switch, keeps going, and writes past the end of its buffer into its own state.
  4. At minimum, the server process hangs or crashes. Memory contents can leak too.
  5. With the right memory layout, the corrupted state steers the decoder to the attacker’s code the next time it calls one of its own functions. Imperva pulled this off on the official Node.js 24.22.0 image for Linux on arm64, through both Express and fetch(), with no debugger or special build. It took multiple attempts.
  6. What happens next depends on the server. Nobody has publicly shown persistence or lateral movement on a real target.
From one compressed request to code execution, and what the attacker gets along the way.

A quick reality check, because severity deserves honesty. Imperva is the only team to report code execution on an official Node.js build, and it didn’t work every time. The other proofs of concept on GitHub reach code execution only in deliberately weakened lab setups. Knocking a server over, on the other hand, is easy: one public proof of concept freezes the decoder at 100% CPU with an 86-byte stream.

Are you exposed?

Probably, if you run:

  • Node.js 22, 24 or 26 older than 22.23.4, 24.22.1 or 26.12.1, or any end-of-life version (20 and earlier, 21, 23 and 25).
  • Bun older than 1.4.4.
  • Google’s Brotli library from 1.0.3 up to the fixed 1.0.10, 1.1.1 and 1.2.1 releases, plus anything that bundles it.
  • Apps that unpack Brotli requests: Express 5, body-parser 2 and Koa. On a vulnerable runtime, a plain app.use(express.json()) is enough. Upgrading Express won’t help, because the bug lives in Node.js.
  • Apps that unpack Brotli responses: anything using Node’s built-in fetch() (which asks secure servers for Brotli by default), axios, SuperAgent, got, or Next.js server-side fetches of outside content.
  • Downstream packages: as of 23 October 2026, Debian and Ubuntu hadn’t shipped fixes, and Electron’s latest release, v45.0.0, still bundles the vulnerable Node.js 24.22.0.

You’re in the clear if:

  • You run Node.js 22.23.4, 24.22.1, 26.12.1 or later, or Bun 1.4.4 or later.
  • Your server never unpacks request bodies and never fetches outside content. A bare Node.js HTTP server doesn’t decode Brotli on its own.

The riskiest setup is a service that fetches content an attacker controls: link previews, webhooks, crawlers, proxies and server-side rendering. Blocking compressed requests at the front door does nothing here, because this attack walks in through the back.

The front door and the back door: a WAF only sees one of them.

The fix, and the fine print

The patched Node.js releases pull in Google’s upstream fix, which makes the decoder reject the impossible block switch and stop. Brotli 1.0.10, 1.1.1 and 1.2.1 and Bun 1.4.4 carry the same fix, and Imperva confirmed the patched Node.js rejects the inputs that ran code on the old one.

Now the fine print:

  • Patching the OS doesn’t patch Node.js. Node.js and Bun carry their own copy of Brotli, so updating the system’s Brotli package leaves them vulnerable. Debian flips this around: its Node.js uses the system library, so it stays exposed until that package is fixed.
  • Old images stay old. The official Node.js Docker images picked up the fix on 21 and 22 October 2026. Anything built before then keeps the bug until someone rebuilds it.
  • The fix sat in public for almost eight months. It landed in Brotli’s public code in February 2026, but no release shipped it until October. Projects that copied Brotli in between may have missed it.
  • End-of-life means no fix, ever. Node.js 20 reached end of life on 30 April 2026, and Node.js says end-of-life versions are always affected when a security release ships.

Short of upgrading, your options are partial. Rejecting Brotli-compressed requests at the proxy shrinks the front-door risk, and Imperva and Vercel have both added WAF rules for known exploit attempts. Vercel’s bulletin says plainly that its rules don’t replace patching. None of that touches the back door. Axios lets you switch off automatic decompression. Node’s built-in fetch() doesn’t: it unpacks any Brotli response a server sends, even if your request never asked for Brotli. For those apps, upgrading is the only fix.

Miggo WAF Copilot deployed blocking rules directly into customers’ WAFs, and version 1.23 of Miggo’s High Emerging Threats rule set for AWS WAF blocks the public Node2Shell exploit payloads while leaving ordinary Brotli traffic alone. Because no WAF can see a re-encoded payload or a malicious response to the server’s own requests, Miggo’s runtime sensor detects exploitation inside the application. Together, these steps gave customers time to test and roll out the upgrade safely instead of remaining without protection.

What Miggo Recommends

Mitigate if you can’t patch immediately. If you can’t upgrade today, reject Brotli-compressed requests at the reverse proxy or load balancer, and turn off automatic decompression in axios. If you run AWS WAF with Miggo’s High Emerging Threats rule set, move to version 1.23 or later, which blocks the known exploit payloads at the front door. For anything that calls fetch() on URLs an attacker can influence, the upgrade is the only fix.

Upgrade the runtime today. Move to Node.js 22.23.4, 24.22.1, 26.12.1 or later, and Bun 1.4.4 or later. On Vercel, Node.js 22 and 24 projects pick up the patch automatically as Vercel rolls it out, and Node.js 20 projects need to move to 22 or 24 and redeploy.

Retire end-of-life Node.js. Versions 20, 21, 23 and 25 will never get this fix.

Rebuild, don’t just pull. Rebuild every container image built on a node base image before 22 October 2026, and check pinned images, Helm charts and apps that embed Node.js, like Electron apps.

Patch standalone Brotli too. Update libbrotli and its language bindings to 1.0.10, 1.1.1 or 1.2.1, and keep an eye on the Debian and Ubuntu packages.

Find the decompression you didn’t choose. List the services that unpack Brotli automatically, starting with anything that fetches URLs an attacker can influence.

Treat decompression crashes as a clue. Imperva notes that unexpected process exits during decompression can help you decide what to investigate first, though a crash alone doesn’t prove an attack.

The Takeaway

Node2Shell comes down to one small word doing two jobs. For eight years, a “no” that was supposed to mean “wait for more data” also meant “this data is broken,” and the decoder couldn’t tell which. That decoder lived inside every Node.js release with Brotli, out of reach of operating system updates and out of sight of the firewalls watching the front door.

The bigger lesson reaches beyond Brotli. The riskiest code in a modern app is often code nobody on the team picked: a C library baked into the runtime, switched on by a framework default, and reached through traffic the app asked for itself. A package manifest can’t tell you where that code runs. A runtime view can: which services actually run a vulnerable Node.js, which of them face the internet, and which fetch content an attacker can shape. That is how Miggo turned Node2Shell into a prioritized fix list for its customers, and it is how teams will find the next bug hiding in code nobody chose.

<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/Flip.min.js"></script>

<script>
  document.addEventListener("DOMContentLoaded", (event) => {
    gsap.registerPlugin(Flip);
    const state = Flip.getState("");
    const element = document.querySelector("");
    element.classList.toggle("");
    Flip.from(state, {
      duration: 0,
      ease: "none",
      absolute: true,
    });
  });
</script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/Flip.min.js"></script>

<script>
  document.addEventListener("DOMContentLoaded", (event) => {
    gsap.registerPlugin(Flip);
    const state = Flip.getState("");
    const element = document.querySelector("");
    element.classList.toggle("");
    Flip.from(state, {
      duration: 0,
      ease: "none",
      absolute: true,
    });
  });
</script>