Le 21 octobre 2026, le même jour que les correctifs de PHP2Shell, le projet Node.js a publié des versions de sécurité pour ses trois branches maintenues : 22.23.4, 24.22.1 et 26.12.1. Elles corrigent dix failles, dont une, CVE-2026-111497, a vite reçu le surnom de « Node2Shell » : une écriture hors limites dans le décodeur Brotli que Node embarque, exploitable à distance pour exécuter du code. J’ai déjà consacré un billet à cette faille sous l’angle de la bibliothèque Brotli. Celui-ci prend l’angle de Node.js : ce que contient réellement un exécutable Node, par quels chemins une application se retrouve à décompresser des données hostiles, ce que corrigent les neuf autres failles, et comment j’ai retrouvé toutes les copies de Node de mes serveurs, dont une qui ne sera jamais corrigée.

Les dix failles

CVEGravitéCe qui se passeBranches
CVE-2026-111497élevée (9,2 pour Google)écriture hors limites dans le décodeur Brotli intégré (« Node2Shell »)22, 24, 26
CVE-2026-105482élevéedns.resolve() tronque un nom au premier octet NUL22, 24, 26
CVE-2026-105483élevéevérification de certificat contournée avec une adresse IP écrite en chiffres pleine chasse24, 26
CVE-2026-106069élevéevérification de certificat contournée quand l’hôte se résout « librement » en adresse IP (zéros en tête)22, 24, 26
CVE-2026-105481moyenneconnexion persistante : une réponse arrivée avant la requête est prise pour sa réponse22, 24, 26
CVE-2026-106072moyenneadresses IPv4 non canoniques authentifiées par un nom DNS du certificat22, 24, 26
CVE-2026-106074moyenneréserves d’aléa de randomUUID() figées dans les instantanés de démarrage22, 24, 26
CVE-2026-106070faibleZipFile renvoie des octets contrôlés par l’attaquant après un en-tête invalide26
CVE-2026-106071faiblemodèle de permissions : un joker accorde plus que prévu26
CVE-2026-106073faiblemodèle de permissions contourné par fs.realpathSync()22, 24, 26

Une seule permet d’exécuter du code. Trois autres touchent à la vérification des certificats TLS ou aux noms DNS, c’est-à-dire à la question « est-ce que je parle bien au serveur que je crois ? ». Les autres sont plus étroites. Les correctifs s’accompagnent de mises à jour des dépendances embarquées : c-ares (le résolveur DNS), OpenSSL, Undici (le client HTTP derrière fetch()), SQLite, et la liste des autorités de certification, dont une autorité intermédiaire compromise a été retirée.

Ce qu’est un exécutable Node

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',

Un exécutable Node n’est pas qu’un interpréteur JavaScript. C’est un assemblage : le moteur V8 de Chrome, la bibliothèque d’entrées-sorties libuv, OpenSSL, zlib, Brotli, c-ares, l’analyseur HTTP llhttp, le client Undici. La commande process.versions en donne l’inventaire. Les binaires officiels et ceux de NodeSource compilent tout cela à l’intérieur de l’exécutable. Certaines distributions, au contraire, compilent Node pour qu’il utilise les bibliothèques du système (c’est le cas du Node de Red Hat et dérivées, qui charge la libbrotli du système).

Cette différence décide de qui corrige quoi. Avec un Node qui embarque Brotli, seule une nouvelle version de Node corrige la faille, et mettre à jour libbrotli ne sert à rien. Avec un Node qui utilise la bibliothèque du système, c’est l’inverse : la mise à jour de libbrotli le corrige, à condition de redémarrer le processus. Avant de savoir si l’on est protégé, il faut donc savoir quel Node on fait tourner.

Node2Shell : par où arrivent les données

Le défaut de Brotli se situe dans le décodage en continu : face à une instruction de changement de bloc invalide, le décodeur répondait « il me faut plus de données » au lieu de « ce flux est invalide ». Il continuait donc d’accumuler les octets suivants dans un petit tampon interne de huit octets, et finissait par écrire au-delà. Je détaille ce mécanisme dans un billet séparé. Ce qui compte ici, c’est de savoir quand une application Node décompresse du Brotli venu de l’extérieur. Il y a trois chemins.

  • l’usage direct de node:zlib : zlib.brotliDecompress() ou createBrotliDecompress() appliqués à des données reçues (fichiers envoyés, messages d’une file, archives) ;
  • fetch() : le client HTTP intégré décompresse automatiquement une réponse Content-Encoding: br. Une application qui appelle une URL fournie par un utilisateur (aperçu de lien, webhook, import par URL) peut être visée par un serveur malveillant. Ici, c’est le client qui est attaqué ;
  • les corps de requête : avec Express 5, le module body-parser 2 décompresse par défaut les corps de requête annoncés comme compressés, Brotli compris. N’importe quel client peut envoyer une requête POST avec Content-Encoding: br et un corps construit pour la faille. C’est le chemin le plus direct pour un serveur exposé.

Vérifier dans le code, pas dans la 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) : seulement deflate, gzip, identity

Sur mes serveurs, deux applications Node reçoivent du trafic web : Etherpad (éditeur de texte collaboratif) et Uptime Kuma (supervision). Plutôt que de me fier aux numéros de version, j’ai lu le code de body-parser installé avec chacune. Etherpad utilise Express 5 et body-parser 2.3.0, qui décompresse le Brotli : il était exposé par le troisième chemin. Uptime Kuma utilise Express 4 et body-parser 1.20.6, qui ne connaît que gzip et deflate : ce chemin-là n’existait pas pour lui.

La mesure d’attente, et le trou que j’y ai trouvé

curl -s -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Encoding: br' --data-binary x https://pad.example.org/
302    # avant : la requête passe le serveur web (ici bloquée plus loin par l'authentification)
415    # après : le serveur web refuse le corps compressé

En plus de la mise à jour de Node, j’avais ajouté au serveur web une règle qui refuse tout corps de requête compressé en Brotli (code 415), dans un fichier commun inclus par chaque site. En préparant ce billet, j’ai testé cette règle site par site. Sur le site d’Etherpad, la requête de test ne recevait pas 415 mais une redirection vers la page de connexion : le fichier de ce site n’incluait pas le répertoire commun. Il n’était pas le seul. Sur une cinquantaine de sites répartis sur trois serveurs, onze sur l’un et deux sur un autre avaient été écrits sans cette ligne d’inclusion, souvent parce qu’ils avaient été créés par des rôles Ansible différents de celui qui gère les sites ordinaires. La mesure ne s’appliquait donc pas partout. Une fois la ligne ajoutée aux treize modèles et déployée, le test a renvoyé 415 partout.

Ici, la conséquence était limitée : Etherpad est derrière une authentification, et son Node était déjà corrigé depuis le lendemain de la publication. Mais la leçon vaut pour toute mesure d’atténuation : on ne la considère appliquée qu’après l’avoir testée sur chaque point d’entrée, pas après avoir écrit la règle.

Les autres failles : des différences d’interprétation

const tls = require('node:tls');
// le certificat présenté porte  subjectAltName: DNS:127.0.0.1
tls.checkServerIdentity('127.0.0.1', cert);   // chiffres pleine chasse : comparé comme un NOM
tls.checkServerIdentity('0127.0.0.1', cert);     // octal pour l'URL (87.0.0.1), décimal pour certains résolveurs

Trois des failles graves partagent une même cause : deux composants qui lisent la même chaîne de caractères différemment. Quand un client TLS se connecte, il vérifie que le certificat présenté correspond à l’hôte demandé : soit un nom DNS, soit une adresse IP, qui se vérifient selon des règles différentes. La fonction tls.checkServerIdentity() doit donc d’abord décider si l’hôte est une adresse IP.

  • chiffres pleine chasse (CVE-2026-105483) : 127.0.0.1, écrit avec des chiffres Unicode « larges », n’était pas reconnu comme une adresse IP, donc vérifié comme un nom. Un certificat portant DNS:127.0.0.1 ou DNS:*.0.0.1 pouvait alors être accepté ;
  • zéros en tête (CVE-2026-106069) : pour les règles d’analyse des URL, 0127.0.0.1 est de l’octal, donc 87.0.0.1 ; pour le résolveur de certains systèmes, c’est du décimal, donc 127.0.0.1. Le certificat est vérifié pour une adresse, la connexion part vers une autre ;
  • formes IPv4 non canoniques (CVE-2026-106072) : 0x7f.0.0.1, 2130706433 ou 127.1 désignent toutes 127.0.0.1 pour la bibliothèque C ; Node les normalisait puis les comparait aux noms DNS du certificat ;
  • le NUL dans un nom DNS (CVE-2026-105482) : dns.resolve() transmet le nom à c-ares sous forme de chaîne C, qui s’arrête au premier octet nul. attaquant.example\0.confiance.example interroge attaquant.example, alors que l’application a peut-être validé le suffixe confiance.example.

Ces failles ne donnent pas un accès au serveur. Elles permettent, dans des situations précises, de faire accepter un certificat ou un nom que l’application aurait dû refuser. Elles visent les applications qui se connectent à des hôtes fournis par un tiers et comptent sur la vérification TLS pour se protéger. Plusieurs sont des corrections incomplètes de failles de 2025 ou du début de 2026 : la manipulation des adresses et des noms reste un terrain glissant.

Deux failles plus discrètes

  • les instantanés de démarrage (CVE-2026-106074) : Node peut sauvegarder l’état de la mémoire après l’initialisation d’une application pour démarrer plus vite (--build-snapshot, exécutables autonomes). Si crypto.randomUUID() a été appelé pendant cette initialisation, sa réserve de valeurs aléatoires est figée dans l’instantané : chaque processus lancé à partir de lui produit les mêmes identifiants, jusqu’à 127 UUID. Mettre Node à jour ne suffit pas, il faut reconstruire les instantanés ;
  • le modèle de permissions (CVE-2026-106071 et 106073) : l’option --permission restreint l’accès d’un programme au système de fichiers. Deux contournements montrent qu’elle reste un garde-fou contre les erreurs, pas une frontière de sécurité face à du code hostile : pour cela, il faut un vrai cloisonnement (utilisateur dédié, SELinux, conteneur).

Retrouver tous ses Node

# 1. tous les exécutables Node sur le disque, avec leur version et leur 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. ceux qui tournent, et depuis quand
ps -eo pid,lstart,args | grep -E '[n]ode( |$)|/node '
# 3. les conteneurs : chaque image a son propre Node
podman ps --format '{{.Names}} {{.Image}}'
/usr/bin/node                                v26.13.0  brotli=1.2.0   # NodeSource, corrigé
/opt/etherpad/nodejs/bin/node                v24.22.1  brotli=1.2.0   # embarqué par Etherpad, corrigé le 22/10
/usr/share/wazuh-dashboard/node/bin/node     v18.19.0  brotli=1.0.9   # Node 18 en fin de vie : jamais corrigé

Sur mes serveurs, la recherche a trouvé trois exécutables. Celui du système vient du dépôt NodeSource, en version 26, déclaré directement dans un rôle Ansible plutôt qu’installé par leur script d’installation : il suit les versions corrigées automatiquement. Celui d’Etherpad est une copie embarquée, téléchargée par le rôle Ansible avec une version et une somme de contrôle fixées : je l’ai passée en 24.22.1 le lendemain de la publication, puis le rôle a été modifié pour qu’il vérifie la version exacte installée. Le troisième est le problème.

Le Node que personne ne corrigera

Le tableau de bord de Wazuh embarque Node 18.19.0. La branche 18 est en fin de vie depuis avril 2025 : elle ne reçoit plus de correctif, ni pour Brotli ni pour le reste. Seul l’éditeur, en livrant une version du tableau de bord avec un Node récent, peut corriger cette copie. En attendant, j’ai réduit l’exposition : l’interface n’est plus accessible que depuis les adresses d’administration, et le corps de requête compressé en Brotli est refusé par le serveur web placé devant. Une copie de Node en fin de vie, embarquée par un produit tiers, est le cas le plus fréquent et le plus difficile : on ne peut pas la mettre à jour soi-même sans casser le produit.

Ce que j’en retiens

  • connaître la construction de son Node : bibliothèques embarquées ou système, le correctif ne vient pas du même endroit ;
  • inventorier toutes les copies, y compris celles des applications installées dans /opt, des conteneurs et des produits tiers ;
  • suivre les chemins réels par lesquels une application reçoit des données compressées, en lisant le code des dépendances quand c’est nécessaire ;
  • tester une mesure d’atténuation sur chaque point d’entrée : une règle écrite n’est pas une règle appliquée ;
  • sortir des branches en fin de vie : Node 20 et antérieurs n’ont rien reçu le 21 octobre ;
  • reconstruire ce qui a été produit avec l’ancienne version (instantanés, exécutables autonomes), pas seulement mettre à jour.

Sources : avis de sécurité de Node.js du 21 octobre 2026 ; analyse d’Akamai « Inside Node2Shell » (26 octobre 2026).


Ce billet fait partie d’une série de tutoriels d’administration système (tag « pas à pas »).
Série « Pas à pas », tutoriel 3/57. · ← Tutoriel précédent · Tutoriel suivant → · English version