• Sécurité
  • PHP
  • Node.js
  • NGINX
  • Debian
  • Retour d'expérience

PHP2Shell et Node2Shell : mettre à jour, vérifier, refuser en amont

PHP2Shell et Node2Shell, corrigées le 21 octobre 2026 : mettre à jour PHP et Node.js, vérifier la correction, refuser les corps piégés avec NGINX.

Le 21 octobre 2026, deux requêtes piégées, un corps multipart pour PHP et un corps Brotli pour Node.js, frappent pendant la lecture du corps, avant l'application ; NGINX les refuse en amont, PHP et Node.js sont mis à jour, et la correction se vérifie21 octobre 2026 · deux correctifsPOSTmultipart/form-dataPHP2Shell415POSTContent-Encoding: brNode2Shell415NNGINXRefus en amontLecture du corpsPHP8.4.27 corrigéNode.js24.22.1 corrigéVotre applicationPas encore exécutéeVérifier la correctiondpkg-query : révision ≥ minimumdocker build --pullnode -v : v24.22.1processus relancés

Le 21 octobre 2026, deux projets ont publié leurs correctifs le même jour :

  • PHP : les versions 8.5.12, 8.4.27, 8.3.36 et 8.2.35 corrigent PHP2Shell (CVE-2026-109618 et CVE-2026-109619), deux failles du cœur de PHP qui permettent une exécution de code à distance, sans authentification ;
  • Node.js : les versions 26.12.1, 24.22.1 et 22.23.4 corrigent, entre autres, Node2Shell (CVE-2026-111497), une écriture hors limites dans le décodeur Brotli, qui peut mener à une exécution de code à distance.

Les deux familles ont un point commun, et c’est lui qui les rend sérieuses : elles frappent pendant la lecture du corps de la requête, avant la première ligne de votre application. Le contrôle d’accès, la validation des entrées, le pare-feu du framework : rien de tout cela n’a encore tourné.

Voici ce que nous avons fait cette semaine, dans l’ordre : mettre à jour, vérifier qu’on l’a vraiment fait, réduire la surface, voir les tentatives, et traiter le cas des serveurs que personne n’a envie de regarder, ceux d’une distribution en fin de vie.

PHP2Shell

Ce que sont ces deux failles

Quand une requête dépasse sa mémoire ou rencontre une erreur fatale, PHP l’interrompt brutalement pour passer à la suite. Les deux failles viennent de ce qui reste derrière cette interruption :

  • CVE-2026-109618 : l’interruption survient alors que des structures de la requête ne sont qu’à moitié initialisées, et le nettoyage de fin de requête leur fait confiance ;
  • CVE-2026-109619 : un état global garde une référence vers la mémoire d’une requête terminée, et la requête suivante traitée par le même worker la réutilise.

Le chemin démontré passe par l’analyse des corps multipart/form-data et par une fonctionnalité activée par défaut, le suivi de progression des envois (session.upload_progress). Nous n’en dirons pas plus sur la mécanique : pour se défendre, ce qui compte, c’est où elle se produit.

Mettre à jour, puis relancer

BrancheVersion corrigéeCorrectifs du projet PHP
8.58.5.12oui
8.48.4.27oui
8.38.3.36oui
8.28.2.35oui, jusqu’au 31 décembre 2026
8.1, 8.0, 7.4aucune version publiéerétroportages minimaux fournis en source
7.3 et avantaucunenon

L’avis est sans ambiguïté : seule la mise à jour corrige. Pour les branches en fin de vie, l’équipe de sécurité de PHP a publié des rétroportages minimaux en source pour 7.4, 8.0 et 8.1, que certaines distributions et certains fournisseurs de paquets ont intégrés dès le jour même. Nos serveurs utilisent les paquets PHP d’Ondřej Surý, qui ont reçu ce rétroportage pour les anciennes branches.

Après la mise à jour, il faut relancer tous les workers. Un processus FPM démarré avant la mise à jour exécute toujours l’ancien code, quelle que soit la version du paquet sur le disque.

Sur une branche en fin de vie, la version ne change pas

Un rétroportage ne change pas le numéro de version de PHP. Sur un PHP 7.4, php -v affiche 7.4.33 avant comme après le correctif. Seule la révision du paquet change :

Lire la révision du paquet, pas la version de PHP
dpkg-query -W -f='${Package} ${Version}\n' 'php*-fpm'
# php7.4-fpm 1:7.4.33-31

Nous construisons nos images PHP de conteneur, une par branche de PHP. Plutôt que de compter sur une relecture, nous avons inscrit la révision minimale dans la définition de chaque image, et la construction échoue si le paquet installé est plus ancien :

Garde-fou à la construction de l'image
installed=$(dpkg-query -W -f='${Version}' "php${PHP_VERSION}-fpm")
if dpkg --compare-versions "$installed" lt "$PHP_PACKAGE_MIN_VERSION"; then
    echo "php${PHP_VERSION}-fpm $installed is older than $PHP_PACKAGE_MIN_VERSION (PHP2Shell)" >&2
    exit 1
fi

dpkg --compare-versions connaît l’époque (1:) et les révisions Debian : une comparaison de chaînes se tromperait.

Autre surprise : un PHP installé depuis un dépôt tiers n’apparaît pas dans la base de sécurité de la distribution. Le scanner d’images déclare donc l’image propre, que PHP soit corrigé ou non. Le scan couvre le système, pas l’interpréteur : d’où la règle explicite ci-dessus.

Réduire la surface, même sur un PHP corrigé

L’avis de CVE-2026-109619 recommande de couper le suivi de progression des envois même après la mise à jour, si on ne s’en sert pas : la fonctionnalité est peu utilisée, et peu testée.

php.ini, ou fichier de configuration commun à toutes les images
session.upload_progress.enabled = Off
Ou dans un pool FPM
php_admin_flag[session.upload_progress.enabled] = off

Le réglage doit être posé dans php.ini, le pool FPM ou la configuration du serveur, pas par ini_set() ni par un .user.ini, qui arrivent trop tard. Il ne prend effet qu’après un redémarrage des workers.

L’avis liste d’autres mesures partielles, pour chacune des deux failles. Aucune ne remplace la mise à jour.

Node2Shell

Ce qu’est la faille

Brotli est un format de compression, très utilisé pour les réponses HTTP. La faille est dans son décodeur : un flux Brotli malformé lui fait écrire au-delà d’un tampon interne. Selon l’avis de Node.js, une application qui décompresse des données Brotli fournies par un attaquant peut subir un plantage, une fuite de mémoire ou une exécution de code à distance.

Le défaut vient de la bibliothèque de référence de Google, versions 1.0.3 à 1.2.0, corrigée en 1.2.1, 1.1.1 et 1.0.10. Node.js en embarque sa propre copie, d’où ses versions correctives. Mais tout ce qui embarque ce décodeur est concerné : les paquets Python Brotli et brotlicffi (corrigés en 1.2.1), Bun (1.4.4), et la bibliothèque libbrotli1 des distributions.

Qui décompresse du Brotli sans le savoir

C’est la vraie question, et la réponse est souvent « plus de code qu’on ne croit » :

  • les requêtes entrantes : un framework, ou un de ses plugins, peut décompresser le corps d’une requête qui annonce Content-Encoding: br, et n’importe quel client peut l’annoncer. C’est le cas d’Express 5 et de body-parser 2. C’est aussi le cas, par défaut, du plugin de compression de Fastify, qu’on installe d’ordinaire pour compresser les réponses ;
  • les réponses sortantes : un client HTTP qui annonce br décompresse ce que le serveur distant lui renvoie. Si ce serveur n’est pas de confiance, c’est lui qui fournit le flux ;
  • l’usage direct de node:zlib sur des données reçues.

Pour le plugin de Fastify, une option suffit si l’API ne reçoit jamais de corps compressé :

Garder la compression des réponses, couper la décompression des requêtes
await app.register(compress, {
  global: true,
  globalDecompression: false,
});

Mettre à jour : changer d’image, pas seulement de paquet

Branche de Node.jsVersion corrigéeStatut
2626.12.1Current
2424.22.1LTS active
2222.23.4LTS de maintenance
20 et avantaucunefin de vie : il faut changer de branche majeure

Nos services Node.js tournent en conteneur. Chaque image embarque sa propre copie de Node.js : mettre à jour l’hôte ne corrige rien, il faut reconstruire chaque image et la redéployer. Nous avons commencé par un inventaire de tous les Dockerfile qui partent d’une image node, rangés en trois groupes :

  • Node.js dans l’image finale : à reconstruire en priorité, en commençant par les services exposés ;
  • Node.js seulement à la construction : l’image finale ne le contient pas, le risque se limite à la machine de build ;
  • applications web statiques : construites avec Node.js, servies par NGINX. Rien à faire.

Vérifier qu’on a vraiment mis à jour

Trois écarts nous attendaient, chacun avec tous les indicateurs au vert.

Le tag flottant. node:24-alpine pointait déjà sur 24.22.1 côté registre. Mais un docker build sans --pull réutilise l’image de base présente dans le cache local : sur une de nos machines de build, elle était encore en 24.21.0. Nous avons ajouté --pull partout, épinglé la version exacte sur les services exposés, et fait échouer la construction si la version présente dans l’image n’est pas celle demandée :

Vérifier, pendant la construction, la version réellement embarquée
ARG NODE_VERSION=24.22.1
FROM node:${NODE_VERSION}-alpine
ARG NODE_VERSION
RUN test "$(node -v)" = "v${NODE_VERSION}"

Le Dockerfile dit ce qui a été demandé, pas ce qui a été tiré. node -v dans l’image produite est la seule preuve qui vaille.

L’image corrigée qui ne tourne pas. Sur un service, l’image corrigée était publiée, mais le conteneur en service tournait depuis deux semaines : le déploiement ne l’avait pas recréé. Une image corrigée qui n’a pas été redéployée ne corrige rien. Après chaque déploiement, nous lisons la date de démarrage du conteneur, ou la durée de fonctionnement que le service expose.

La bibliothèque du système. Hors conteneurs, libbrotli1 reste installée sur les hôtes, et d’autres programmes utilisent son décodeur : curl, par exemple, pour décompresser les réponses. Debian a publié le correctif pour Debian 12 (1.0.9-2+deb12u1) et Debian 13 (1.1.0-2+deb13u1).

Lire la révision installée de la bibliothèque Brotli
dpkg-query -W -f='${Package} ${Version}\n' 'libbrotli*'

Refuser les corps Brotli avant qu’ils n’atteignent l’application

NGINX ne décompresse jamais le corps des requêtes : il les transmet tels quels. Il peut en revanche les refuser sur le seul examen des en-têtes. Une API qui ne reçoit jamais de corps compressé n’a aucune raison d’en accepter un :

Bloc http : repérer un corps annoncé en Brotli
map $http_content_encoding $refuse_brotli_body {
    "~*(^|,)\s*br\s*(,|$)"  1;
    default                 0;
}
Bloc server : refuser avant toute location
if ($refuse_brotli_body) {
    return 415;
}

La règle reconnaît br seul ou dans une liste (gzip, br), quelle que soit la casse, et même réparti sur plusieurs en-têtes Content-Encoding, que NGINX réunit. Elle laisse passer gzip. C’est une mesure d’attente : elle protège les requêtes entrantes, pas les réponses que vos services vont chercher ailleurs.

Pour notre propre module Brotli de NGINX, nous avons vérifié ce qu’il charge : le filtre n’est lié qu’à l’encodeur de la bibliothèque, et le module qui sert les fichiers précompressés n’en utilise aucune partie. Ni l’un ni l’autre ne décode quoi que ce soit.

Le même réflexe pour les deux : refuser en amont

Les deux familles de failles se déclenchent sur une forme de corps que la plupart des routes ne reçoivent jamais : du multipart/form-data pour PHP2Shell, du Brotli pour Node2Shell. NGINX voit les en-têtes avant tout le monde, et peut refuser ces requêtes avant que l’application ne lise un octet.

Pour le multipart, la règle doit épargner les routes d’envoi de fichiers. Chez nous, sur plus d’une centaine de routes d’écriture, deux seulement en reçoivent : l’envoi de photos et celui des pièces jointes. C’est la prochaine étape que nous préparons. En voici le principe, sur un exemple générique :

Bloc http : repérer un multipart hors des routes d'envoi
map $http_content_type $is_multipart {
    "~*^multipart/"  1;
    default          0;
}

map $uri $is_upload_route {
    "~^/api/uploads$"  1;
    default            0;
}

map "$is_multipart:$is_upload_route" $refuse_multipart {
    "1:0"    1;
    default  0;
}
Bloc server : refuser avant toute location
if ($refuse_multipart) {
    return 415;
}

$uri est l’URI normalisée par NGINX : un détour comme /api/uploads/../orders est ramené à /api/orders, et refusé.

Dans les deux cas, mesurez avant d’activer : relevez, sur quelques semaines de journaux, quelles routes reçoivent réellement du multipart ou un corps compressé. Une route oubliée, c’est une fonctionnalité cassée.

Voir les tentatives dans les journaux HTTP

Mettre à jour protège. Mais on veut aussi savoir si quelqu’un essaie, et depuis où.

Notre service de journalisation HTTP reçoit déjà une copie de chaque requête adressée à PHP, en-têtes et corps compris, par le module mirror de NGINX. Pour PHP2Shell, il n’a pas fallu ajouter de miroir : il a suffi d’examiner ce qui arrivait déjà. Chaque requête multipart passe par une série de règles, qui portent sur sa forme :

  • présence du champ de suivi de progression des envois ;
  • imbrication anormale des noms de champs, nombre de parties excessif, noms démesurés ;
  • cookie de session inattendu, sur une API qui n’utilise pas de session ;
  • multipart sur une route qui n’en reçoit jamais ;
  • corps trop gros, malformé, ou rafale d’envois depuis une même adresse.

Les premières règles désignent une tentative : alerte critique, et l’adresse rejoint une liste de blocage. Les suivantes sont des signaux plus faibles, à surveiller.

Deux choix comptent plus que les règles elles-mêmes :

  • on n’enregistre jamais le contenu, seulement des longueurs, des comptes et des catégories. Un journal de sécurité ne doit pas devenir une copie des données envoyées ;
  • les seuils sont calibrés sur le trafic réel. Sur 30 jours, nous avons mesuré le nombre de routes qui reçoivent du multipart, le nombre maximal d’envois légitimes par adresse en 5 minutes, la taille des fichiers. Les règles ont été rejouées sur des cas construits, une par règle, puis validées sur le trafic de production : aucune détection aux seuils retenus.

Pour Node2Shell, le signal est plus simple encore : une requête qui annonce un corps en Brotli est rare. Une recherche sur l’en-tête Content-Encoding des requêtes reçues dit si vos API en voient, et depuis où.

Les serveurs restés sur une distribution en fin de vie

Toutes les entreprises en ont : le serveur qu’on migrera « le trimestre prochain ». Sur une distribution en fin de vie, chaque faille se double d’un autre problème : plus personne ne publie de paquets.

Cette semaine l’illustre bien. Debian a corrigé libbrotli1 pour Debian 12 et 13, pas pour Debian 11, sortie de son support à long terme fin août 2026, ni pour Debian 10. Le dépôt officiel de nginx.org ne publie plus rien pour Debian 10 depuis août 2022, et les dépôts PHP courants l’ont abandonnée. On ne peut plus « juste mettre à jour ».

Découpler les applications du système hôte

Notre réponse tient en une phrase : PHP et Node.js ne dépendent plus de la distribution de l’hôte. Chaque version tourne dans une image de conteneur construite sur une Debian supportée, avec les mêmes extensions partout. L’hôte n’a plus qu’à faire tourner des conteneurs.

Le correctif arrive alors comme n’importe quelle mise à jour d’image, avec son garde-fou de version, que l’hôte soit récent ou non. Restent sur l’hôte ses propres bibliothèques, comme libbrotli1 : celles-là suivent le sort de la distribution.

Reconstruire NGINX, le temps d’une transition

Pour NGINX, l’équation est différente : c’est lui qui reçoit les requêtes d’Internet. Nous avons vérifié qu’il est possible de reconstruire, sur Debian 10, la version stable courante de NGINX et ses modules, avec les recettes officielles de nginx.org et sans les modifier.

Un point nous a arrêtés : sur Debian 10, NGINX serait lié à l’OpenSSL de la distribution, une branche 1.1.1 qui n’est plus maintenue en amont depuis septembre 2023. Nous avons donc validé une construction qui intègre OpenSSL 3.5, la branche à support long, directement dans NGINX. Le binaire ne dépend plus de la bibliothèque TLS du système, et TLS 1.3 fonctionne comme sur une distribution récente.

Ce n’est pas une solution durable, et nous la traitons comme telle :

  • une date de fin annoncée : au-delà, plus aucune reconstruction ;
  • seul NGINX en profite : le reste du système (curl, apt, la commande openssl, et libbrotli1) garde ses anciennes bibliothèques ;
  • c’est nous qui suivons les failles de NGINX et d’OpenSSL pour ce système, à la place de ceux qui ont cessé de le faire.

Gagner du temps pour migrer, oui. Faire durer une distribution en fin de vie, non.

Les pièges à connaître

  1. Le numéro de version qui ne bouge pas

    Le risque

    Sur une branche de PHP en fin de vie, le correctif rétroporté ne change pas la version : php -v affiche 7.4.33 avant comme après.

    La parade

    Vérifier la révision du paquet (dpkg-query), pas PHP_VERSION, et refuser de construire une image en dessous de la révision corrigée.

  2. Le scanner qui ne voit pas PHP

    Le risque

    Un PHP installé depuis un dépôt tiers n’apparaît pas dans la base de sécurité de la distribution : le scanner d’images le déclare propre, corrigé ou non.

    La parade

    Contrôler la version de PHP par une règle explicite, en plus du scan du système.

  3. Le tag flottant qui ne tire rien

    Le risque

    node:24-alpine pointe déjà sur la version corrigée, mais un docker build sans --pull réutilise l’image de base du cache local, donc l’ancienne.

    La parade

    Construire avec --pull, ou épingler la version exacte, et lire node -v dans l’image produite.

  4. L'image corrigée qui ne tourne pas

    Le risque

    Une image reconstruite, ou un paquet mis à jour, ne protège rien tant que le processus en service n’a pas été relancé : workers FPM, conteneur jamais recréé.

    La parade

    Vérifier la date de démarrage des processus, ou la durée de fonctionnement exposée par le service, après chaque déploiement.

  5. Le plugin qui décompresse les requêtes

    Le risque

    Un plugin de compression des réponses peut aussi, par défaut, décompresser le corps des requêtes entrantes, Brotli compris.

    La parade

    Lire sa documentation, couper la décompression des requêtes si l’API n’en a pas besoin, ou la restreindre aux formats attendus.

  6. Le gestionnaire d'erreurs qui efface les traces

    Le risque

    Les requêtes malformées sont rejetées par l’analyseur du collecteur, puis tombent dans un gestionnaire d’erreurs qui ne garde rien.

    La parade

    Faire enregistrer aussi les requêtes rejetées : ce sont précisément celles qui ressemblent à une attaque.

La leçon : vérifier la correction, pas la mise à jour

Deux failles, deux écosystèmes, et les mêmes écarts entre « mis à jour » et « corrigé » : un numéro de version qui ne bouge pas, un scanner qui ne regarde pas l’interpréteur, un tag qui ne tire pas ce qu’il annonce, un processus qui garde l’ancien code en mémoire. Chacun laisse un serveur vulnérable avec tous les indicateurs au vert.

La parade est la même à chaque fois : une règle explicite, qui échoue bruyamment. Une construction qui refuse un paquet ou un binaire trop ancien, un test qui lit la révision et non la version, un déploiement qui vérifie que le processus a bien redémarré, un journal qui garde aussi les requêtes rejetées.

Questions fréquentes

Quelles versions de PHP corrigent PHP2Shell ?

PHP 8.5.12, 8.4.27, 8.3.36 et 8.2.35, publiées le 21 octobre 2026. Les branches 7.4, 8.0 et 8.1 n’ont pas de nouvelle version, mais des rétroportages minimaux, intégrés par certaines distributions et certains fournisseurs de paquets. Seule la mise à jour corrige.

Comment savoir si un PHP 7.4 est corrigé ?

Pas avec php -v, qui affiche 7.4.33 avant comme après le rétroportage. Il faut lire la révision du paquet avec dpkg-query, la comparer à la première révision corrigée, et vérifier que les workers FPM ont été relancés.

Node2Shell ne concerne-t-il que Node.js ?

Non. La faille vient de la bibliothèque Brotli de référence : les paquets Python Brotli et brotlicffi, Bun et la bibliothèque libbrotli1 des distributions sont aussi concernés.

NGINX peut-il bloquer ces attaques ?

Il peut refuser, sur le seul examen des en-têtes, les corps multipart/form-data hors des routes d’envoi et les corps annoncés en Brotli, avant que l’application ne lise un octet. C’est une mesure d’attente : elle ne remplace pas la mise à jour.

En résumé

ÉtapePHP2ShellNode2Shell
Mettre à jour8.5.12, 8.4.27, 8.3.36, 8.2.35 ; rétroportage pour 7.4, 8.0, 8.126.12.1, 24.22.1, 22.23.4 ; Node 20 : changer de branche ; libbrotli1 sur les hôtes
RelancerRedémarrer tous les workers PHPReconstruire chaque image, recréer chaque conteneur
VérifierLire la révision du paquet, échouer en dessousnode -v dans l’image, échouer si différent
Réduiresession.upload_progress.enabled = Off ; multipart refusé hors des routes d’envoiDécompression des requêtes coupée ; corps br refusé par NGINX
DétecterRègles sur la forme des requêtes multipart, sans en garder le contenuRequêtes qui annoncent un corps Brotli
Fin de vieApplications en conteneur sur une base supportée ; NGINX reconstruit, avec une date de finidem

Sources : PHP, avis de sécurité du 21 octobre 2026, GHSA-w8hw-w9fx-j79w (CVE-2026-109618), GHSA-6wmf-4rv5-p3rp (CVE-2026-109619), CISA, Critical Vulnerabilities in PHP Require Immediate Patching, Node.js, October 2026 Security Releases, Debian, CVE-2026-111497, Aikido, Node2Shell au-delà de Node.js, @fastify/compress, décompression des requêtes.