Quelques pensées

Terres dévastées

Vallée de montagne enneigée entre deux sommets rocheux, sous un ciel bleu
Schéma du piège du postinst de CrowdSec en cinq étapes, de la correction par acquis.yaml et des sept vérifications finales

CrowdSec 1.8 sur Debian : réinstaller depuis le dépôt officiel, piège du postinst et vérifications

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. Cet article raconte le passage de CrowdSec sur Debian à la 1.8 : pourquoi réinstaller, le piège du postinst, puis la checklist pour vérifier que la machine est réellement protégée.

Schéma du piège du postinst de CrowdSec en cinq étapes, de la correction par acquis.yaml et des sept vérifications finales
Le piège, la correction et les sept points à vérifier avant de dire qu’il protège.

En un coup d’œil

ÉtapeCe qu’il faut retenir
PourquoiLa 1.4.6 de Debian a des années de retard, et un parc homogène simplifie le rôle Ansible
MéthodeDésinstaller puis réinstaller depuis le dépôt officiel : les deux paquets de bouncer entrent en conflit
Sauvegarde/etc/crowdsec et la base SQLite, avec .backup et integrity_check
PiègeSur une installation neuve, désactiver l’assistant laisse CrowdSec sans source de journaux et il refuse de démarrer
CorrectionDéposer l’acquis.yaml avant le premier apt-get install
VérificationSept points, de la version jusqu’à la mémoire du Pi

Pourquoi quitter la version de CrowdSec sur 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.

Réinstaller plutôt que mettre à jour

On ne peut pas simplement ajouter le dépôt officiel et lancer apt upgrade pour mettre à jour CrowdSec sur Debian. 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 la section ci-dessous… avec un piège inattendu.

Le piège du postinst

Le paquet crowdsec du dépôt officiel lance, à l’installation, un assistant : cscli setup unattended. Il détecte les services présents (sshd, nginx, postfix…) et crée tout seul les sources de journaux dans /etc/crowdsec/acquis.d/setup.*.yaml, puis installe les collections correspondantes. C’est pratique sur une machine gérée à la main, mais gênant quand Ansible gère déjà tout ça : on se retrouve avec deux sources pour les mêmes journaux, donc des événements comptés deux fois.

La variable prévue pour ça… et son effet de bord

CROWDSEC_SETUP_UNATTENDED_DISABLE=true apt-get install -y crowdsec

Cette variable désactive l’assistant. Sur une machine où CrowdSec a déjà une configuration, c’est parfait. Mais sur une installation neuve, sans assistant, CrowdSec n’a aucune source de journaux. Or il refuse de démarrer sans au moins une source :

FATAL crowdsec init: while loading acquisition config: no datasource enabled

La suite est une réaction en chaîne :

  • le postinst du paquet essaie de démarrer le service, qui échoue, donc le postinst sort en erreur ;
  • dpkg laisse le paquet « à moitié configuré » (iF dans dpkg -l) ;
  • à l’apt-get install suivant (le bouncer, par exemple), dpkg essaie de finir la configuration en attente et relance le postinst de crowdsec. Cette fois, la variable n’est plus dans l’environnement, et l’assistant s’exécute.

Résultat : exactement ce qu’on voulait éviter. Des fichiers setup.*.yaml sont créés, des collections inutiles installées (auditd, une liste blanche « bons robots »…), et en prime le bouncer échoue à son installation, puisque l’API locale de CrowdSec n’était pas encore démarrée au moment où il s’est configuré.

La correction

Il suffit que CrowdSec trouve une source de journaux dès son premier démarrage. Le rôle Ansible dépose donc sa propre configuration avant d’installer le paquet, uniquement si CrowdSec n’est pas encore présent :

- name: Check whether CrowdSec is already installed
  ansible.builtin.stat:
    path: /usr/bin/crowdsec
  register: _crowdsec_binary

- name: Seed acquisition config before the first install
  when: not _crowdsec_binary.stat.exists
  ansible.builtin.template:
    src: acquis.yaml.j2              # auth.log + syslog sur Debian
    dest: /etc/crowdsec/acquis.yaml

Avec un acquis.yaml déjà en place, le premier démarrage réussit, le postinst se termine normalement, et l’assistant reste désactivé.

Nettoyer si c’est déjà arrivé

dpkg --configure -a                                  # finir les configurations en attente
mkdir -p /root/backup/setup-leftovers
mv /etc/crowdsec/acquis.d/setup.*.yaml /root/backup/setup-leftovers/
cscli collections remove crowdsecurity/auditd crowdsecurity/whitelist-good-actors
systemctl restart crowdsec crowdsec-firewall-bouncer

Un dernier piège, côté Ansible : si un run échoue en cours de route, les handlers déjà notifiés (« redémarrer crowdsec ») ne s’exécutent pas. Le run suivant voit des fichiers déjà à jour et ne notifie plus rien. Les services tournent alors avec l’ancienne configuration. Après une correction, redémarrez-les vous-même.

Vérifier que ça protège vraiment

Deux services « active » ne prouvent pas grand-chose. CrowdSec peut tourner sans lire le bon journal, et le bouncer peut tourner sans rien bloquer. Voici la checklist que j’applique après chaque installation ou mise à jour, avec ce que chaque vérification démontre.

1. Les versions et les services

cscli version | head -1                              # version: v1.8.1…
systemctl is-active crowdsec crowdsec-firewall-bouncer crowdsec-nft-resync.timer

2. Le pare-feu : un seul hook, au bon endroit

nft list ruleset | grep -E "^table|hook"
# table ip crowdsec
#     type filter hook input priority filter; policy accept;
# table ip6 crowdsec6
#     type filter hook input priority filter; policy accept;

Le bouncer doit filtrer le trafic entrant (hook input) en IPv4 et en IPv6. Une chaîne forward ou output en trop serait un reste d’ancienne configuration. Oublier l’IPv6, c’est laisser une porte grande ouverte.

3. Un seul bouncer enregistré, le bon

cscli bouncers list
# crowdsec-firewall-bouncer   127.0.0.1   ✔️   …

Le paquet du bouncer s’enregistre tout seul sous un nom aléatoire (cs-firewall-bouncer-<horodatage>), et l’installation Ansible ajoute le sien. Il ne doit en rester qu’un, sinon on ne sait plus lequel est vraiment utilisé.

4. Les journaux sont lus et compris

cscli metrics show acquisition       # auth.log et syslog avec des lignes lues
cscli explain --type syslog \
  --log "Oct 23 10:00:00 pi sshd[1]: Invalid user admin from 203.0.113.9 port 4242"

cscli explain fait passer une ligne de journal dans toute la chaîne d’analyse, et montre quels analyseurs la reconnaissent et quels scénarios elle alimente. Si aucun scénario ssh n’apparaît, la détection de force brute SSH ne fonctionnera jamais, quel que soit l’état des services.

5. La liste communautaire

cscli capi status                    # « You can successfully interact with Central API »
cscli decisions list -a -o raw | tail -n +2 | wc -l

Juste après une installation neuve, la liste est vide : une machine nouvellement enregistrée met jusqu’à deux heures à recevoir sa première liste de blocage communautaire. Le lendemain, le Pi bloquait plus de 15 000 adresses, contre 3 600 avec l’ancienne version.

6. Les ensembles nftables correspondent aux décisions

Le bouncer de pare-feu a longtemps eu un bug qui pouvait laisser les ensembles nftables se désynchroniser des décisions. Un petit timer de resynchronisation compare les deux et rajoute ce qui manque. Son journal doit indiquer still_missing=0 :

journalctl -u crowdsec-nft-resync --since -1h | grep still_missing | tail -2
# ipv4: active=15301 set=15301 … still_missing=0

7. La mémoire, sur un petit Pi

Sur un Raspberry Pi qui fait aussi DNS, je vérifie que CrowdSec n’affame pas le reste : free -m, la mémoire résidente de chaque processus et l’usage du swap. Après une journée, CrowdSec occupait environ 100 Mo, et il restait 350 Mo de libres. Si ça devenait serré, désactiver le support grok de RE2 dans feature.yaml réduit nettement l’empreinte.

Une fois ces sept points au vert, on peut dire que CrowdSec sur Debian protège réellement la machine, et pas seulement que des services tournent.

Ce que j’en retiens

  • Sauvegardez avant de purger : le purge supprime /var/lib/crowdsec, et la copie SQLite doit passer integrity_check ;
  • Fournissez une source de journaux dès l’installation si vous désactivez l’assistant, sinon CrowdSec ne démarre pas et dpkg relance le postinst sans la variable ;
  • Après un run Ansible en échec, redémarrez vous-même les services : les handlers notifiés n’ont pas tourné ;
  • Deux services « active » ne prouvent rien : contrôlez le hook nftables (IPv4 et IPv6), le bouncer unique, cscli explain, la liste communautaire et still_missing=0 ;
  • Sur un petit Pi, surveillez la mémoire de CrowdSec sur Debian après une journée de fonctionnement.

Articles liés

Schéma comparant Brotli, PHP2Shell et ZDI-CAN-33367 : mécanisme de chaque faille et présence ou non d'une corruption mémoire

Sous le capot des failles d’octobre 2026 : un octet de trop, un pointeur oublié, une page partagée

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

Schéma : six familles de réglages MariaDB (mémoire, durabilité, entrées/sorties, connexions, tables, binlogs) avec leur méthode de vérification

Pas à pas : les réglages MariaDB qui comptent vraiment, et comment les vérifier

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.

Schéma : six familles de réglages MariaDB (mémoire, durabilité, entrées/sorties, connexions, tables, binlogs) avec leur méthode de vérification
Les réglages MariaDB qui comptent vraiment, regroupés par famille, avec comment les vérifier.

Où vit la configuration de MariaDB, 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.

Le fichier, en entier

[mariadb]
# --- Mémoire -------------------------------------------------------------
innodb_buffer_pool_size        = 12G
innodb_log_file_size           = 2G
innodb_log_buffer_size         = 256M
tmp_table_size                 = 256M
max_heap_table_size            = 256M

# --- Durabilité ----------------------------------------------------------
innodb_flush_log_at_trx_commit = 1
sync_binlog                    = 1
innodb_flush_method            = O_DIRECT

# --- Entrées/sorties (disques rotatifs) ----------------------------------
innodb_io_capacity             = 200
innodb_io_capacity_max         = 400

# --- Connexions ----------------------------------------------------------
max_connections                = 400
thread_cache_size              = 64
wait_timeout                   = 28800

# --- Tables et fichiers --------------------------------------------------
table_open_cache               = 4096
table_open_cache_instances     = 8
table_definition_cache         = 4096
open_files_limit               = 68000

# --- Binlogs, réplication ------------------------------------------------
server_id                      = 10
log_bin                        = /data/mysql/binlogs/logbin
binlog_format                  = ROW
binlog_expire_logs_seconds     = 432000
max_binlog_size                = 512M
log_slave_updates              = ON

# --- Hygiène -------------------------------------------------------------
character_set_server           = utf8mb4
collation_server               = utf8mb4_uca1400_ai_ci
transaction_isolation          = READ-COMMITTED
skip_name_resolve              = ON
local_infile                   = OFF
max_allowed_packet             = 256M
slow_query_log                 = ON
long_query_time                = 5
log_slow_verbosity             = query_plan,explain

La mémoire

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 :

# /etc/systemd/system/mariadb.service.d/limits.conf
[Service]
LimitNOFILE=68000
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.

Vérifications de MariaDB après un changement

systemctl restart mariadb && systemctl is-active mariadb
journalctl -u mariadb -n 30 --no-pager | grep -iE 'warn|error'
mariadb -e "SHOW GLOBAL VARIABLES" > /tmp/vars.after
diff <(sort /tmp/vars.before) <(sort /tmp/vars.after)

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.

Une vraie vérification, 75 minutes après un redémarrage

Pour montrer ce que donnent ces vérifications sur un vrai serveur, voici le MariaDB 12.3 de mon serveur web le 2 novembre 2026, lu avec le compte de supervision (qui n’a besoin que de lire variables et compteurs). La machine avait redémarré 75 minutes plus tôt, ce qui rend les chiffres plus instructifs, pas moins.

SELECT @@innodb_buffer_pool_size/1024/1024/1024 AS buffer_pool_gb,
       @@innodb_log_file_size/1024/1024        AS redo_mb,
       @@max_connections, @@innodb_flush_log_at_trx_commit, @@sync_binlog;
+----------------+---------+-------------------+---------------------------------+---------------+
| buffer_pool_gb | redo_mb | @@max_connections | @@innodb_flush_log_at_trx_commit| @@sync_binlog |
+----------------+---------+-------------------+---------------------------------+---------------+
|  12.31         | 2048    |               400 |                               1 |             1 |
+----------------+---------+-------------------+---------------------------------+---------------+

SHOW GLOBAL STATUS WHERE Variable_name IN (...);
| Uptime                           | 4487     |
| Innodb_buffer_pool_read_requests | 5163358  |
| Innodb_buffer_pool_reads         | 121600   |
| Innodb_log_waits                 | 0        |
| Max_used_connections             | 61       |
| Threads_created                  | 61       |
| Connections                      | 11846    |
| Created_tmp_tables               | 37637    |
| Created_tmp_disk_tables          | 5223     |

Ma lecture, ligne par ligne :

  • Taux de réussite du buffer pool : 1 − 121 600 / 5 163 358 = 97,6 %. Sur un serveur démarré depuis des semaines, j’attends plus de 99,9 %. Ici, le cache se remplit encore : chaque page lue sur disque depuis le redémarrage compte comme un échec. C’est pour cela que le taux se compare sur plusieurs jours, jamais juste après un redémarrage, et que la première heure qui suit est plus lente.
  • Innodb_log_waits = 0 : aucune transaction n’a dû attendre de la place dans le tampon du journal. Avec un journal de reprise de 2 Go, même l’activité du démarrage ne l’a pas saturé.
  • Max_used_connections = 61 sur 400 autorisées, et Threads_created = 61 pour 11 846 connexions : le cache de threads réutilise les threads au lieu d’en créer un par connexion. Si Threads_created grimpait presque aussi vite que Connections, thread_cache_size serait trop petit.
  • Tables temporaires sur disque : 5 223 sur 37 637, soit 14 %. Ce sont surtout des requêtes WordPress qui trient des colonnes de texte, lesquelles partent sur disque quelle que soit la valeur de tmp_table_size. Au-delà de 25 %, il vaut la peine de chercher les requêtes responsables avant de relever une limite.
  • innodb_flush_log_at_trx_commit = 1 et sync_binlog = 1 : durabilité complète sur le primaire. Le réplica tourne avec 2 et 0 ; les raisons sont dans la série consacrée à MariaDB.

Temps total : deux requêtes, une minute. Lancez-les avant et après chaque changement, et gardez les sorties : c’est ce qui transforme « j’ai l’impression que c’est plus rapide » en mesure.


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

Articles liés

Schéma : serveur VPS, tunnel WireGuard 10.99.0.0/24 et réseau de la maison, rôle d'AllowedIPs et les trois étapes d'installation

Pas à pas : installer et configurer WireGuard entre un serveur et la maison

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.

Schéma : serveur VPS, tunnel WireGuard 10.99.0.0/24 et réseau de la maison, rôle d'AllowedIPs et les trois étapes d'installation
Le tunnel WireGuard entre le serveur et la maison, et les trois étapes sur le serveur.

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émentValeurRôle
Réseau du tunnel10.99.0.0/24adresses internes au tunnel, une par extrémité
Routeur (tunnel)10.99.0.2côté maison, derrière la box
Serveur (tunnel)10.99.0.3le VPS
Réseau de la maison192.168.10.0/24ce que le serveur doit pouvoir joindre
Serveur (public)203.0.113.25adresse 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 :

/interface wireguard add name=wg-servers listen-port=13231 private-key="<clé privée du routeur>"
/ip address add address=10.99.0.2/24 interface=wg-servers
/interface wireguard peers add interface=wg-servers \
    public-key="<clé publique du serveur>" preshared-key="<clé partagée>" \
    endpoint-address=203.0.113.25 endpoint-port=51820 \
    allowed-address=10.99.0.3/32 persistent-keepalive=25s

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.

À quoi ça ressemble sur mon serveur

Voici la même vérification sur mon serveur web, relevée le 2 novembre 2026, une heure environ après un redémarrage. Les adresses sont réécrites selon le plan de ce tutoriel ; tout le reste est tel qu’affiché.

# wg show wg0
interface: wg0
  public key: (masquée)
  private key: (masquée)
  listening port: 51820

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: 1 minute, 9 seconds ago
  transfer: 4.01 MiB received, 74.50 MiB sent
  persistent keepalive: every 25 seconds

# ip route get 192.168.10.20
192.168.10.20 dev wg0 src 10.99.0.3 uid 0

# ping -c3 -q 10.99.0.2
3 packets transmitted, 3 received, 0% packet loss, time 2002ms
rtt min/avg/max/mdev = 8.060/8.424/8.976/0.396 ms
  • latest handshake: 1 minute, 9 seconds ago : WireGuard renouvelle la clé de session toutes les deux minutes tant que du trafic circule ; tout ce qui reste sous deux minutes et demie environ est normal. Une valeur en heures, ou l’absence de la ligne, signifie que les deux côtés ne s’entendent plus sur les clés ou que le port UDP est bloqué.
  • transfer: 4.01 MiB received, 74.50 MiB sent : l’asymétrie est attendue ici, le serveur pousse sauvegardes et métriques vers la maison. Ce qui compte, c’est que les deux compteurs augmentent.
  • persistent keepalive: every 25 seconds : le réglage de la section [Peer], bien appliqué. Sans lui, la traduction d’adresses du routeur oublie le flux après quelques minutes de silence.
  • 8,4 ms aller-retour entre un centre de données et une fibre domestique : le tunnel lui-même ajoute bien moins d’une milliseconde, le reste, c’est la distance.

Deux autres tests méritent une place dans le carnet. Le premier mesure le plus gros paquet que le tunnel accepte sans fragmentation, ce qui permet de confirmer la MTU de 1420 :

# ping -c2 -M do -s 1392 192.168.10.20      # 1392 + 28 octets d'en-tête = 1420
2 packets transmitted, 2 received, 0% packet loss, time 1001ms

# ping -c1 -M do -s 1393 192.168.10.20      # un octet de plus
ping: sendmsg: Message too long

-M do interdit la fragmentation, -s fixe la taille des données, et les en-têtes ICMP et IP ajoutent 28 octets. 1420 passe, 1421 est refusé localement avec Message too long : la MTU de l’interface est exactement celle demandée par la configuration. Si les gros transferts se figent alors que les petits échanges passent, c’est le premier test à faire, depuis les deux bouts.

Le second est une vraie connexion TCP, parce qu’un ping ne prouve rien sur le pare-feu : une session SSH depuis le serveur vers une machine de la maison, à travers le tunnel, s’est ouverte en 0,27 s, bannière comprise.

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.


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

Articles liés

Schéma : famille de failles Dirty COW, Dirty Pipe, Dirty Frag et ZDI-CAN-33367, et la chaîne en quatre étapes du marqueur perdu

ZDI-CAN-33367 : quand une copie évitée écrit dans un fichier en lecture seule

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 faille du noyau Linux, 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.

Schéma : famille de failles Dirty COW, Dirty Pipe, Dirty Frag et ZDI-CAN-33367, et la chaîne en quatre étapes du marqueur perdu
La famille de failles du cache de pages et la chaîne du marqueur perdu.

Une famille de failles du noyau

FailleAnnéeLe principe commun
Dirty COW (CVE-2016-5195)2016une course dans la copie sur écriture permet d’écrire dans une page projetée en lecture seule
Dirty Pipe (CVE-2022-0847)2022un drapeau mal réinitialisé dans un tube permet d’écrire dans les pages du cache d’un fichier
Dirty Frag (CVE-2026-43284)2026le déchiffrement ESP « sur place » écrit dans des fragments réseau qui pointent vers le cache de fichiers
ZDI-CAN-33367octobre 2026le 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 de la faille du noyau, é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.

Pourquoi cette faille du noyau permet à un utilisateur ordinaire de tout faire

sudo -u nobody unshare -Urn true && echo "espace de noms utilisateur : autorisé"
espace de noms utilisateur : autorisé
sysctl user.max_user_namespaces
user.max_user_namespaces = 125388

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.

Couper la chaîne de la faille du noyau au bon maillon

# /etc/modprobe.d/zdi-can-33367.conf (déployé par Ansible)
install esp4 /bin/false
blacklist esp4
install esp6 /bin/false
blacklist esp6
install nlmon /bin/false
blacklist nlmon
install act_pedit /bin/false
blacklist act_pedit

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.

Où en est la faille du noyau le 2 novembre 2026

# uname -r
6.12.0-211.69.1.el10_2.x86_64

# dnf check-update kernel
(rien : aucun noyau plus récent publié pour l'instant)

# modprobe -n -v esp4 ; modprobe -n -v esp6 ; modprobe -n -v nlmon ; modprobe -n -v act_pedit
install /bin/false
install /bin/false
install /bin/false
install /bin/false

# lsmod | grep -cE '^(esp4|esp6|nlmon|act_pedit) '
0

modprobe -n -v montre ce que ferait un chargement sans le faire : install /bin/false signifie que toute tentative, par un utilisateur ou par le chargement automatique du noyau, exécute /bin/false à la place et échoue. lsmod confirme qu’aucun des quatre n’est chargé, y compris d’avant la mise en place du blocage (un redémarrage a réglé la question). La configuration de blocage est en place sur les six machines vérifiées avec Ansible. Toujours pas de noyau corrigé : le blocage reste, et le rappel de le retirer reste dans le fichier de configuration lui-même.

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.


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

Articles liés

Schéma Node2Shell : un exécutable Node est un assemblage, trois chemins vers le Brotli hostile, Etherpad exposé et Uptime Kuma non exposé

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

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

Schéma Node2Shell : un exécutable Node est un assemblage, trois chemins vers le Brotli hostile, Etherpad exposé et Uptime Kuma non exposé
Un exécutable Node, les trois chemins du Brotli hostile et deux applications comparées.

Les dix failles, dont Node2Shell

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

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

Ce qu’est un exécutable Node

node -p "process.versions" | grep -E "node|v8|uv|brotli|zlib|openssl|ares|undici|llhttp"
  node: '26.13.0',
  v8: '14.6.202.34-node.40',
  uv: '1.52.1',
  brotli: '1.2.0',
  zlib: '1.3.2.1-motley-456ae73',
  openssl: '3.5.9',
  ares: '1.34.9',
  undici: '8.12.2',
  llhttp: '9.4.3',

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

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

Node2Shell : par où arrivent les données

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

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

Vérifier dans le code, pas dans la documentation

# body-parser 2.3.0 (Express 5), lib/read.js
    case 'deflate': …
    case 'gzip':    …
    case 'br':
      return zlib.createBrotliDecompress()
# body-parser 1.20.6 (Express 4) : seulement deflate, gzip, identity

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

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

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

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

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

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

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

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

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

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

Deux failles plus discrètes

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

Retrouver tous ses Node

# 1. tous les exécutables Node sur le disque, avec leur version et leur Brotli
find / -xdev \( -path /proc -o -path /sys \) -prune -o -type f -name node -perm -u+x -print 2>/dev/null \
  | while read n; do echo "$n $($n --version) brotli=$($n -p process.versions.brotli)"; done
# 2. ceux qui tournent, et depuis quand
ps -eo pid,lstart,args | grep -E '[n]ode( |$)|/node '
# 3. les conteneurs : chaque image a son propre Node
podman ps --format '{{.Names}} {{.Image}}'
/usr/bin/node                                v26.13.0  brotli=1.2.0   # NodeSource, corrigé
/opt/etherpad/nodejs/bin/node                v24.22.1  brotli=1.2.0   # embarqué par Etherpad, corrigé le 22/10
/usr/share/wazuh-dashboard/node/bin/node     v18.19.0  brotli=1.0.9   # Node 18 en fin de vie : jamais corrigé

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

Le Node que personne ne corrigera

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

Ce que j’en retiens de Node2Shell

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

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


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

Articles liés

Schéma CVE-2026-111497 : Brotli, libbrotli partagée du système contre copies embarquées dans Node.js, Etherpad et Wazuh, et comment chacune se corrige

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

Brotli est un algorithme de compression conçu par Google, utilisé partout sur le web : la plupart des navigateurs l’acceptent, la plupart des serveurs web et réseaux de diffusion l’utilisent pour compresser les pages. Une faille Brotli 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.

Schéma CVE-2026-111497 : Brotli, libbrotli partagée du système contre copies embarquées dans Node.js, Etherpad et Wazuh, et comment chacune se corrige
Bibliothèque partagée ou copie embarquée : deux façons de corriger la même faille.

Ce qu’est la faille Brotli CVE-2026-111497

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

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

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

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

La bibliothèque partagée du système

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

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

Les copies embarquées

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

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

Ce que j’ai fait, dans l’ordre

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

La mesure d’attente

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

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

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

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

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

Redémarrer ce qui utilise l’ancienne copie

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

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

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

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

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

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

En résumé sur la faille Brotli

  • 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.

Mise à jour du 2 novembre 2026 : la faille Brotli sur tout le parc, revérifiée

Le 2 novembre, le même inventaire, lancé par une seule commande Ansible ad hoc sur toutes les machines :

machine   libbrotli (système)      copies embarquées
main      1.1.0-8.el10_2.1  corrigée  node v26.13.0 (brotli 1.2.0), node d'Etherpad v24.22.1
homelab   1.1.0-8.el10_2.1  corrigée  node v26.13.0
mail      1.1.0-8.el10_2.1  corrigée  -
mgmt      1.0.9-10.el9_8.1            -      (branche EL9, ligne 1.0.9)
ldap      1.0.9-10.el9_8.1            -      (EL9 sur un Raspberry Pi)
pihole    1.0.9-2+b6                  -      (Debian)

# rpm -q --changelog libbrotli | head -3
* 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

Les serveurs EL10 ont tous la version corrigée, et le journal des modifications cite la CVE : c’est la preuve qui compte (le numéro de version seul ne le dirait pas). Les trois autres machines utilisent des branches plus anciennes de la bibliothèque : elles sont sur la liste à vérifier selon les avis de leur propre distribution, plutôt que supposées sûres ou vulnérables. Le seul Node qui ne peut toujours pas être corrigé est celui du tableau de bord de Wazuh, qui embarque Node 18.19.0 : ce cas est traité dans le billet consacré à Node.js.


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

Articles liés

Schéma du cycle d'une requête PHP-FPM (MINIT à requête suivante) et des deux failles PHP2Shell, versions touchées et corrigées

PHP2Shell : comprendre les failles PHP d’octobre 2026 et vérifier ses serveurs

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.

Schéma du cycle d'une requête PHP-FPM (MINIT à requête suivante) et des deux failles PHP2Shell, versions touchées et corrigées
Les deux failles PHP2Shell dans le cycle de vie d’une requête PHP-FPM.

Les faits sur PHP2Shell

CVE-2026-109618CVE-2026-109619
intitulé de l’avis« RCE via OOM corruption »« RCE via stale global state »
naturecorruption 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.19,8 (sans authentification, sans interaction)8,1 (complexité d’attaque élevée)
CVSS 4.09,29,2
versions touchéesPHP 5.4 à 8.5PHP 5.4 à 8.5
corrigé dans8.2.35, 8.3.36, 8.4.27, 8.5.12idem
Date (2026)Événement
aoûtdé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 octobrepremière tentative d’exploitation observée par un pot de miel, quatre jours avant la publication
20 octobreannonce publique, fuites (vidéo de démonstration publiée par erreur), correctifs déjà visibles chez un distributeur
21 octobre, 13 h 34 UTCavis et versions corrigées publiés ; exploitation observée le jour même
23 octobreavis 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 PHP2Shell

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

Articles liés

Schéma : redémarrage, OpenBao scellé sans auto-unseal, déploiements bloqués, raisons du choix de Shamir et checklist en cinq étapes

OpenBao sans auto-unseal : pourquoi Shamir, et la checklist après chaque redémarrage

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. Cet article explique pourquoi j’ai choisi de ne pas utiliser l’auto-unseal, ce que ça coûte, et la checklist que je suis après chaque redémarrage.

Schéma : redémarrage, OpenBao scellé sans auto-unseal, déploiements bloqués, raisons du choix de Shamir et checklist en cinq étapes
Pourquoi Shamir sans auto-unseal, et la checklist en cinq étapes après chaque redémarrage.

En un coup d’œil

SujetMon choix
SecretsOpenBao, lu par Ansible via une identité AppRole en lecture seule
DescellementShamir, 5 parts nécessaires sur 5 (t = n = 5), aucune stockée sur le serveur
Auto-unsealÉcarté : pas de dépendance à un fournisseur cloud, un seul serveur
Prix à payerAprès chaque redémarrage, le coffre est scellé et aucun déploiement n’est possible
ProcédureCinq étapes, environ cinq minutes
Prochaine améliorationUne alerte Prometheus sur l’état de scellement

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.

Mon choix : Shamir, sans auto-unseal

bao operator init -key-shares=5 -key-threshold=5
# Unseal Key 1: …   (×5)
# Initial Root Token: …

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 :

Scellement d'OpenBao : cinq parts Shamir reconstruisent la clé maîtresse en mémoire, qui déchiffre les secrets lus par Ansible
Cinq parts, toutes nécessaires : la clé maîtresse n’existe qu’en mémoire, chaque redémarrage laisse OpenBao scellé.
  • 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 de la checklist ci-dessous.

La checklist après redémarrage

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 OpenResty 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 OpenResty)

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 que j’en retiens

  • Un coffre qui ne s’ouvre pas tout seul est un choix de sécurité, avec un coût d’exploitation que l’on accepte en connaissance de cause ;
  • Ne passez jamais une part de clé en argument ni dans un outil qui garde un historique ;
  • Vérifiez la chaîne complète après le descellement, depuis le nœud Ansible et avec la même identité AppRole que les playbooks ;
  • Surveillez l’état de scellement (/v1/sys/health) : le risque principal est l’oubli.

Articles liés

Schéma : structure logique et fichiers physiques d’Oracle

Dans les fichiers d’une base de données (3/3) : Oracle, et les trois côte à côte

Dernier volet de la série, Oracle. Je ne fais pas tourner Oracle chez moi : ce billet s’appuie sur mon métier de DBA et sur la documentation d’Oracle, pas sur mes serveurs. Oracle est le plus ancien des trois moteurs, et le plus explicite sur sa structure : il distingue soigneusement la vue logique (tablespaces, segments, extents, blocs) des fichiers physiques, et il expose presque tout dans des vues du dictionnaire.

Structure logique et fichiers physiques

Structure logique et physique d'une base Oracle
Côté logique, la hiérarchie tablespace, segment, extent, bloc ; côté physique, les datafiles.

Une base Oracle se compose de plusieurs familles de fichiers :

  • les datafiles contiennent les données. Un tablespace regroupe un ou plusieurs datafiles, et un datafile n’appartient qu’à un seul tablespace ;
  • les control files sont la carte de la base : la liste des datafiles et des journaux, le SCN (le numéro de version global de la base) du dernier checkpoint, l’historique des sauvegardes RMAN. On les multiplexe sur plusieurs disques, car sans eux la base ne démarre pas ;
  • les online redo logs forment un journal circulaire, organisé en groupes de fichiers identiques (membres). Quand un groupe est plein, Oracle passe au suivant (log switch). En mode ARCHIVELOG, chaque groupe plein est copié en archived log avant d’être réutilisé : c’est ce qui permet une restauration à un instant précis ;
  • le tablespace d’undo garde les images d’avant des données modifiées ;
  • les tempfiles servent aux tris et aux jointures qui ne tiennent pas en mémoire ; ils ne sont pas journalisés ni sauvegardés ;
  • le spfile contient les paramètres de l’instance, et le fichier de mots de passe permet les connexions administrateur à distance.
SELECT tablespace_name, file_name, bytes/1024/1024 AS mb, autoextensible FROM dba_data_files;
SELECT group#, members, bytes/1024/1024 AS mb, status FROM v$log;
SELECT name FROM v$controlfile;

Côté logique, un segment correspond à un objet qui occupe de la place : une table, un index, une partition, un LOB. Un segment est composé d’extents, chacun formé de blocs contigus dans un seul datafile. Un segment peut donc s’étaler sur plusieurs fichiers, extent par extent. Enfin, le bloc est l’unité d’entrée-sortie, 8 Ko par défaut (DB_BLOCK_SIZE, fixé à la création de la base).

SELECT segment_name, segment_type, extents, blocks FROM dba_segments WHERE owner = 'SHOP';
SELECT extent_id, file_id, block_id, blocks FROM dba_extents
WHERE owner = 'SHOP' AND segment_name = 'ORDERS' ORDER BY extent_id;

Dans un datafile Oracle

Organisation d'un datafile et d'un segment ASSM
En-tête de fichier, bitmap des extents, blocs bitmap ASSM, en-tête de segment, blocs de données et High Water Mark.

Le début de chaque datafile contient son en-tête (identité du fichier, SCN du dernier checkpoint, état de sauvegarde), puis, dans un tablespace géré localement (le cas normal aujourd’hui), un bitmap qui indique quels extents sont occupés. Avant, cette information vivait dans des tables du dictionnaire, ce qui créait de la contention lors des allocations. Deux politiques d’allocation existent : UNIFORM (tous les extents ont la même taille) et AUTOALLOCATE, où Oracle choisit des extents de plus en plus grands à mesure que le segment grossit (64 Ko, puis 1 Mo, puis 8 Mo…).

Dans un segment en ASSM (Automatic Segment Space Management, le défaut), des blocs bitmap à trois niveaux décrivent le remplissage de chaque bloc de données, par tranches de 25 %. Une session qui insère cherche un bloc assez libre dans ces bitmaps, et des sessions différentes sont orientées vers des blocs différents, ce qui évite qu’elles se battent pour le même bloc. L’en-tête de segment garde la liste des extents et la High Water Mark (HWM), la limite des blocs déjà formatés. Un parcours complet de la table lit tous les blocs jusqu’à la HWM, même vides : après un gros DELETE, un ALTER TABLE orders SHRINK SPACE (ou MOVE) fait redescendre la HWM.

Anatomie d’un bloc Oracle

Structure d'un bloc de données Oracle de 8 Ko
En-tête cache, ITL, répertoires, espace libre, lignes et queue.
  • L’en-tête cache (20 octets) identifie le bloc : type, adresse (le RDBA, numéro de fichier et de bloc), SCN de la dernière modification, somme de contrôle. Avec DB_BLOCK_CHECKSUM=TYPICAL, la somme est vérifiée à chaque lecture depuis le disque ;
  • l’ITL (Interested Transaction List) est propre à Oracle : une entrée de 24 octets par transaction qui modifie le bloc, avec son identifiant (XID), l’adresse de son undo et son état. INITRANS fixe le nombre d’entrées réservées à la création du bloc. Si toutes sont prises et que le bloc n’a plus de place pour en ajouter, une nouvelle transaction attend : c’est l’attente enq: TX - allocate ITL entry, qu’on corrige en augmentant INITRANS sur les tables très concurrentes ;
  • le répertoire des lignes donne le décalage de chaque ligne dans le bloc, deux octets par ligne. Un ROWID désigne une entrée de ce répertoire, ce qui permet, comme chez PostgreSQL, de déplacer une ligne dans le bloc sans changer son adresse ;
  • PCTFREE (10 % par défaut) réserve de l’espace libre pour les UPDATE qui allongent les lignes. Sans cette réserve, une ligne qui grandit ne tient plus dans son bloc et doit migrer : elle part dans un autre bloc et laisse à sa place un pointeur. Chaque lecture par ROWID coûte alors deux blocs. Une ligne trop grande pour n’importe quel bloc est chaînée sur plusieurs blocs. La statistique table fetch continued row mesure les deux ;
  • la queue (4 octets) répète une partie du SCN, du type et de la séquence de l’en-tête : un bloc dont la tête et la queue ne correspondent pas a été écrit à moitié (fractured block). Oracle n’a pas de doublewrite : ce cas est détecté à la lecture, et le bloc se répare depuis une sauvegarde RMAN et les journaux (RECOVER ... BLOCK). Pendant une sauvegarde à chaud manuelle (BEGIN BACKUP), Oracle écrit l’image complète des blocs dans le redo à leur première modification, comme le full_page_writes de PostgreSQL.

Une ligne et son ROWID

Format d'une ligne Oracle et structure du ROWID
Une ligne : en-tête de 3 octets puis longueur et valeur de chaque colonne ; le ROWID en quatre parties.

Une ligne commence par trois octets : des indicateurs, l’octet de verrou et le nombre de colonnes. Viennent ensuite, pour chaque colonne, sa longueur et sa valeur. Les NULL au milieu de la ligne coûtent un octet (0xFF), et les NULL en fin de ligne ne sont pas stockés du tout. Placer les colonnes souvent nulles en dernier économise donc de la place.

L’octet de verrou est le détail le plus élégant du format : quand une transaction modifie une ligne, elle y inscrit le numéro de son entrée dans l’ITL du bloc. Le verrou de ligne est donc stocké dans le bloc lui-même, sans table de verrous en mémoire : verrouiller un million de lignes ne coûte pas plus de mémoire que d’en verrouiller une. Une autre transaction qui veut la même ligne lit l’ITL, trouve la transaction propriétaire et attend la fin de celle-ci.

Le ROWID est l’adresse physique d’une ligne : numéro d’objet, numéro de fichier relatif au tablespace, numéro de bloc, numéro de ligne dans le répertoire. C’est l’accès le plus rapide qui soit, et c’est ce que contiennent les index B-tree. Le paquet DBMS_ROWID le décode :

SELECT rowid,
       dbms_rowid.rowid_object(rowid)       AS obj,
       dbms_rowid.rowid_relative_fno(rowid) AS file#,
       dbms_rowid.rowid_block_number(rowid) AS block#,
       dbms_rowid.rowid_row_number(rowid)   AS row#
FROM shop.orders WHERE id = 7;

Pour voir le contenu brut d’un bloc, ALTER SYSTEM DUMP DATAFILE 4 BLOCK 151; écrit sa description complète (en-têtes, ITL, chaque ligne en hexadécimal) dans le fichier de trace de la session. À réserver à une base de test : c’est très instructif, mais ce n’est pas un outil de production.

Lecture cohérente : l’undo

Comme InnoDB, Oracle modifie les blocs sur place et range l’image d’avant dans l’undo. Quand une requête démarre, elle note le SCN courant. Si elle tombe sur un bloc modifié après ce SCN, elle en fabrique une copie en mémoire et lui applique à rebours les enregistrements d’undo désignés par l’ITL, jusqu’à obtenir le bloc tel qu’il était à son SCN de départ : c’est le consistent read. Les lecteurs ne bloquent jamais les écrivains, ni l’inverse.

Le revers : si l’undo nécessaire a déjà été écrasé par des transactions plus récentes, la requête échoue avec ORA-01555: snapshot too old. UNDO_RETENTION indique combien de temps Oracle doit essayer de garder l’undo, à condition que le tablespace d’undo soit assez grand. Autre phénomène, cousin des hint bits de PostgreSQL : le delayed block cleanout. Après un gros COMMIT, Oracle ne revisite pas tous les blocs modifiés, et c’est le premier SELECT suivant qui nettoie les entrées ITL… et génère du redo, à la surprise générale.

Les trois moteurs côte à côte

Où vivent les anciennes versions dans InnoDB, PostgreSQL et Oracle
Trois réponses à la même question.
InnoDBPostgreSQLOracle
Taille de page16 Ko8 Ko8 Ko (2 à 32 Ko)
Organisation de la tablearbre B+ de la clé primairetas (heap)tas (heap) par défaut, IOT en option
Adresse d’une ligne dans un indexclé primairectid (page, slot)ROWID (objet, fichier, bloc, ligne)
Anciennes versionsundo logdans la tabletablespace d’undo
Nettoyagepurge threadVACUUMréutilisation de l’undo
Pages déchiréesdoublewrite bufferfull_page_writes (WAL)contrôle du bloc + restauration RMAN
Verrous de ligneen mémoire (lock system)xmax dans le tupleoctet de verrou → ITL du bloc

Aucun de ces choix n’est meilleur dans l’absolu. L’index cluster d’InnoDB rend les recherches par clé primaire imbattables mais pénalise les clés aléatoires. Le tas de PostgreSQL rend les mises à jour simples et les retours en arrière instantanés, au prix de VACUUM. L’undo séparé d’Oracle et d’InnoDB garde les tables compactes, mais impose de dimensionner l’undo. Savoir comment les données sont rangées sur le disque, c’est savoir lequel de ces compromis on paie, et à quel moment.


Ce billet fait partie d’une série de notes d’administration système (tag « carnet d’admin »).
Série « Dans les fichiers d’une base de données », partie 3/3. · ← Billet précédent · English version

Articles liés

Page 1 of 32

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