|

Node.js, October 2026: Node2Shell and nine other flaws, finding every Node you run

On 21 October 2026, the same day as the PHP2Shell fixes, the Node.js project published security releases for its three maintained lines: 22.23.4, 24.22.1 and 26.12.1. They fix ten flaws, one of which, CVE-2026-111497, was quickly nicknamed “Node2Shell”: an out-of-bounds write in the Brotli decoder Node bundles, remotely exploitable to run code. I’ve already devoted…

On 21 October 2026, the same day as the PHP2Shell fixes, the Node.js project published security releases for its three maintained lines: 22.23.4, 24.22.1 and 26.12.1. They fix ten flaws, one of which, CVE-2026-111497, was quickly nicknamed “Node2Shell”: an out-of-bounds write in the Brotli decoder Node bundles, remotely exploitable to run code. I’ve already devoted a post to this flaw from the Brotli library’s angle. This one takes Node.js’s angle: what a Node executable actually contains, through which paths an application ends up decompressing hostile data, what the other nine flaws fix, and how I found every copy of Node on my servers, including one that will never be fixed.

The ten flaws

CVESeverityWhat happensLines
CVE-2026-111497high (9.2 per Google)out-of-bounds write in the bundled Brotli decoder (“Node2Shell”)22, 24, 26
CVE-2026-105482highdns.resolve() truncates a name at the first NUL byte22, 24, 26
CVE-2026-105483highcertificate check bypassed with an IP address written in fullwidth digits24, 26
CVE-2026-106069highcertificate check bypassed when the host “loosely” resolves to an IP (leading zeros)22, 24, 26
CVE-2026-105481mediumkeep-alive connection: a response that arrived before the request is taken as its response22, 24, 26
CVE-2026-106072mediumnon-canonical IPv4 addresses authenticated by a certificate DNS name22, 24, 26
CVE-2026-106074mediumrandomUUID() entropy caches frozen into startup snapshots22, 24, 26
CVE-2026-106070lowZipFile returns attacker-controlled bytes after an invalid header26
CVE-2026-106071lowpermission model: a wildcard grants more than intended26
CVE-2026-106073lowpermission model bypassed through fs.realpathSync()22, 24, 26

Only one allows running code. Three others concern TLS certificate verification or DNS names, that is, the question “am I really talking to the server I think I am?”. The rest are narrower. The fixes come with updates to bundled dependencies: c-ares (the DNS resolver), OpenSSL, Undici (the HTTP client behind fetch()), SQLite, and the list of certificate authorities, from which a compromised intermediate authority was removed.

What a Node executable is

node -p "process.versions" | grep -E "node|v8|uv|brotli|zlib|openssl|ares|undici|llhttp"
  node: '26.13.0',
  v8: '14.6.202.34-node.40',
  uv: '1.52.1',
  brotli: '1.2.0',
  zlib: '1.3.2.1-motley-456ae73',
  openssl: '3.5.9',
  ares: '1.34.9',
  undici: '8.12.2',
  llhttp: '9.4.3',

A Node executable isn’t just a JavaScript interpreter. It’s an assembly: Chrome’s V8 engine, the libuv I/O library, OpenSSL, zlib, Brotli, c-ares, the llhttp HTTP parser, the Undici client. The process.versions command gives the inventory. Official binaries and NodeSource’s compile all of that into the executable. Some distributions, on the contrary, build Node to use the system libraries (that’s the case for Red Hat’s Node and its derivatives, which loads the system libbrotli).

That difference decides who fixes what. With a Node that bundles Brotli, only a new Node version fixes the flaw, and updating libbrotli is useless. With a Node using the system library, it’s the reverse: updating libbrotli fixes it, provided you restart the process. Before knowing whether you’re protected, you must know which Node you run.

Node2Shell: where the data comes in

Brotli’s defect lies in streaming decoding: faced with an invalid block-switch instruction, the decoder answered “I need more data” instead of “this stream is invalid”. So it kept accumulating the following bytes in a small eight-byte internal buffer, and eventually wrote past it. I detail that mechanism in a separate post. What matters here is knowing when a Node application decompresses Brotli coming from outside. There are three paths.

  • direct use of node:zlib: zlib.brotliDecompress() or createBrotliDecompress() applied to received data (uploaded files, queue messages, archives);
  • fetch(): the built-in HTTP client automatically decompresses a Content-Encoding: br response. An application calling a user-supplied URL (link preview, webhook, import by URL) can be targeted by a malicious server. Here, it’s the client under attack;
  • request bodies: with Express 5, the body-parser 2 module decompresses by default request bodies announced as compressed, Brotli included. Any client can send a POST with Content-Encoding: br and a body crafted for the flaw. It’s the most direct path for an exposed server.

Check in the code, not in the documentation

# body-parser 2.3.0 (Express 5), lib/read.js
    case 'deflate': …
    case 'gzip':    …
    case 'br':
      return zlib.createBrotliDecompress()
# body-parser 1.20.6 (Express 4) : only deflate, gzip, identity

On my servers, two Node applications receive web traffic: Etherpad (collaborative text editor) and Uptime Kuma (monitoring). Rather than trusting version numbers, I read the body-parser code installed with each. Etherpad uses Express 5 and body-parser 2.3.0, which decompresses Brotli: it was exposed through the third path. Uptime Kuma uses Express 4 and body-parser 1.20.6, which only knows gzip and deflate: that path didn’t exist for it.

The stopgap, and the hole I found in it

curl -s -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Encoding: br' --data-binary x https://pad.example.org/
302    # before: the request passes the web server (stopped further on by authentication here)
415    # after: the web server refuses the compressed body

Besides the Node update, I had added to the web server a rule refusing any Brotli-compressed request body (code 415), in a common file included by every site. While preparing this post, I tested that rule site by site. On Etherpad’s site, the test request didn’t get 415 but a redirect to the login page: that site’s file didn’t include the common directory. It wasn’t the only one. Out of about fifty sites across three servers, eleven on one and two on another had been written without that include line, often because they’d been created by Ansible roles other than the one managing ordinary sites. So the measure didn’t apply everywhere. Once the line was added to the thirteen templates and deployed, the test returned 415 everywhere.

Here, the consequence was limited: Etherpad sits behind authentication, and its Node had been fixed since the day after publication. But the lesson holds for any mitigation: consider it applied only after testing it on every entry point, not after writing the rule.

The other flaws: interpretation differences

const tls = require('node:tls');
// the presented certificate carries  subjectAltName: DNS:127.0.0.1
tls.checkServerIdentity('127.0.0.1', cert);   // fullwidth digits: compared as a NAME
tls.checkServerIdentity('0127.0.0.1', cert);     // octal for the URL (87.0.0.1), decimal for some resolvers

Three of the serious flaws share one cause: two components reading the same string differently. When a TLS client connects, it checks that the presented certificate matches the requested host: either a DNS name or an IP address, which are checked under different rules. So tls.checkServerIdentity() must first decide whether the host is an IP address.

  • fullwidth digits (CVE-2026-105483): 127.0.0.1, written with “wide” Unicode digits, wasn’t recognised as an IP address, so it was checked as a name. A certificate carrying DNS:127.0.0.1 or DNS:*.0.0.1 could then be accepted;
  • leading zeros (CVE-2026-106069): for URL parsing rules, 0127.0.0.1 is octal, so 87.0.0.1; for some systems’ resolver, it’s decimal, so 127.0.0.1. The certificate is checked for one address, the connection goes to another;
  • non-canonical IPv4 forms (CVE-2026-106072): 0x7f.0.0.1, 2130706433 or 127.1 all mean 127.0.0.1 to the C library; Node normalised them then compared them with the certificate’s DNS names;
  • NUL in a DNS name (CVE-2026-105482): dns.resolve() passes the name to c-ares as a C string, which stops at the first null byte. attacker.example\0.trusted.example queries attacker.example, while the application may have validated the trusted.example suffix.

These flaws don’t give access to the server. In specific situations, they let a certificate or name be accepted that the application should have refused. They target applications connecting to third-party-supplied hosts and relying on TLS verification to protect themselves. Several are incomplete fixes of flaws from 2025 or early 2026: handling addresses and names remains slippery ground.

Two quieter flaws

  • startup snapshots (CVE-2026-106074): Node can save memory state after an application’s initialisation to start faster (--build-snapshot, single executable applications). If crypto.randomUUID() was called during that initialisation, its pool of random values is frozen into the snapshot: every process started from it produces the same identifiers, up to 127 UUIDs. Updating Node isn’t enough; snapshots must be rebuilt;
  • the permission model (CVE-2026-106071 and 106073): the --permission option restricts a program’s file system access. Two bypasses show it remains a guard against mistakes, not a security boundary against hostile code: that takes real isolation (dedicated user, SELinux, container).

Finding all your Nodes

# 1. every Node executable on disk, with its version and its Brotli
find / -xdev \( -path /proc -o -path /sys \) -prune -o -type f -name node -perm -u+x -print 2>/dev/null \
  | while read n; do echo "$n $($n --version) brotli=$($n -p process.versions.brotli)"; done
# 2. the running ones, and since when
ps -eo pid,lstart,args | grep -E '[n]ode( |$)|/node '
# 3. containers: each image has its own Node
podman ps --format '{{.Names}} {{.Image}}'
/usr/bin/node                                v26.13.0  brotli=1.2.0   # NodeSource, fixed
/opt/etherpad/nodejs/bin/node                v24.22.1  brotli=1.2.0   # bundled by Etherpad, fixed on 22/10
/usr/share/wazuh-dashboard/node/bin/node     v18.19.0  brotli=1.0.9   # Node 18 en fin de vie : jamais fixed

On my servers, the search found three executables. The system one comes from the NodeSource repository, version 26, declared directly in an Ansible role rather than installed by their setup script: it follows fixed releases automatically. Etherpad’s is a bundled copy, downloaded by the Ansible role with a pinned version and checksum: I moved it to 24.22.1 the day after publication, then changed the role so it checks the exact installed version. The third is the problem.

The Node nobody will fix

The Wazuh dashboard bundles Node 18.19.0. The 18 line has been end of life since April 2025: it gets no more fixes, for Brotli or anything else. Only the vendor, by shipping a dashboard release with a recent Node, can fix that copy. Meanwhile, I reduced exposure: the interface is only reachable from admin addresses, and Brotli-compressed request bodies are refused by the web server in front. An end-of-life Node copy bundled by a third-party product is the most common and hardest case: you can’t update it yourself without breaking the product.

What I take away

  • know how your Node is built: bundled or system libraries, the fix doesn’t come from the same place;
  • inventory every copy, including those of applications installed in /opt, containers and third-party products;
  • follow the real paths through which an application receives compressed data, reading dependency code when needed;
  • test a mitigation on every entry point: a written rule isn’t an applied rule;
  • get off end-of-life lines: Node 20 and older got nothing on 21 October;
  • rebuild what was produced with the old version (snapshots, single executables), not just update.

Sources: Node.js security advisory of 21 October 2026; Akamai’s analysis “Inside Node2Shell” (26 October 2026).


This post is part of a series of sysadmin tutorials (tag “how-to”).
Series “How-to”, tutorial 3/57. · ← Previous tutorial · Next tutorial → · Version française

Similar Posts

One Comment

Leave a Reply

Your email address will not be published. Required fields are marked *