Cette semaine, j’ai publié quatre billets sur quatre failles d’octobre 2026 : PHP2Shell, la faille Brotli, les correctifs de Node.js et la faille noyau ZDI-CAN-33367. Chacun expliquait la faille et ce que j’ai fait sur mes serveurs. Celui-ci prend du recul et descend d’un niveau : comment un octet de trop ou un pointeur oublié (deux formes de corruption mémoire), ou une page partagée, deviennent une exécution de code, et pourquoi les protections modernes freinent certaines de ces failles et pas d’autres. Aucun détail d’exploitation, seulement les mécanismes, au niveau qu’un administrateur a besoin de comprendre pour juger d’une gravité et choisir une mesure.

Brotli (Node2Shell)PHP2ShellZDI-CAN-33367
classe de bugécriture hors limitesutilisation après libération, état incohérenterreur de logique : deux chemins se partagent une page
oùdécodeur en continu, code Cgestionnaire de mémoire et nettoyage de requête, code Cpile réseau du noyau
corruption mémoire ?ouiouinon : chaque écriture est « légitime »
déclencheurdonnées compressées reçuesrequête HTTPutilisateur local
freiné par ASLR, CFI…oui, en partieoui, en partienon
Schéma comparant Brotli, PHP2Shell et ZDI-CAN-33367 : mécanisme de chaque faille et présence ou non d'une corruption mémoire
Comment chaque faille devient une exécution de code, et pourquoi les protections n’agissent pas partout.

Ce que veut dire « corruption mémoire »

Un programme écrit en C gère lui-même sa mémoire : il demande une zone d’une certaine taille, y range des données, la rend quand il a fini. Le langage ne vérifie rien : écrire 9 octets dans une zone de 8, ou lire une zone déjà rendue, ne provoque aucune erreur immédiate. Le programme écrit simplement à côté, dans ce qui se trouve là. Toute la gravité dépend de ce qui se trouve là. On parle alors de corruption mémoire.

Pour un attaquant, le chemin typique comporte trois étapes. D’abord une primitive : la capacité, souvent très limitée au départ, d’écrire ou de lire un peu de mémoire qui ne devrait pas l’être. Ensuite l’élargissement : utiliser cette petite écriture pour modifier une structure qui contrôle d’autres accès (une longueur, un pointeur), jusqu’à pouvoir lire et écrire n’importe où. Enfin le détournement : modifier une adresse que le programme utilisera pour savoir quel code exécuter ensuite. Chaque étape est difficile ; les outils d’analyse assistés par IA qui ont découvert PHP2Shell et Node2Shell sont précisément bons à les enchaîner.

Brotli : une erreur prise pour une attente

Le format

flux Brotli
├─ en-tête du flux : taille de la fenêtre glissante (jusqu'à 16 Mio)
└─ méta-blocs, l'un après l'autre
   ├─ en-tête : longueur, dernier bloc ?
   ├─ 3 catégories de blocs, chacune avec jusqu'à 256 « types de bloc » :
   │    littéraux · longueurs insertion+copie · distances
   ├─ codes préfixes (Huffman) pour chaque type de bloc
   ├─ cartes de contexte : quel code utiliser selon les octets précédents
   └─ commandes : « insérer N littéraux, puis copier M octets situés D plus haut »
        … entrecoupées de CHANGEMENTS DE BLOC : « passe au type 3 pour les 500 prochains symboles »

Brotli est un format dense. Un flux est une suite de méta-blocs. Les données y sont décrites par des commandes : insérer quelques octets littéraux, puis recopier une séquence déjà produite plus haut dans la fenêtre. Chaque élément est codé par des codes préfixes (des codes de Huffman, où les symboles fréquents prennent peu de bits). Particularité de Brotli : il peut utiliser plusieurs jeux de codes, appelés types de bloc, et passer de l’un à l’autre en cours de route par des commandes de changement de bloc. C’est ce qui lui permet de bien compresser un document dont la nature change (du HTML, puis du JavaScript intégré, puis du texte). C’est aussi une complexité supplémentaire pour le décodeur, qui doit valider chaque changement : le type demandé existe-t-il, le nombre de symboles annoncé est-il cohérent ?

Le décodage en continu

appel 1 : entrée = 4 096 octets   → décode … il manque 3 bits pour finir le symbole
          → copie les octets restants dans le petit tampon interne (8 octets)
          → répond NEEDS_MORE_INPUT
appel 2 : entrée = 4 096 octets   → ajoute 1 octet au tampon, réessaie
          → succès : reprend le décodage normal
--- avec le bug ---
appel n : changement de bloc INVALIDE → répond NEEDS_MORE_INPUT au lieu d'ERROR
          → ajoute 1 octet au tampon, réessaie → toujours invalide → NEEDS_MORE_INPUT
          → ajoute 1 octet… 9e octet, 10e octet… → écriture au-delà des 8 octets

Un décodeur en continu reçoit les données par morceaux, au fil du réseau. Un symbole peut être à cheval sur deux morceaux : les derniers bits d’un morceau ne suffisent pas pour le décoder. Le décodeur de référence garde alors les octets en attente dans un très petit tampon interne de huit octets, répond à l’appelant « il me faut plus de données », puis, à l’appel suivant, ajoute les nouveaux octets un par un dans ce tampon et réessaie, jusqu’à ce que le symbole soit complet. Ce petit tampon suffit toujours, car aucun symbole valide ne dépasse cette taille.

Le bug tient dans une confusion entre deux réponses. Face à un changement de bloc invalide, le décodeur répondait « il me faut plus de données » au lieu de « ce flux est invalide ». Or un changement invalide ne deviendra jamais valide avec plus d’octets : le décodeur ajoutait donc un octet, réessayait, échouait, en ajoutait un autre… et finissait par écrire au-delà des huit octets du tampon, dans les champs voisins de sa propre structure d’état. Ces champs décrivent le décodage en cours : positions, longueurs, pointeurs vers la fenêtre de sortie. Et c’est l’attaquant qui choisit les octets qui y sont écrits. Le correctif ajoute un code d’erreur dédié, renvoyé au lieu de la demande de données.

La leçon dépasse Brotli : dans une machine à états, la frontière entre « incomplet » et « invalide » est un point de sécurité. Un analyseur qui confond les deux finit soit par boucler indéfiniment, soit, comme ici, par accumuler sans fin.

PHP2Shell : la mémoire qui survit à la requête

L’allocateur de PHP

mémoire d'une requête PHP (Zend Memory Manager)
├─ blocs de 2 Mio, découpés en pages de 4 Kio
├─ petites allocations : 30 classes de taille (8, 16, 24 … 3 072 octets)
│    une page est dédiée à une classe ; chaque case libre pointe vers la suivante
└─ fin de requête : tout est rendu d'un coup, sans libérer objet par objet

PHP n’utilise pas directement l’allocateur du système pour les données des scripts. Son propre gestionnaire, le Zend Memory Manager, réserve de gros blocs et les découpe en pages, chaque page étant dédiée à une classe de taille. Une allocation de 40 octets reçoit une case de la classe 40 ; une case libérée rejoint une liste de cases libres de sa classe, et sera la prochaine donnée à une allocation de même taille. C’est rapide et cela rend le comportement très prévisible, ce qui est un avantage pour les performances et un cadeau pour un attaquant : s’il sait qu’une case de 48 octets vient d’être libérée, il sait qu’une chaîne de 48 octets qu’il envoie prendra sa place.

Interrompre un nettoyage

Quand un script dépasse memory_limit, PHP lève une erreur fatale et interrompt l’exécution par un saut direct vers le point de reprise de la requête (le mécanisme bailout, construit sur longjmp en C). Tout le code qui devait s’exécuter entre les deux est sauté. Si l’interruption survient pendant le nettoyage de structures internes, certaines sont à moitié démontées : un compteur de références décrémenté sans que l’objet soit libéré, ou l’inverse. C’est le premier bug de PHP2Shell, intitulé « RCE via OOM corruption » : un manque de mémoire provoqué au bon moment laisse le gestionnaire dans un état incohérent.

L’état qui survit

requête 1 : objet A (48 octets) alloué ; une variable GLOBALE garde un pointeur vers A
            fin de requête → la mémoire de A est libérée, le pointeur global, lui, reste
requête 2 (même processus) : l'attaquant envoie une chaîne de 48 octets
            → l'allocateur la range dans la case libre… celle de A
            → le code qui suit le pointeur global lit la chaîne comme si c'était A

Le second bug, « RCE via stale global state », exploite la réutilisation des processus. Avec PHP-FPM, un même processus traite des milliers de requêtes. La mémoire de chaque requête est rendue en bloc à la fin, mais certaines variables globales, propres au processus, survivent. Si l’une d’elles garde un pointeur vers une donnée de la requête précédente, ce pointeur désigne désormais une case libre, que la requête suivante réutilisera. L’attaquant envoie alors une donnée de la bonne taille, qui prend la place de l’ancienne. Le code qui suit le pointeur interprète ses octets comme une structure interne de PHP : il lit, à la place d’une longueur ou d’un pointeur, ce que l’attaquant a écrit. C’est la confusion de types, point de départ classique vers une lecture et une écriture arbitraires.

ZDI-CAN-33367 : aucune corruption, une page partagée

La faille noyau est d’une autre nature, et c’est ce qui la rend si fiable. Aucune zone n’est dépassée, aucun pointeur n’est périmé. Une page du cache d’un fichier est référencée par un paquet réseau grâce à une optimisation sans copie ; le marqueur qui signale « cette page est partagée » se perd dans une fonction de transfert ; le module ESP déchiffre alors « sur place », c’est-à-dire qu’il écrit son résultat dans cette page. Chaque opération est correcte en elle-même. C’est leur combinaison qui fait écrire le noyau dans un fichier en lecture seule, avec un contenu choisi par l’attaquant, qui contrôle la clé et le texte chiffré.

Comme rien n’est corrompu, aucune protection contre la corruption ne s’applique. Pas d’adresse à deviner, pas de flot d’exécution à détourner : l’attaquant modifie un fichier (un programme setuid, un fichier de configuration), puis l’exécute normalement. C’est la même famille que Dirty COW et Dirty Pipe, réputées pour fonctionner presque à coup sûr.

Les protections, et leurs limites

readelf -h /usr/sbin/php-fpm | grep Type       → DYN   (exécutable à position indépendante)
readelf -h /usr/bin/node     | grep Type       → EXEC  (adresse fixe : l'ASLR ne déplace pas son code)
readelf -n /usr/sbin/php-fpm | grep -o 'IBT\|SHSTK'   → IBT SHSTK (compilé pour CET)
grep -wo 'ibt\|user_shstk' /proc/cpuinfo        → (rien : le processeur ne gère pas CET)
ps -eZ | grep php-fpm                           → httpd_t  (confiné par SELinux)

Les systèmes modernes empilent des protections qui rendent chaque étape d’une exploitation par corruption mémoire plus coûteuse. Aucune ne corrige le bug ; toutes rendent son exploitation plus difficile.

  • ASLR : les adresses de la pile, du tas et des bibliothèques changent à chaque démarrage, donc l’attaquant doit d’abord découvrir où se trouve ce qu’il veut modifier, souvent grâce à une seconde faille de lecture. Un exécutable non compilé en position indépendante garde son code à une adresse fixe : c’est le cas des binaires officiels de Node.js sur mes serveurs, contrairement à php-fpm ;
  • pile et données non exécutables : on ne peut plus écrire du code puis sauter dessus ; il faut réutiliser des morceaux du code existant ;
  • RELRO complet : les tables d’adresses de fonctions sont mises en lecture seule après le chargement, une cible classique de moins ;
  • CET (Intel) : une pile fantôme vérifie les adresses de retour et un marquage des cibles de saut limite les détournements. php-fpm est compilé pour, mais le processeur de mon serveur, de 2017, ne le gère pas : la protection est présente dans le binaire et inactive dans les faits ;
  • le confinement : même réussie, l’exploitation donne les droits du processus attaqué. php-fpm tourne dans le domaine SELinux httpd_t, qui limite ce qu’il peut lire, exécuter et contacter. Un service sans domaine dédié (unconfined_service_t) n’a pas ce filet.

Ces protections freinent Brotli et PHP2Shell, qui doivent passer de la corruption au détournement. Elles ne font rien contre ZDI-CAN-33367, qui ne détourne rien. Contre cette famille, les seules défenses sont le correctif, la réduction de la surface (modules bloqués, espaces de noms restreints) et le confinement de ce qui pourrait servir de point d’entrée local.

Ce que j’en tire pour juger une faille

  • la classe de bug compte autant que le score : une erreur de logique sur une page partagée est souvent plus fiable qu’une corruption mémoire notée plus haut ;
  • le déclencheur : qui fournit les données ? Un client distant sans authentification (PHP2Shell, corps de requête Brotli), un serveur que l’on contacte (fetch()), un utilisateur local (noyau) ;
  • la prévisibilité de l’allocateur et la réutilisation des processus augmentent l’exploitabilité ;
  • les protections réellement actives, sur le matériel réel : un binaire compilé pour CET sur un processeur qui ne le gère pas n’est pas protégé ;
  • le confinement détermine ce que coûte une exploitation réussie : c’est la dernière ligne, et la seule qui fonctionne contre une faille encore inconnue.

Sources : RFC 7932 (format Brotli) ; avis de sécurité de PHP, Node.js ; analyses d’Akamai et de TuxCare ; code source de php-src (Zend/zend_alloc.c) et de Brotli (c/dec/decode.c).


Ce billet fait partie d’une série de tutoriels d’administration système (tag « pas à pas »).
Série « Pas à pas », tutoriel 7/77. · ← Tutoriel précédent · Tutoriel suivant → (à paraître le 4/11) · English version

Articles liés