CrowdSec protège tous mes serveurs : il lit les journaux, détecte les comportements hostiles (force brute SSH, scans web…) et fait bloquer les adresses fautives par un « bouncer » au niveau du pare-feu. Cinq machines sous Rocky Linux étaient déjà en version 1.8.1, installée depuis le dépôt officiel de CrowdSec. Restait le Raspberry Pi qui fait DNS et DHCP, sous Debian 12, avec la version 1.4.6 des dépôts Debian.
Pourquoi ne pas garder la version Debian
1.4.6 a des années de retard : scénarios, analyseurs et liste communautaire ont beaucoup évolué depuis ;
un parc homogène : un seul rôle Ansible, une seule version, un seul comportement à connaître ;
le bouncer Debian (0.0.25) avait besoin de contournements (type de service systemd, fichier de configuration parasite) que la version officielle n’exige plus.
Pourquoi une réinstallation plutôt qu’une mise à jour
On ne peut pas simplement ajouter le dépôt CrowdSec et lancer apt upgrade. Le bouncer Debian s’appelle crowdsec-firewall-bouncer, et celui du dépôt officiel crowdsec-firewall-bouncer-nftables : ce sont deux paquets différents qui fournissent les mêmes fichiers, donc ils entrent en conflit. Et avec state: present, Ansible ne remplacerait pas une version déjà installée. La solution propre est de désinstaller, puis de réinstaller depuis le dépôt officiel.
Étape 1 : sauvegarder
systemctl stop crowdsec-firewall-bouncer crowdsec
tar czf /root/backup/crowdsec-pre18.tgz /etc/crowdsec
sqlite3 /var/lib/crowdsec/data/crowdsec.db ".backup /root/backup/crowdsec-pre18.db"
sqlite3 /root/backup/crowdsec-pre18.db "PRAGMA integrity_check;" # doit répondre ok
La base SQLite contient les décisions locales et l’historique des alertes. .backup utilise l’API de sauvegarde de SQLite, ce qui donne une copie cohérente même si un processus y accède encore. Le integrity_check vérifie que la copie est saine avant d’aller plus loin.
Étape 2 : purger, en gardant de quoi revenir en arrière
DEBIAN_FRONTEND=noninteractive apt-get purge -y crowdsec crowdsec-firewall-bouncer
for d in /etc/crowdsec /etc/systemd/system/crowdsec-firewall-bouncer.service.d; do
[ -e "$d" ] && mv "$d" "$d.old-deb"
done
systemctl daemon-reload
Deux remarques. purge supprime aussi les fichiers de configuration du paquet, contrairement à remove : on repart d’une base propre. Et le purge supprime lui-même/var/lib/crowdsec, d’où l’importance de la sauvegarde de la base à l’étape 1.
Pendant cette fenêtre, le Pi n’a plus de protection CrowdSec. Le DNS, lui, continue de fonctionner : il ne dépend pas de CrowdSec. C’est une affaire de quelques minutes, le temps de l’étape suivante, détaillée dans le prochain billet… avec un piège inattendu.
Ce billet fait partie d’une série de notes d’administration système (tag « carnet d’admin »). Série « CrowdSec 1.8 sur Debian », partie 1/3. · Billet suivant → (à paraître le 4/11) · English version
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, un pointeur oublié 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)
PHP2Shell
ZDI-CAN-33367
classe de bug
écriture hors limites
utilisation après libération, état incohérent
erreur de logique : deux chemins se partagent une page
où
décodeur en continu, code C
gestionnaire de mémoire et nettoyage de requête, code C
pile réseau du noyau
corruption mémoire ?
oui
oui
non : chaque écriture est « légitime »
déclencheur
données compressées reçues
requête HTTP
utilisateur local
freiné par ASLR, CFI…
oui, en partie
oui, en partie
non
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à.
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 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 2/11) · English version
On trouve sur Internet des fichiers my.cnf de cent lignes, copiés de serveur en serveur, dont personne ne sait plus pourquoi chaque ligne est là. La plupart des valeurs par défaut de MariaDB sont raisonnables ; une dizaine de réglages seulement font une vraie différence. Ce tutoriel les passe en revue à partir d’un serveur réel : MariaDB 12.3 sur Rocky Linux, une trentaine de sites (WordPress, Matomo, Piwigo…), 32 Go de RAM partagés avec PHP, nginx et d’autres services, des disques rotatifs.
Pour chaque réglage : ce qu’il fait, comment choisir la valeur, et surtout comment vérifier qu’elle est bonne. Un réglage qu’on ne sait pas mesurer est un réglage qu’on ne devrait pas toucher.
Où vit la configuration, et comment lire la valeur réelle
MariaDB lit /etc/my.cnf puis les fichiers de /etc/my.cnf.d/. Les réglages du serveur vont dans une section [mariadb] (lue seulement par MariaDB) ou [mysqld] (lue aussi par les outils compatibles MySQL). Le piège classique : une même variable définie dans deux fichiers, la dernière lue l’emporte. Deux commandes donnent la vérité :
mariadbd --print-defaults # ce que le serveur lira au prochain démarrage
mariadb -e "SHOW GLOBAL VARIABLES LIKE 'innodb_buffer_pool_size'" # la valeur en cours
Une variable mal orthographiée dans le fichier est ignorée avec un simple avertissement dans le journal d’erreurs. Comparez toujours la valeur en cours avec celle que vous croyez avoir mise.
innodb_buffer_pool_size, le réglage qui compte le plus
C’est le cache dans lequel InnoDB garde les pages de tables et d’index. Tant que les données réellement utilisées y tiennent, les lectures ne touchent pas le disque, et c’est souvent un facteur cent sur les temps de réponse. La règle habituelle : 60 à 75 % de la RAM sur un serveur dédié à la base, 30 à 40 % sur un serveur qui fait aussi tourner l’application et le serveur web. Ici, 12 Go sur 32.
Pour savoir s’il suffit, mesurez la proportion de lectures servies par le cache :
SELECT ROUND(100 * (1 - r.v / q.v), 2) AS hit_ratio_pct
FROM (SELECT VARIABLE_VALUE v FROM information_schema.GLOBAL_STATUS
WHERE VARIABLE_NAME = 'Innodb_buffer_pool_reads') r,
(SELECT VARIABLE_VALUE v FROM information_schema.GLOBAL_STATUS
WHERE VARIABLE_NAME = 'Innodb_buffer_pool_read_requests') q;
Au-dessus de 99 % sur une journée d’activité normale, la mémoire n’est pas votre problème. En dessous de 95 %, chaque requête qui touche des données froides va lire le disque : augmentez le cache si la RAM le permet, ou cherchez les requêtes qui balaient des tables entières. Depuis MariaDB 10.5, il n’y a plus lieu de découper le cache en innodb_buffer_pool_instances : c’est géré en interne.
innodb_log_file_size : dimensionner le journal redo
Chaque modification est d’abord écrite dans le journal redo, puis les pages modifiées sont écrites dans les fichiers de données en tâche de fond. Un journal trop petit oblige InnoDB à vider ses pages en urgence quand il se remplit, ce qui produit des pics de latence. Une bonne taille couvre environ une heure d’écritures. Mesurez-la :
SELECT VARIABLE_VALUE INTO @a FROM information_schema.GLOBAL_STATUS WHERE VARIABLE_NAME='Innodb_lsn_current';
SELECT SLEEP(3600);
SELECT VARIABLE_VALUE INTO @b FROM information_schema.GLOBAL_STATUS WHERE VARIABLE_NAME='Innodb_lsn_current';
SELECT ROUND((@b - @a) / 1024 / 1024) AS redo_mb_per_hour;
Si le résultat est de 400 Mo par heure, un journal de 512 Mo à 1 Go suffit ; ici 2 Go laissent de la marge pour les imports. Un journal plus grand allonge seulement la récupération après un crash, de quelques secondes à quelques minutes.
La mémoire par connexion : le piège de la multiplication
sort_buffer_size, join_buffer_size, read_buffer_size, read_rnd_buffer_size sont alloués par connexion, et parfois plusieurs fois par requête. Une valeur de 64 Mo pour join_buffer_size semble anodine, mais multipliée par 400 connexions elle peut théoriquement réclamer 25 Go. Gardez les valeurs par défaut sauf mesure contraire, et relevez-les seulement pour une session précise (SET SESSION) lors d’un traitement lourd.
tmp_table_size et max_heap_table_size vont ensemble : la plus petite des deux fixe la taille maximale d’une table temporaire en mémoire avant son passage sur disque. Surveillez le rapport entre Created_tmp_disk_tables et Created_tmp_tables : s’il dépasse 10 à 20 %, des requêtes trient ou regroupent de gros volumes.
La durabilité
innodb_flush_log_at_trx_commit = 1 écrit et synchronise le journal redo à chaque validation ; sync_binlog = 1 fait de même pour le binlog. Ensemble, ils garantissent qu’une coupure de courant ne fait perdre aucune transaction validée et que le réplica reste cohérent avec le serveur principal. Les valeurs 2 ou 0 réduisent le nombre d’écritures synchrones, au prix de jusqu’à une seconde de transactions perdues en cas de crash du système : acceptable sur un réplica qu’on reconstruit, jamais sur le principal.
innodb_flush_method = O_DIRECT fait écrire InnoDB directement sur le disque sans passer par le cache du système : les données ne sont pas mises en cache deux fois (dans le cache d’InnoDB et dans celui du noyau). C’est la valeur par défaut des versions récentes ; vérifiez-la plutôt que de la supposer.
Les entrées/sorties
innodb_io_capacity indique à InnoDB combien d’opérations par seconde le stockage peut absorber pour ses travaux de fond (vidage des pages, purge). La valeur par défaut convient aux SSD. Sur des disques rotatifs, 200 évite qu’InnoDB sature le disque au détriment des requêtes ; innodb_io_capacity_max fixe le plafond en cas de retard. Pour vérifier : iostat -x 5 pendant une période chargée, la colonne %util ne doit pas rester collée à 100.
Les connexions
max_connections doit couvrir la somme des connexions que les applications peuvent ouvrir, avec une marge, sans plus : chaque connexion consomme de la mémoire, et augmenter cette limite pour « réparer » des erreurs Too many connections cache souvent une application qui ne ferme pas ses connexions. Regardez le pic réel :
SHOW GLOBAL STATUS LIKE 'Max_used_connections';
SHOW GLOBAL STATUS LIKE 'Aborted_c%';
SHOW GLOBAL STATUS LIKE 'Threads_created';
thread_cache_size garde des fils d’exécution prêts à resservir : si Threads_created augmente rapidement, augmentez-le. wait_timeout ferme les connexions inactives ; 8 heures conviennent aux applications web, qui se reconnectent d’elles-mêmes.
Les tables et les fichiers
table_open_cache garde les tables ouvertes entre deux requêtes ; il est découpé en table_open_cache_instances pour limiter la contention entre fils. Si Opened_tables augmente sans cesse, le cache est trop petit.
Le piège qui m’a coûté du temps : ce cache dépend du nombre de fichiers que le processus a le droit d’ouvrir. MariaDB calcule ses limites à partir d’open_files_limit, mais systemd impose sa propre limite (LimitNOFILE). Si celle-ci est plus basse, MariaDB réduit silencieusement ses caches au démarrage : j’ai ainsi trouvé un serveur réglé sur 8 instances qui n’en utilisait que 3. La correction est un fichier de surcharge systemd :
systemctl daemon-reload && systemctl restart mariadb
mariadb -e "SHOW GLOBAL VARIABLES WHERE Variable_name IN ('open_files_limit','table_open_cache_instances')"
Les binlogs et la réplication
Le binlog enregistre chaque modification ; il sert à la réplication et à la restauration à un instant précis. binlog_format = ROW enregistre les lignes modifiées plutôt que les requêtes : plus volumineux, mais sans ambiguïté (une requête avec NOW() ou RAND() ne donne pas le même résultat rejouée ailleurs). binlog_expire_logs_seconds purge les anciens fichiers : gardez au moins l’intervalle entre deux sauvegardes complètes, sinon la restauration à un instant précis devient impossible pour la période intermédiaire. server_id doit être unique dans toute la topologie de réplication, et log_slave_updates permet à un réplica de servir lui-même de source.
L’hygiène
utf8mb4 : le vrai UTF-8 sur 4 octets, émojis compris. Le jeu utf8 historique est limité à 3 octets et tronque certains caractères ;
collation_server : les versions récentes proposent les collations UCA 14.0 (utf8mb4_uca1400_ai_ci), plus justes pour les comparaisons et les tris en français ;
READ-COMMITTED : moins de verrous que le REPEATABLE-READ par défaut, adapté aux applications web ;
skip_name_resolve : aucune résolution DNS à chaque connexion (un DNS lent rendrait les connexions lentes), et les droits se donnent par adresse IP ;
local_infile = OFF : ferme une porte qui permet à un client de faire lire des fichiers ;
max_allowed_packet : la taille maximale d’une requête ou d’une ligne ; trop petite, les imports et les gros contenus échouent ;
le journal des requêtes lentes : au-delà de 5 secondes, avec le plan d’exécution. À relire chaque semaine : c’est la meilleure source d’optimisation qui soit.
La méthode
changez un seul réglage à la fois, et notez la mesure avant et après ;
beaucoup de variables se modifient à chaud (SET GLOBAL) pour un essai ; écrivez-les ensuite dans le fichier, sinon elles disparaissent au redémarrage ;
celles qui exigent un redémarrage (taille du journal redo, open_files_limit) se planifient ;
gardez la configuration sous gestion de version, avec en commentaire la raison de chaque valeur non standard. Dans six mois, ce commentaire vaudra plus que la valeur elle-même.
Si un exporteur Prometheus surveille la base, comparez sur quelques jours le taux de réussite du cache, les tables temporaires sur disque, les connexions et la latence des requêtes. Un réglage n’est bon que si ces courbes le confirment.
Ce tutoriel relie un serveur hébergé (un VPS sous Rocky Linux) au réseau de la maison par un tunnel WireGuard. À la fin, le serveur joint les machines de la maison, la maison joint le serveur, et seul un port UDP est exposé sur Internet. C’est la base de nombreux usages : sauvegardes envoyées vers un NAS, supervision d’un serveur public depuis la maison, accès à des services qu’on ne veut surtout pas publier.
Chaque ligne de configuration est expliquée, ainsi que les vérifications à faire et les pannes les plus fréquentes. Les adresses sont des exemples ; adaptez-les à votre plan d’adressage.
Pourquoi WireGuard
WireGuard tient en quelques milliers de lignes de code dans le noyau Linux, contre des centaines de milliers pour OpenVPN ou IPsec. Il n’offre aucun choix d’algorithme : la cryptographie est fixée (Curve25519, ChaCha20-Poly1305, BLAKE2s), ce qui supprime une source entière d’erreurs de configuration. Il est sans état apparent : pas de session à négocier à la main, une poignée de main renouvelée automatiquement toutes les deux minutes environ, et un tunnel qui survit aux changements d’adresse d’un des pairs. Enfin, il est silencieux : un paquet qui n’est pas authentifié par une clé connue ne reçoit aucune réponse, le port semble fermé à un balayage.
Les notions à avoir en tête
Une paire de clés par extrémité. La clé privée reste sur la machine ; la clé publique sert d’identité auprès de l’autre côté. Il n’y a ni utilisateur ni mot de passe ;
Des pairs, pas un client et un serveur. Chaque côté déclare l’autre comme pair. Ce qui ressemble à un « serveur » est simplement le pair qui a une adresse fixe et attend qu’on l’appelle ;
Le routage par clé. La directive AllowedIPs associe des réseaux à un pair. Un paquet destiné à ces réseaux part chiffré vers ce pair ; un paquet reçu de ce pair n’est accepté que si son adresse source appartient à ces réseaux ;
UDP uniquement. Tout passe dans un seul port UDP, ce qui traverse bien les traductions d’adresses des box.
Le plan d’adressage
Élément
Valeur
Rôle
Réseau du tunnel
10.99.0.0/24
adresses internes au tunnel, une par extrémité
Routeur (tunnel)
10.99.0.2
côté maison, derrière la box
Serveur (tunnel)
10.99.0.3
le VPS
Réseau de la maison
192.168.10.0/24
ce que le serveur doit pouvoir joindre
Serveur (public)
203.0.113.25
adresse publique fixe du VPS, port UDP 51820
Choisissez un réseau de tunnel qui n’existe nulle part ailleurs chez vous, ni chez l’hébergeur : un chevauchement d’adresses produit des pannes très difficiles à comprendre.
Prérequis
un serveur Rocky Linux 9 ou 10 avec une adresse IPv4 publique fixe et un accès root ;
un routeur à la maison qui sait faire du WireGuard (RouterOS 7 de MikroTik, OpenWrt, pfSense, OPNsense…), ou à défaut une machine Linux allumée en permanence ;
un accès console au serveur chez l’hébergeur, au cas où une erreur de pare-feu couperait SSH.
Étape 1 : installer les outils
Le module noyau fait partie de Rocky Linux depuis la version 9. Il ne manque que les outils en espace utilisateur, wg et wg-quick :
dnf install wireguard-tools
modprobe wireguard
lsmod | grep wireguard # le module est chargé
Étape 2 : générer les clés
umask 077
cd /etc/wireguard
wg genkey | tee server.key | wg pubkey > server.pub
wg genpsk > maison.psk # clé partagée, facultative mais recommandée
cat server.pub
Le umask 077 garantit que les fichiers sont créés lisibles par root seulement, sans fenêtre pendant laquelle un autre utilisateur pourrait les lire. La clé partagée (preshared key) ajoute une couche de chiffrement symétrique par-dessus l’échange de clés : elle ne remplace rien, mais protège le trafic enregistré aujourd’hui contre un futur ordinateur quantique capable de casser Curve25519. Elle doit être identique des deux côtés et ne circuler que par un canal sûr.
Faites de même sur le routeur, et notez sa clé publique. Les clés privées n’apparaissent jamais dans un fichier versionné en clair : placez-les dans un coffre à secrets (Vault, OpenBao, Ansible Vault) si vous automatisez le déploiement.
Étape 3 : écrire /etc/wireguard/wg0.conf
[Interface]
Address = 10.99.0.3/24
ListenPort = 51820
PrivateKey = <clé privée du serveur>
MTU = 1420
SaveConfig = false
[Peer]
# routeur de la maison
PublicKey = <clé publique du routeur>
PresharedKey = <clé partagée>
AllowedIPs = 192.168.10.0/24, 10.99.0.0/24
PersistentKeepalive = 25
La section [Interface]
Address : l’adresse du serveur dans le tunnel. Le masque /24 fait créer par wg-quick une route vers tout le réseau du tunnel, utile si d’autres serveurs rejoignent le même tunnel plus tard ;
ListenPort : le port UDP d’écoute. 51820 est la convention, rien n’oblige à l’utiliser ;
PrivateKey : le contenu de server.key. Une variante consiste à ne pas l’écrire dans le fichier et à la charger par PostUp = wg set %i private-key /etc/wireguard/server.key ;
MTU = 1420 : la taille maximale des paquets dans le tunnel. WireGuard ajoute 60 octets d’en-tête en IPv4 (80 en IPv6) ; 1420 laisse la place sur un lien Ethernet classique à 1500. Si la box de la maison utilise PPPoE (MTU 1492), descendez à 1412, voire 1380 en cas de doute ;
SaveConfig = false : empêche wg-quick de réécrire le fichier à l’arrêt avec l’état courant, ce qui effacerait vos commentaires et pourrait y figer une modification faite à la main pour un test.
La section [Peer]
PublicKey : l’identité du routeur ;
PresharedKey : la clé partagée générée plus haut ;
AllowedIPs : la directive la plus importante, et la plus mal comprise. Elle sert à la fois de table de routage (les paquets vers 192.168.10.0/24 et 10.99.0.0/24 partent vers ce pair) et de filtre (un paquet reçu de ce pair dont la source n’est pas dans ces réseaux est jeté). On n’y met pas 0.0.0.0/0 : seul le trafic destiné à la maison passe par le tunnel, le reste du trafic du serveur sort normalement. C’est ce qu’on appelle un tunnel partagé (split tunnel) ;
pas d’Endpoint : le serveur ne sait pas où joindre la maison, dont l’adresse publique peut changer. C’est le routeur qui l’appelle ; le serveur apprend son adresse à la première poignée de main et la met à jour s’il change ;
PersistentKeepalive = 25 : un petit paquet toutes les 25 secondes. Indispensable côté routeur, car la box oublie une traduction d’adresses UDP inactive au bout de 30 à 120 secondes et le serveur ne pourrait plus joindre la maison. Côté serveur, c’est sans inconvénient et cela garde le tunnel actif dans les deux sens.
Étape 4 : le côté maison
Le routeur déclare le serveur comme pair, avec cette fois un Endpoint : l’adresse publique du VPS. Ses AllowedIPs pour ce pair se limitent à l’adresse du serveur dans le tunnel. Exemple sur un MikroTik sous RouterOS 7 :
Il faut aussi autoriser, dans le pare-feu du routeur, le trafic entre l’interface WireGuard et le réseau local (chaîne forward), et le placer avant les règles de blocage génériques. Une règle courante qui rejette les adresses privées (« bogons ») doit ne s’appliquer qu’à l’interface côté Internet : sinon elle rejette le réseau du tunnel lui-même.
Étape 5 : le pare-feu du serveur
firewall-cmd --permanent --add-port=51820/udp
# rattacher wg0 à une zone : 'trusted' pour tout autoriser depuis la maison,
# ou une zone dédiée qui n'ouvre que le nécessaire (SSH, exporteurs…)
firewall-cmd --permanent --new-zone=wireguard
firewall-cmd --permanent --zone=wireguard --add-interface=wg0
firewall-cmd --permanent --zone=wireguard --add-service=ssh
firewall-cmd --reload
Une zone dédiée est plus prudente que trusted : si une machine de la maison était compromise, elle ne verrait du serveur que les services que vous avez choisis. Le serveur n’a pas besoin de faire suivre des paquets entre réseaux (net.ipv4.ip_forward peut rester à 0) puisqu’il n’est qu’une extrémité.
Étape 6 : démarrer
systemctl enable --now wg-quick@wg0
wg-quick crée l’interface wg0, lui donne son adresse, charge la configuration WireGuard et ajoute les routes déduites d’AllowedIPs. Le service se relance au démarrage. Pour appliquer une modification du fichier sans couper le tunnel : wg syncconf wg0 <(wg-quick strip wg0).
Étape 7 : vérifier, dans l’ordre
wg show wg0
# peer: <clé publique du routeur>
# endpoint: 198.51.100.7:13231
# allowed ips: 192.168.10.0/24, 10.99.0.0/24
# latest handshake: 41 seconds ago
# transfer: 1.20 MiB received, 3.45 MiB sent
latest handshake de moins de deux minutes : les clés et le port UDP sont bons ;
endpoint renseigné : le serveur connaît l’adresse actuelle de la maison ;
transfer qui augmente dans les deux sens : du trafic circule.
ip route get 192.168.10.20 # doit répondre « dev wg0 »
ping -c3 10.99.0.2 # le routeur dans le tunnel
ping -c3 192.168.10.20 # une machine de la maison
ssh admin@192.168.10.20 # une vraie connexion TCP
# depuis une machine de la maison, dans l'autre sens :
ssh root@10.99.0.3
Ne vous arrêtez pas au ping. Un pare-feu peut accepter l’ICMP tôt dans sa chaîne et rejeter les nouvelles connexions TCP plus loin : le ping passe, rien d’autre. Testez une vraie connexion, dans les deux sens.
Les pannes les plus fréquentes
Aucune poignée de main
Le port UDP est fermé (chez l’hébergeur ou dans firewalld), les clés publiques sont inversées, ou le routeur a une mauvaise adresse d’Endpoint. tcpdump -ni eth0 udp port 51820 sur le serveur montre si les paquets du routeur arrivent : s’ils arrivent sans réponse, c’est une clé ; s’ils n’arrivent pas, c’est le réseau.
Poignée de main, mais aucun trafic
Les AllowedIPs ne correspondent pas des deux côtés : le routeur envoie des paquets dont la source n’est pas dans la liste du serveur, qui les jette sans bruit. Ou la route de retour manque côté maison.
Le ping passe, le TCP non
Une règle de pare-feu traite l’ICMP à part. Le cas typique est une liste d’adresses « bogons » contenant 10.0.0.0/8 et appliquée à toutes les interfaces : elle rejette le réseau du tunnel dans la chaîne forward. Limitez-la à l’interface Internet.
Les petits échanges passent, les gros transferts se figent
Un problème de MTU. Les paquets pleins dépassent la taille permise quelque part sur le chemin et sont perdus. Testez avec ping -M do -s 1372 192.168.10.20 en augmentant la taille jusqu’à l’échec, puis baissez le MTU du tunnel en conséquence des deux côtés.
Tout marche, puis plus rien au bout d’un moment
Le PersistentKeepalive manque côté routeur, et la box a oublié la traduction d’adresses. Ou l’adresse publique de la maison a changé et le serveur ne l’a pas encore apprise : c’est au routeur de relancer la poignée de main, ce que le keepalive fait tout seul.
Les noms des machines de la maison ne se résolvent pas
Le tunnel transporte des adresses, pas des noms. Pour résoudre nas.maison.lan depuis le serveur, faites passer uniquement ce domaine par le serveur DNS de la maison, par exemple avec le greffon dnsmasq de NetworkManager, plutôt que de remplacer tout le DNS du serveur.
Entretenir le tunnel
Changer de clés sans perdre l’accès : ajoutez d’abord la nouvelle clé publique du serveur comme second pair sur le routeur, basculez le serveur sur sa nouvelle clé, vérifiez la poignée de main, puis retirez l’ancien pair. Ne modifiez jamais les deux côtés à l’aveugle depuis l’extérieur ;
Ajouter un second serveur : nouvelle adresse dans le tunnel (10.99.0.4), nouveau pair sur le routeur avec allowed-address=10.99.0.4/32. Chaque adresse ne doit apparaître que dans un seul pair, sinon le routage devient imprévisible ;
Surveiller : l’âge de la dernière poignée de main est le meilleur indicateur. Au-delà de trois minutes, le tunnel est tombé. Un script qui lit wg show wg0 latest-handshakes et publie la valeur pour Prometheus, ou une simple sonde depuis la maison, suffit ;
Modifier un routeur à distance : activez son mode sans échec (Safe Mode sur RouterOS) avant toute modification. Si la session est perdue, le routeur annule tout ce qui a été fait depuis.
Pour aller plus loin
Le même tunnel peut transporter la supervision (Prometheus qui interroge les exporteurs du serveur), les sauvegardes (rsync vers un compte enfermé dans un répertoire du NAS) et l’administration (SSH limité à l’interface du tunnel). Une fois le tunnel en place, beaucoup de ports qu’on aurait été tenté d’ouvrir sur Internet n’ont plus besoin de l’être.
Le 20 octobre 2026, un rapport de la Zero Day Initiative destiné à l’équipe de sécurité du noyau Linux a été envoyé par erreur sur des listes de diffusion publiques. Il décrivait une élévation de privilèges locale, enregistrée sous le numéro ZDI-CAN-33367, et un programme de démonstration a circulé dans la foulée. Dix jours plus tard, la faille n’a toujours pas de numéro CVE, le correctif proposé n’est pas encore intégré au noyau officiel, et le noyau de ma distribution publié le 28 octobre ne le contient pas. Elle permet à n’importe quel utilisateur local de modifier des fichiers qu’il ne peut que lire (un programme setuid, /etc/passwd) et donc de devenir root.
Ce billet explique le mécanisme, sans détail d’exploitation, parce qu’il illustre une famille de failles qui revient régulièrement : celles où une optimisation qui évite de copier des données finit par écrire là où elle ne devrait pas. Il décrit ensuite la mesure que j’ai appliquée en attendant un noyau corrigé, et pourquoi j’ai écarté celle que la plupart des guides recommandent.
Une famille de failles
Faille
Année
Le principe commun
Dirty COW (CVE-2016-5195)
2016
une course dans la copie sur écriture permet d’écrire dans une page projetée en lecture seule
Dirty Pipe (CVE-2022-0847)
2022
un drapeau mal réinitialisé dans un tube permet d’écrire dans les pages du cache d’un fichier
Dirty Frag (CVE-2026-43284)
2026
le déchiffrement ESP « sur place » écrit dans des fragments réseau qui pointent vers le cache de fichiers
ZDI-CAN-33367
octobre 2026
le correctif de Dirty Frag repose sur un marqueur que skb_zerocopy() ne recopie pas
Toutes ces failles exploitent le même point faible : le cache de pages. Quand un programme lit un fichier, le noyau en garde une copie en mémoire, et toutes les lectures suivantes, par n’importe quel utilisateur, sont servies depuis cette copie. Les droits d’accès sont vérifiés à l’ouverture du fichier ; ensuite, le noyau manipule les pages en mémoire. Si un chemin du noyau écrit dans une de ces pages par erreur, le contenu du fichier change pour tout le système, sans que les droits aient jamais été consultés, et sans que le fichier sur disque soit modifié : un contrôle d’intégrité qui relit le disque après avoir vidé le cache ne voit rien.
Le mécanisme, étape par étape
1. Éviter les copies
Copier des données coûte cher. Pour envoyer un fichier sur le réseau, le noyau sait éviter de le copier : avec splice() ou sendfile(), le paquet réseau ne contient pas les octets du fichier mais une référence vers les pages du cache qui les contiennent. Dans le noyau, un paquet est représenté par une structure appelée skb (socket buffer), dont les données peuvent être réparties en fragments, chacun pointant vers une page mémoire. Un fragment peut donc pointer vers une page du cache d’un fichier que l’utilisateur a seulement le droit de lire. Tant que personne n’écrit dans ce fragment, c’est sans danger.
2. Déchiffrer sur place
IPsec protège des paquets IP en les chiffrant avec le protocole ESP. À la réception, le module esp4 (ou esp6) déchiffre le paquet. Pour aller vite, il le déchiffre sur place : le texte clair remplace le texte chiffré, dans la même mémoire. Si cette mémoire est un fragment qui pointe vers une page du cache d’un fichier, le « déchiffrement » écrit dans le fichier. L’attaquant contrôle la clé et les données chiffrées, puisqu’il configure lui-même sa liaison IPsec : il choisit donc ce que le déchiffrement produit.
3. Le correctif de Dirty Frag, et son marqueur
C’est exactement Dirty Frag, corrigée plus tôt en 2026. Le correctif a ajouté un marqueur, SKBFL_SHARED_FRAG, posé sur un paquet dont les fragments pointent vers une mémoire partagée avec quelqu’un d’autre. Avant de déchiffrer, ESP vérifie ce marqueur : s’il est présent, il copie d’abord les données dans une mémoire à lui (copie sur écriture), puis déchiffre la copie.
4. Le marqueur perdu
fichier en lecture seule ──lecture──▶ page du cache (mémoire)
│ splice() / envoi sans copie : le paquet RÉFÉRENCE la page, il ne la copie pas
▼
paquet réseau (skb) ── fragment ──▶ même page marqueur SHARED_FRAG posé ✔
│ NFQUEUE ou Open vSwitch : skb_zerocopy() transfère le fragment vers un nouveau skb
▼
nouveau skb ── fragment ──▶ même page marqueur PERDU ✘
│ réception ESP (IPsec) : « pas partagé, je déchiffre sur place »
▼
le « déchiffrement » écrit dans la page du cache ──▶ le fichier lu par tous est modifié
La nouvelle faille tient dans une fonction, skb_zerocopy(), qui existe depuis le noyau 3.14. Elle sert à transférer le contenu d’un paquet vers un autre sans le copier, en déplaçant les références vers les fragments. Elle est utilisée notamment par NFQUEUE, le mécanisme qui envoie des paquets du pare-feu vers un programme en espace utilisateur, et par Open vSwitch. Elle transfère les fragments, mais pas le marqueur : le nouveau paquet pointe vers les mêmes pages du cache, sans indiquer qu’elles sont partagées. ESP, plus loin, ne voit pas le marqueur, déchiffre sur place, et l’écriture atteint le cache du fichier. Le correctif proposé tient en deux lignes : recopier le marqueur.
Ce schéma est fréquent. Un correctif ajoute une information de sécurité à une structure ; tous les chemins qui créent ou recopient cette structure doivent la propager ; un seul oubli, dans une fonction écrite dix ans plus tôt, et le correctif ne protège plus rien sur ce chemin.
Configurer IPsec, une file NFQUEUE ou des règles de contrôle de trafic demande normalement des droits d’administration réseau (la capacité CAP_NET_ADMIN). Mais les espaces de noms utilisateur, activés par défaut sur la plupart des distributions, permettent à un utilisateur ordinaire de créer un espace où il est root, avec son propre espace de noms réseau dans lequel il a cette capacité. Il ne peut agir que sur ses interfaces à lui, mais c’est suffisant : la chaîne décrite ci-dessus se déroule entièrement dans son espace réseau, et c’est le noyau, commun à tous, qui écrit dans le cache de pages, commun à tous lui aussi.
La mesure recommandée, et pourquoi je ne l’ai pas prise
La mesure la plus citée consiste à interdire les espaces de noms utilisateur aux utilisateurs ordinaires (user.max_user_namespaces=0). Elle coupe la chaîne à la racine, et pas seulement pour cette faille. Je l’ai écartée parce qu’elle casse des choses que j’utilise : les services systemd durcis avec PrivateUsers=, et les conteneurs Podman sans privilège sur une de mes machines, qui reposent précisément sur ce mécanisme.
L’autre approche consiste à empêcher le chargement des modules du noyau dont la chaîne a besoin. Un module bloqué de cette façon ne peut plus être chargé à la demande, même par root dans un espace de noms : la directive install … /bin/false remplace le chargement par une commande qui échoue, et blacklist empêche le chargement automatique par alias. Le choix des modules demande de vérifier ce qui est réellement utilisé.
esp4 et esp6 : c’est là que se produit l’écriture. Sans ESP, pas de déchiffrement sur place, donc pas d’écriture dans le cache. Aucun de mes serveurs n’utilise IPsec : mes tunnels sont en WireGuard, qui n’a rien à voir avec ESP ;
nlmon et act_pedit : deux modules utilisés par les démonstrations publiées pour mettre en place la chaîne, inutiles sur mes serveurs ;
pas nfnetlink_queue : NFQUEUE est l’un des deux chemins qui perdent le marqueur, mais Suricata l’utilise en mode prévention d’intrusion sur trois de mes serveurs. Le bloquer couperait le filtrage. Couper la chaîne à l’étape ESP suffit.
modprobe -n -v esp4 # ce que ferait un chargement
install /bin/false # → bloqué
lsmod | grep -E '^(esp4|esp6|nlmon|act_pedit|nfnetlink_queue) '
nfnetlink_queue 36864 1 # chargé : utilisé par Suricata en mode IPS
La vérification se fait en deux temps : modprobe -n -v montre ce que ferait un chargement (ici, exécuter /bin/false, donc échouer), et lsmod confirme qu’aucun des modules bloqués n’est déjà chargé, car un module déjà en mémoire n’est pas affecté par le fichier de configuration. Si l’un l’avait été, il aurait fallu le décharger, ou redémarrer.
Suivre l’arrivée du vrai correctif
dnf -q repoquery --changelogs --latest-limit 1 kernel-core | grep -ciE 'skb_zerocopy|SHARED_FRAG'
0 # le noyau 6.12.0-211.69.1 disponible le 30 octobre ne contient pas le correctif
Cette mesure est une attente. Pour savoir quand la retirer, il faut surveiller les noyaux de la distribution. En l’absence de numéro CVE, les outils qui comparent des listes de CVE ne servent à rien : c’est le journal des modifications du paquet noyau qu’il faut lire, en y cherchant le nom de la fonction ou du marqueur. Le noyau publié le 28 octobre corrige seize autres CVE mais pas celle-ci. Un dérivé de Red Hat a publié un noyau corrigé dans son dépôt de test ; un autre propose un correctif appliqué à chaud. Quand un noyau corrigé arrivera, je l’installerai, je redémarrerai, puis je viderai la liste des modules bloqués.
Et si l’on ne peut rien bloquer ?
Sur une machine qui utilise IPsec, la mesure ne s’applique pas. Il reste à interdire les espaces de noms utilisateur à ceux qui n’en ont pas besoin, à limiter les comptes locaux (la faille demande un accès local : un compte, ou une application web compromise), et à surveiller les signes d’exploitation : création d’espaces de noms par des comptes de service, chargement des modules concernés, modification d’un binaire setuid détectée par la comparaison de son empreinte en mémoire avec celle du paquet (rpm -V).
Ce que j’en retiens
le cache de pages est partagé par tous : toute écriture accidentelle dans une de ses pages contourne les droits d’accès ;
les optimisations « sans copie » sont la source récurrente de ces failles, depuis Dirty COW ;
un correctif qui repose sur un marqueur n’est complet que si tous les chemins le propagent ;
les espaces de noms utilisateur transforment beaucoup de failles « réservées à root » en failles accessibles à tous ;
bloquer un module demande d’abord de vérifier qu’on ne s’en sert pas ;
sans CVE, c’est le journal des modifications qui dit si un noyau est corrigé.
Sources : analyse publiée sur GitHub (FranklinF25/zdi-can-33367) ; billets de TuxCare, CloudLinux, Sysdig et AlmaLinux, octobre 2026.
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
CVE
Gravité
Ce qui se passe
Branches
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ée
dns.resolve() tronque un nom au premier octet NUL
22, 24, 26
CVE-2026-105483
élevée
vérification de certificat contournée avec une adresse IP écrite en chiffres pleine chasse
24, 26
CVE-2026-106069
élevée
vé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-105481
moyenne
connexion persistante : une réponse arrivée avant la requête est prise pour sa réponse
22, 24, 26
CVE-2026-106072
moyenne
adresses IPv4 non canoniques authentifiées par un nom DNS du certificat
22, 24, 26
CVE-2026-106074
moyenne
réserves d’aléa de randomUUID() figées dans les instantanés de démarrage
22, 24, 26
CVE-2026-106070
faible
ZipFile renvoie des octets contrôlés par l’attaquant après un en-tête invalide
26
CVE-2026-106071
faible
modèle de permissions : un joker accorde plus que prévu
26
CVE-2026-106073
faible
modè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.
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).
Après chaque redémarrage du serveur qui héberge OpenBao, voici la procédure que je suis, dans l’ordre. Elle tient en cinq minutes.
1. Pointer le client sur le bon serveur
Chez moi, OpenBao écoute en HTTP sur 127.0.0.1, et nginx assure le TLS devant. Le client bao essaie HTTPS par défaut, d’où cette erreur trompeuse :
bao status
# Error checking seal status: … http: server gave HTTP response to HTTPS client
export BAO_ADDR=http://127.0.0.1:8200 # en local, sur le serveur
# ou : export BAO_ADDR=https://secrets.example.org (via nginx)
Je mets cet export dans le .bashrc de root sur ce serveur, pour ne plus y penser.
2. Vérifier l’état
bao status
# Sealed true
# Total Shares 5
# Threshold 5
# Unseal Progress 0/5
Sealed true confirme le besoin de desceller. Unseal Progress montre combien de parts ont déjà été saisies : la saisie peut se faire en plusieurs fois, et même par plusieurs personnes.
3. Desceller
bao operator unseal # répété 5 fois ; chaque part est demandée sans écho à l'écran
Sans argument, la commande demande la part de façon interactive et ne l’affiche pas. Ne passez jamais une part en argument de la ligne de commande : elle finirait dans l’historique du shell et dans la liste des processus. Et ne la collez jamais dans un outil qui garde un historique, qu’il s’agisse d’une messagerie ou d’un assistant.
4. Vérifier la chaîne complète
Un coffre descellé ne suffit pas : il faut que les déploiements puissent vraiment lire leurs secrets. Je teste donc depuis le nœud de contrôle Ansible, avec la même identité AppRole que les playbooks :
# connexion AppRole, puis lecture des métadonnées d'un secret (pas de sa valeur)
bao write -field=token auth/approle/login role_id=… secret_id=… > /tmp/t
BAO_TOKEN=$(cat /tmp/t) bao kv metadata get -mount=ansible postgresql | head -3
shred -u /tmp/t
Lire les métadonnées (version, dates) prouve que l’authentification et les droits fonctionnent, sans faire transiter la valeur du secret.
5. Reprendre les déploiements
Un ansible-playbook … --check sur n’importe quel playbook qui lit un secret confirme que tout est revenu à la normale.
Ne pas l’oublier
Le plus grand risque, c’est l’oubli : un redémarrage nocturne, et personne ne s’aperçoit que les déploiements sont bloqués avant d’en avoir besoin. Une alerte Prometheus sur l’état de scellement préviendrait dès que ça arrive : le point /v1/sys/health renvoie 503 quand le coffre est scellé, et une sonde blackbox suffit à le surveiller. C’est la prochaine amélioration sur ma liste. Après chaque redémarrage du serveur de la maison, le descellement fait désormais partie de ma checklist, au même titre que la vérification des services.
Ce billet fait partie d’une série de notes d’administration système (tag « carnet d’admin »). Série « OpenBao scellé », partie 2/2. · ← Billet précédent · English version
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.
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é
Machine
Bibliothèque système
Corrigée ?
Pourquoi
serveur web principal
1.1.0-8.el10_2.1
oui
mise à jour manuelle le 22–23 octobre
laboratoire
1.1.0-8.el10_2.1
oui
idem
serveur de courrier
1.1.0-8.el10_2.1
oui
idem
serveur Wazuh
1.1.0-8.el10_2
non (au 30 octobre)
mises à jour automatiques en mode « sécurité » seulement, et aucun avis publié
Raspberry Pi (annuaire)
1.0.9-10.el9_8
non
mises à 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.
PHP2Shell est le nom donné à deux failles de PHP publiées le 21 octobre 2026, CVE-2026-109618 et CVE-2026-109619. Elles permettent, par une simple requête HTTP et sans authentification, d’exécuter du code sur un serveur qui fait tourner PHP. PHP alimentant environ 70 % des sites web dont la technologie serveur est connue (WordPress, Drupal, Joomla, et une foule d’applications), la portée est considérable : des sites gouvernementaux, Wikimedia et l’Internet Archive ont été mis hors ligne par précaution, et certains spécialistes l’ont comparée à Heartbleed.
Ce billet explique ce que sont ces failles, sans entrer dans les détails d’exploitation, en partant de la façon dont PHP gère la mémoire d’une requête. Il décrit ensuite comment vérifier qu’un serveur est réellement protégé, ce qui demande un peu plus qu’un numéro de version, et ce que j’ai fait sur mes propres serveurs. Les faits viennent des avis de sécurité de PHP et de la page Wikipédia consacrée à l’affaire.
Les faits
CVE-2026-109618
CVE-2026-109619
intitulé de l’avis
« RCE via OOM corruption »
« RCE via stale global state »
nature
corruption mémoire lors d’un manque de mémoire pendant le nettoyage d’une requête
état interne propre à une requête qui survit après la libération de sa mémoire
CVSS 3.1
9,8 (sans authentification, sans interaction)
8,1 (complexité d’attaque élevée)
CVSS 4.0
9,2
9,2
versions touchées
PHP 5.4 à 8.5
PHP 5.4 à 8.5
corrigé dans
8.2.35, 8.3.36, 8.4.27, 8.5.12
idem
Date (2026)
Événement
août
découverte par deux équipes, avec l’aide d’outils d’analyse assistés par IA ; un des deux bugs avait été repéré dès 2024 lors d’un audit, sans correctif faute d’accord sur la solution
17 octobre
première tentative d’exploitation observée par un pot de miel, quatre jours avant la publication
20 octobre
annonce publique, fuites (vidéo de démonstration publiée par erreur), correctifs déjà visibles chez un distributeur
21 octobre, 13 h 34 UTC
avis et versions corrigées publiés ; exploitation observée le jour même
23 octobre
avis complémentaire CVE-2026-115560, lié à la progression des envois de fichiers en session
Deux éléments de ce calendrier méritent attention. La faille a été exploitée avant sa publication : quand le correctif sort, il n’y a plus de période de grâce. Et le premier bug avait été repéré deux ans plus tôt, sans être corrigé faute de démonstration d’exploitabilité : c’est l’arrivée d’outils capables de construire cette démonstration qui a tout changé. On peut s’attendre à voir ce scénario se répéter sur d’autres vieux bugs « théoriques ».
Comprendre : la vie d’une requête dans PHP
Pour saisir ce que veulent dire « corruption lors d’un manque de mémoire » et « état global périmé », il faut savoir comment PHP traite une requête, en particulier avec PHP-FPM, le mode le plus courant derrière nginx.
des processus réutilisés : PHP-FPM démarre un groupe de processus (workers) qui traitent chacun une requête après l’autre, des centaines ou des milliers de fois avant d’être recyclés (pm.max_requests). Ce n’est pas un processus neuf par requête ;
un cycle en quatre temps : au démarrage du processus, PHP initialise ses extensions une fois pour toutes (phase MINIT). Puis, pour chaque requête : initialisation propre à la requête (RINIT), exécution du script, puis nettoyage (RSHUTDOWN) qui doit remettre le processus dans un état propre pour la requête suivante ;
une mémoire par requête : le gestionnaire de mémoire de PHP (le Zend Memory Manager) alloue la mémoire du script dans une zone propre à la requête, libérée d’un bloc à la fin. C’est rapide et cela évite la plupart des fuites, mais cela suppose que rien ne garde, au-delà de la requête, un pointeur vers cette zone ;
une limite de mémoire : memory_limit borne la mémoire d’une requête. Quand un script la dépasse, PHP interrompt l’exécution… au beau milieu de ce qu’il faisait, y compris pendant le nettoyage.
Les deux bugs, à ce niveau d’explication
Le premier bug concerne justement ce cas : un dépassement de mémoire survenant pendant le nettoyage d’une requête laisse certaines structures internes dans un état incohérent, à moitié libérées. Un attaquant qui sait provoquer ce dépassement au bon moment, avec une requête construite pour cela, obtient une corruption de la mémoire du processus.
Le second bug relève de la même famille : un élément d’état interne appartenant à une requête n’est pas réinitialisé et survit à la libération de sa mémoire. La requête suivante, traitée par le même processus, trouve une référence vers une zone libérée puis réutilisée, éventuellement remplie par l’attaquant. C’est une utilisation après libération (use-after-free), classique dans les programmes écrits en C.
Dans les deux cas, une corruption de mémoire maîtrisée permet de détourner le flot d’exécution du processus PHP, donc d’exécuter du code avec ses droits : ceux de l’utilisateur du serveur web, qui peut lire les fichiers des sites, leurs identifiants de base de données, et souvent écrire dans leurs répertoires. Le nom « PHP2Shell » résume le résultat.
Pourquoi tous les modes sont touchés
Les deux bugs sont dans le cœur de PHP, pas dans un module particulier. PHP-FPM, Apache avec mod_php, php-cgi, FrankenPHP, LiteSpeed : tout ce qui sert des requêtes web est concerné. La ligne de commande l’est moins, puisqu’aucun attaquant distant n’y envoie de requêtes. Et le fait que les processus FPM soient réutilisés d’une requête à l’autre est précisément ce qui rend exploitable un état qui « survit » à une requête.
Pourquoi un pare-feu applicatif ne suffit pas
Les fournisseurs de pare-feu applicatif ont publié des règles temporaires dès les premiers jours. Elles ont leur utilité contre les outils d’exploitation publiés, mais une faille de corruption mémoire déclenchée par la forme d’une requête se prête mal au filtrage : il suffit souvent de varier la charge utile. Les mainteneurs de PHP l’ont dit clairement : aucune mesure de contournement n’est complète, seule la mise à jour protège. L’agence de sécurité américaine (NSA) est allée jusqu’à recommander de mettre hors ligne les sites qui ne pouvaient pas être mis à jour immédiatement.
Vérifier un serveur : la version ne suffit pas
php -v # le binaire en ligne de commande
rpm -qa 'php*-fpm' 'php*-common' --qf '%{NAME} %{VERSION} installé %{INSTALLTIME:date}\n'
systemctl show -p ActiveEnterTimestamp php-fpm # PHP-FPM a-t-il redémarré APRÈS la mise à jour ?
needs-restarting -s | grep -i php # un processus utilise-t-il encore d'anciennes bibliothèques ?
ls /opt/remi/*/root/usr/sbin/php-fpm 2>/dev/null # versions parallèles (collections logicielles)
la version installée doit être au moins 8.2.35, 8.3.36, 8.4.27 ou 8.5.12. Pour les versions plus anciennes, plus maintenues par le projet, seuls des correctifs en source existent ; les distributions et certains fournisseurs (cPanel, CloudLinux) en ont fait des paquets ;
le processus en cours doit avoir redémarré après la mise à jour. Un PHP-FPM lancé avant l’installation continue d’exécuter l’ancien code en mémoire, quel que soit le paquet sur le disque. needs-restarting -s liste les services concernés ;
toutes les copies de PHP : un serveur peut en avoir plusieurs (le paquet principal, des collections parallèles dans /opt/remi, des conteneurs qui embarquent leur propre PHP). Chacune doit être vérifiée ;
les numéros rétroportés : sur une distribution qui corrige sans changer de version (RHEL et dérivées pour leur PHP d’origine), c’est le journal des modifications du paquet qu’il faut lire (rpm -q --changelog), pas le numéro de version.
Ce que j’ai trouvé sur mes serveurs
# main
php-fpm 8.5.12 installé le 21 oct. 2026 à 21:10 (avis publié à 15:34, heure de Paris)
# homelab
php-fpm 8.4.27 installé le 21 oct. 2026 à 20:30
Mes deux serveurs qui font tourner PHP utilisent les paquets du dépôt Remi, qui suit de près les versions officielles : les versions corrigées étaient installées le soir même de la publication, dans les cinq à six heures. Le processus PHP-FPM de l’un d’eux avait redémarré depuis, et celui de l’autre aussi, mais needs-restarting le signalait encore pour une bibliothèque mise à jour ensuite : un redémarrage de plus a réglé ce point. C’est la vérification qu’on oublie le plus souvent.
La faille complémentaire : la progression des envois de fichiers
; 99-local.ini : désactiver la progression des envois en session (CVE-2026-115560)
session.upload_progress.enabled = Off
Le 23 octobre, l’équipe de PHP a publié un avis sur une troisième faille, CVE-2026-115560, distincte des deux premières : une exécution de code qui dépend de la configuration, liée à la fonction de suivi de la progression des envois de fichiers en session. Quand elle est active (c’est la valeur par défaut), PHP écrit dans la session, pendant un envoi, des informations dont une partie vient de la requête. Si aucune application ne s’en sert, la désactiver ne coûte rien. J’ai vérifié qu’aucun code PHP de mes sites n’utilisait cette fonction (recherche de PHP_SESSION_UPLOAD_PROGRESS et upload_progress), puis je l’ai désactivée par la configuration commune déployée par Ansible.
Détecter une tentative
journalctl -u php-fpm --since "-7 days" | grep -E "exited on signal (11|6)|SIGSEGV|SIGABRT"
# WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) after 3.2 seconds from start
Une tentative d’exploitation ratée d’une corruption mémoire se traduit souvent par le plantage du processus visé. Des processus PHP-FPM qui meurent sur un signal 11 (erreur de segmentation) ou 6 (abandon), surtout en série depuis une même adresse dans les journaux du serveur web, sont un signal à surveiller. Une règle d’alerte sur ces messages, dans un outil comme Wazuh, coûte peu.
Les leçons
la vitesse de mise à jour compte plus que jamais : exploitation avant publication, outils d’exploitation dans les jours suivants. Un dépôt de paquets qui suit de près les versions amont, et des mises à jour automatiques de sécurité, font la différence ;
une mise à jour n’est appliquée qu’au redémarrage du processus qui utilise le code. Vérifiez-le, ne le supposez pas ;
les versions en fin de vie (PHP 8.1 et antérieures) n’ont reçu que des correctifs en source. Un serveur sur une version ancienne, c’est une compilation à faire soi-même au pire moment ;
réduire ce qui est exposé : une fonction inutilisée (la progression des envois) désactivée, c’est une faille de moins le jour où elle est découverte ;
et une évolution de fond : les outils d’analyse assistés par IA découvrent et démontrent des failles anciennes plus vite qu’avant. Les bugs « jamais exploités » ne le resteront peut-être pas.
Sources : avis de sécurité du projet PHP (GHSA-w8hw-w9fx-j79w, GHSA-6wmf-4rv5-p3rp) ; page Wikipédia « PHP2Shell » ; Help Net Security et Recorded Future, octobre 2026.
Ce billet fait partie d’une série de tutoriels d’administration système (tag « pas à pas »). Série « Pas à pas », tutoriel 1/77. · Tutoriel suivant → · English version
Tous mes playbooks Ansible lisent leurs secrets (mots de passe de bases, clés d’API, jetons) dans OpenBao, le fork libre de HashiCorp Vault. Les secrets ne sont donc écrits ni dans le dépôt Git, ni dans des fichiers chiffrés à faire circuler : Ansible les demande au moment du déploiement, avec une identité AppRole qui n’a que le droit de lecture.
Scellé, descellé : de quoi parle-t-on
OpenBao chiffre tout ce qu’il stocke avec une clé maîtresse. Au démarrage, il ne connaît pas cette clé : il est scellé. Il tourne et répond aux requêtes, mais refuse de lire ou d’écrire le moindre secret. Pour le desceller, il faut reconstituer la clé. Il existe deux grandes approches :
Shamir : à l’initialisation, la clé est découpée en n parts, dont t suffisent à la reconstituer. Au démarrage, il faut saisir ces parts à la main (bao operator unseal) ;
auto-unseal : la clé est confiée à un service externe (un KMS cloud, un HSM, un autre Vault). OpenBao la récupère tout seul au démarrage.
Ici, les cinq parts sont nécessaires (t = n = 5), et aucune n’est stockée sur le serveur. Elles sont conservées dans un endroit séparé, avec des copies sur deux autres supports physiques. Ce choix a des raisons précises :
un vol du disque ou d’une sauvegarde ne donne rien : sans les parts, les données d’OpenBao ne sont qu’un bloc chiffré ;
pas de dépendance à un fournisseur cloud : un auto-unseal par KMS ferait dépendre tout le parc d’un compte cloud et de sa disponibilité ;
un seul serveur : l’auto-unseal se justifie surtout pour des grappes qui redémarrent souvent, ce qui n’est pas mon cas.
Le prix à payer
Après n’importe quel redémarrage (mise à jour du noyau, coupure de courant, plantage), OpenBao repart scellé. Et comme tous les playbooks lisent leurs secrets dedans, aucun déploiement n’est possible sur aucune machine tant que quelqu’un n’a pas saisi les parts. Le symptôme côté Ansible est explicite :
fatal: … Vault is sealed
C’est un compromis assumé : un peu d’intervention humaine après un redémarrage, en échange d’un coffre qui ne s’ouvre jamais tout seul. Il faut juste ne pas l’oublier, et c’est l’objet du billet suivant.
Ce billet fait partie d’une série de notes d’administration système (tag « carnet d’admin »). Série « OpenBao scellé », partie 1/2. · Billet suivant → · English version