Terres dévastées

Vallée de montagne enneigée entre deux sommets rocheux, sous un ciel bleu

Étiquette : Node.js

Billets, notes et écrits associés au mot-clé « Node.js » sur le carnet de bord personnel.

Node.js, octobre 2026 : Node2Shell et neuf autres failles, retrouver tous ses Node

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

La faille Brotli CVE-2026-111497 : une bibliothèque partout, en plusieurs exemplaires

Brotli est un algorithme de compression conçu par Google, utilisé partout sur le web : la plupart des navigateurs l’acceptent, la plupart des serveurs web et réseaux de diffusion l’utilisent pour compresser les pages. Une faille dans son décodeur, CVE-2026-111497, publiée le 21 octobre 2026, permet une écriture hors des limites d’un tampon mémoire lors de la décompression de données construites pour cela. Ce billet explique pourquoi une faille dans une bibliothèque de compression est plus difficile à traiter qu’une faille dans une application, comment j’ai retrouvé toutes les copies de Brotli sur mes serveurs, et pourquoi, neuf jours après, une machine n’était toujours pas corrigée malgré des mises à jour automatiques actives.

Ce qu’est la faille

Un décodeur de compression lit un flux d’octets qui décrit, de façon très compacte, comment reconstituer les données d’origine : des symboles littéraux, des références vers des données déjà produites (« recopier 300 octets situés 4 000 octets plus haut »), des tailles de blocs. Le décodeur doit écrire le résultat dans un tampon de sortie, en vérifiant à chaque étape que ce qu’il s’apprête à écrire tient dans ce tampon. Une écriture hors limites (out-of-bounds write) se produit quand une de ces vérifications manque ou est fausse dans un cas particulier : un flux construit pour atteindre ce cas fait écrire le décodeur au-delà de la fin du tampon, dans la mémoire voisine. Selon ce qui s’y trouve, le résultat va du plantage à l’exécution de code.

Le point essentiel est qui fournit les données compressées. Le scénario dangereux est celui où un programme décompresse des données venues de l’extérieur : un client HTTP qui reçoit une réponse compressée d’un serveur malveillant, ou un serveur qui accepte des corps de requête compressés envoyés par un client. Compresser ses propres données ne pose aucun problème.

Le vrai problème : Brotli est partout, et en plusieurs exemplaires

Une faille dans nginx se corrige en mettant à jour nginx. Une faille dans une bibliothèque de compression se cache dans des dizaines de programmes, de deux façons différentes.

La bibliothèque partagée du système

rpm -q --whatrequires 'libbrotlidec.so.1()(64bit)'
freetype-2.13.2-8.el10.x86_64        # polices : PHP gd/imagick, génération d'images
exiv2-libs-0.28.3-5.el10.x86_64      # métadonnées d'images
libjxl-0.10.4-1.el10_1.x86_64        # images JPEG XL
libsoup3-3.6.5-5.el10.x86_64         # client HTTP de GNOME
libcurl-8.12.1-4.el10_2.7.x86_64     # curl, donc PHP curl, git, dnf…
dotnet-runtime-9.0-9.0.21-1.el10_2   # applications .NET

Le paquet libbrotli fournit une bibliothèque partagée que d’autres programmes chargent au démarrage. La requête rpm -q --whatrequires donne ceux qui en dépendent directement : curl (et donc l’extension curl de PHP, git, une partie des outils système), les bibliothèques de polices et d’images utilisées par PHP pour générer des vignettes, le moteur d’exécution .NET. Ce cas est le plus simple : on met à jour libbrotli, et tous ces programmes utilisent la version corrigée… à leur prochain démarrage.

Les copies embarquées

node -p "process.versions.brotli"                                    # 1.2.0 (corrigé)
/opt/etherpad/nodejs/bin/node -p "process.versions.brotli"           # 1.2.0 (corrigé)
/usr/share/wazuh-dashboard/node/bin/node -p "process.versions.brotli" # 1.0.9 (vulnérable, Node 18 en fin de vie)

Beaucoup de logiciels n’utilisent pas la bibliothèque du système mais embarquent leur propre copie, compilée à l’intérieur de leur exécutable. Node.js est l’exemple type : il contient sa propre version de Brotli, de zlib, d’OpenSSL. Et les applications qui embarquent elles-mêmes Node.js (un éditeur collaboratif installé dans /opt, le tableau de bord de Wazuh) ont leur copie de Node, donc leur copie de Brotli. Mettre à jour libbrotli ne change rien pour elles. Seule une nouvelle version du logiciel qui les embarque les corrige. Sur un de mes serveurs, le tableau de bord de Wazuh embarque Node 18, en fin de vie, avec Brotli 1.0.9 : aucune version corrigée de Node 18 n’existera, il faudra attendre que Wazuh mette à jour son Node intégré.

Ce que j’ai fait, dans l’ordre

  • inventaire : les paquets qui dépendent de libbrotli, puis tous les exécutables Node présents sur chaque machine (find / -xdev -type f -name node) avec leur version de Brotli intégrée (node -p process.versions.brotli) ;
  • les copies embarquées exposées en premier : l’éditeur collaboratif, accessible depuis Internet, est passé le jour même à une version de Node corrigée (24.22.1) ; les installations de Node du système sont passées au dépôt NodeSource, en version 26, qui embarque Brotli 1.2.0 corrigé ;
  • une mesure d’attente côté serveur web, pour les cas où l’on décompresse ce qu’envoie le client ;
  • la bibliothèque système, dès que le correctif de la distribution a été disponible ;
  • les redémarrages de tout ce qui chargeait l’ancienne bibliothèque ;
  • l’accès au tableau de bord Wazuh restreint aux seules adresses d’administration, en attendant une version corrigée.

La mesure d’attente

# snippets/server.d/reject-brotli-request.conf : refuser les CORPS de requête compressés en Brotli
if ($http_content_encoding ~* "br") {
    return 415;
}

Un client HTTP peut annoncer, par l’en-tête Content-Encoding: br, que le corps de sa requête est compressé en Brotli. Aucun navigateur ne le fait spontanément, et aucune de mes applications n’en a besoin. Refuser ces requêtes par un code 415 (« type de média non supporté ») au niveau du serveur web empêche qu’un corps compressé malveillant atteigne une application qui le décompresserait. C’est une mesure étroite, sans effet de bord pour un usage normal, et facile à retirer.

Lire le journal des modifications, pas le numéro de version

rpm -q --changelog libbrotli | head -4
* Thu Oct 22 2026 RHEL Packaging Agent <…> - 1.1.0-8.1
- CVE-2026-111497 brotli: Out-of-Bounds Write in Google Brotli Decoder
- Resolves: RHEL-290261

Le projet Brotli a corrigé la faille dans ses versions 1.0.10, 1.1.1 et 1.2.1. La distribution, elle, a gardé la version 1.1.0 et rétroporté le correctif dans une nouvelle révision, 1.1.0-8.1. Un scanner qui compare « 1.1.0 » à la liste des versions vulnérables conclura à tort que le paquet est vulnérable. La seule vérification fiable est la présence de la CVE dans le journal des modifications du paquet installé.

Redémarrer ce qui utilise l’ancienne copie

# processus qui ont encore l'ANCIENNE bibliothèque en mémoire (fichier remplacé sur disque)
grep -l 'libbrotli.*(deleted)' /proc/[0-9]*/maps 2>/dev/null | cut -d/ -f3 \
  | xargs -r -I{} sh -c 'echo "$(cat /proc/{}/comm) {}"' | sort -u
needs-restarting -s          # la même chose, service par service

Quand un paquet remplace une bibliothèque, le fichier sur disque change, mais chaque processus déjà lancé garde en mémoire l’ancienne version, dont le fichier n’existe plus. Le noyau le signale dans /proc/<pid>/maps par la mention (deleted). La commande needs-restarting fait cette vérification pour vous, et donne la liste des services à redémarrer. Un lecteur multimédia, un serveur PHP, un service .NET peuvent tourner des semaines sans redémarrer : sans cette vérification, ils restent vulnérables bien après la mise à jour.

Le piège : une correction sans avis de sécurité

MachineBibliothèque systèmeCorrigée ?Pourquoi
serveur web principal1.1.0-8.el10_2.1ouimise à jour manuelle le 22–23 octobre
laboratoire1.1.0-8.el10_2.1ouiidem
serveur de courrier1.1.0-8.el10_2.1ouiidem
serveur Wazuh1.1.0-8.el10_2non (au 30 octobre)mises à jour automatiques en mode « sécurité » seulement, et aucun avis publié
Raspberry Pi (annuaire)1.0.9-10.el9_8nonmises à jour automatiques désactivées (mémoire insuffisante pour dnf)

Neuf jours après la publication, deux machines n’étaient pas corrigées. Pour le Raspberry Pi, c’est un choix assumé : les mises à jour automatiques y sont désactivées parce que dnf y manque de mémoire, et il faut les faire à la main. Pour le serveur Wazuh, c’est plus instructif. Les mises à jour automatiques y sont actives, configurées pour n’installer que les mises à jour de sécurité (upgrade_type = security dans dnf-automatic). Or la distribution a publié le paquet corrigé sans avis de sécurité associé : pour dnf, c’est une mise à jour ordinaire. Le mode « sécurité seulement » ne l’a jamais installée, et mon script quotidien de surveillance des CVE, qui s’appuyait sur ces mêmes avis, ne l’a pas vue non plus.

Le même jour, un nouveau noyau corrigeant seize CVE est apparu, lui aussi sans avis. J’ai modifié mon script pour qu’il compare, pour chaque mise à jour en attente, les CVE citées dans son journal des modifications à celles du paquet installé : il détecte désormais ces correctifs « silencieux ». Et la question du mode des mises à jour automatiques (sécurité seulement, ou toutes les mises à jour) se pose à nouveau : le mode « sécurité » ne vaut que ce que valent les avis publiés.

En résumé

  • une faille dans une bibliothèque se cache dans tous les programmes qui la chargent, et dans tous ceux qui en embarquent une copie ;
  • l’inventaire se fait en deux temps : dépendances du paquet système, puis copies embarquées (Node.js, Python, Java, Go…) ;
  • une mise à jour de bibliothèque n’agit qu’au redémarrage des processus : needs-restarting ;
  • le journal des modifications fait foi, pas le numéro de version ;
  • les mises à jour automatiques « de sécurité » ne voient que ce qui est publié comme tel. Vérifiez les mises à jour en attente qui citent des CVE.

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

Propulsé par WordPress & Thème par Anders Norén