Quelques pensées

Terres dévastées

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

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.

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.

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/57. · ← Tutoriel précédent · Tutoriel suivant → (à paraître le 1/11) · English version

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 élévation de privilèges locale, enregistrée sous le numéro ZDI-CAN-33367, et un programme de démonstration a circulé dans la foulée. Dix jours plus tard, la faille n’a toujours pas de numéro CVE, le correctif proposé n’est pas encore intégré au noyau officiel, et le noyau de ma distribution publié le 28 octobre ne le contient pas. Elle permet à n’importe quel utilisateur local de modifier des fichiers qu’il ne peut que lire (un programme setuid, /etc/passwd) et donc de devenir root.

Ce billet explique le mécanisme, sans détail d’exploitation, parce qu’il illustre une famille de failles qui revient régulièrement : celles où une optimisation qui évite de copier des données finit par écrire là où elle ne devrait pas. Il décrit ensuite la mesure que j’ai appliquée en attendant un noyau corrigé, et pourquoi j’ai écarté celle que la plupart des guides recommandent.

Une famille de failles

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, é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 un utilisateur ordinaire peut 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 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.

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/57. · ← Tutoriel précédent · Tutoriel suivant → · English version

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

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

Les dix failles

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

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

Ce qu’est un exécutable Node

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

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

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

Node2Shell : par où arrivent les données

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

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

Vérifier dans le code, pas dans la documentation

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

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

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

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

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

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

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

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

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

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

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

Deux failles plus discrètes

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

Retrouver tous ses Node

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

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

Le Node que personne ne corrigera

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

Ce que j’en retiens

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

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


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

OpenBao scellé (2/2) : 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 nginx assure le TLS devant. Le client bao essaie HTTPS par défaut, d’où cette erreur trompeuse :

bao status
# Error checking seal status: … http: server gave HTTP response to HTTPS client
export BAO_ADDR=http://127.0.0.1:8200      # en local, sur le serveur
# ou : export BAO_ADDR=https://secrets.example.org   (via nginx)

Je mets cet export dans le .bashrc de root sur ce serveur, pour ne plus y penser.

2. Vérifier l’état

bao status
# Sealed          true
# Total Shares    5
# Threshold       5
# Unseal Progress 0/5

Sealed true confirme le besoin de desceller. Unseal Progress montre combien de parts ont déjà été saisies : la saisie peut se faire en plusieurs fois, et même par plusieurs personnes.

3. Desceller

bao operator unseal      # répété 5 fois ; chaque part est demandée sans écho à l'écran

Sans argument, la commande demande la part de façon interactive et ne l’affiche pas. Ne passez jamais une part en argument de la ligne de commande : elle finirait dans l’historique du shell et dans la liste des processus. Et ne la collez jamais dans un outil qui garde un historique, qu’il s’agisse d’une messagerie ou d’un assistant.

4. Vérifier la chaîne complète

Un coffre descellé ne suffit pas : il faut que les déploiements puissent vraiment lire leurs secrets. Je teste donc depuis le nœud de contrôle Ansible, avec la même identité AppRole que les playbooks :

# connexion AppRole, puis lecture des métadonnées d'un secret (pas de sa valeur)
bao write -field=token auth/approle/login role_id=… secret_id=… > /tmp/t
BAO_TOKEN=$(cat /tmp/t) bao kv metadata get -mount=ansible postgresql | head -3
shred -u /tmp/t

Lire les métadonnées (version, dates) prouve que l’authentification et les droits fonctionnent, sans faire transiter la valeur du secret.

5. Reprendre les déploiements

Un ansible-playbook … --check sur n’importe quel playbook qui lit un secret confirme que tout est revenu à la normale.

Ne pas l’oublier

Le plus grand risque, c’est l’oubli : un redémarrage nocturne, et personne ne s’aperçoit que les déploiements sont bloqués avant d’en avoir besoin. Une alerte Prometheus sur l’état de scellement préviendrait dès que ça arrive : le point /v1/sys/health renvoie 503 quand le coffre est scellé, et une sonde blackbox suffit à le surveiller. C’est la prochaine amélioration sur ma liste. Après chaque redémarrage du serveur de la maison, le descellement fait désormais partie de ma checklist, au même titre que la vérification des services.


Ce billet fait partie d’une série de notes d’administration système (tag « carnet d’admin »).
Série « OpenBao scellé », partie 2/2. · ← Billet précédent · English version

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

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

Ce qu’est la faille

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

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

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

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

La bibliothèque partagée du système

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

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

Les copies embarquées

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

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

Ce que j’ai fait, dans l’ordre

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

La mesure d’attente

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

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

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

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

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

Redémarrer ce qui utilise l’ancienne copie

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

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

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

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

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

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

En résumé

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

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

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.

Les faits

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

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/57. · Tutoriel suivant → · English version

OpenBao scellé (1/2) : pourquoi pas d’auto-unseal

Tous mes playbooks Ansible lisent leurs secrets (mots de passe de bases, clés d’API, jetons) dans OpenBao, le fork libre de HashiCorp Vault. Les secrets ne sont donc écrits ni dans le dépôt Git, ni dans des fichiers chiffrés à faire circuler : Ansible les demande au moment du déploiement, avec une identité AppRole qui n’a que le droit de lecture.

Scellé, descellé : de quoi parle-t-on

OpenBao chiffre tout ce qu’il stocke avec une clé maîtresse. Au démarrage, il ne connaît pas cette clé : il est scellé. Il tourne et répond aux requêtes, mais refuse de lire ou d’écrire le moindre secret. Pour le desceller, il faut reconstituer la clé. Il existe deux grandes approches :

  • Shamir : à l’initialisation, la clé est découpée en n parts, dont t suffisent à la reconstituer. Au démarrage, il faut saisir ces parts à la main (bao operator unseal) ;
  • auto-unseal : la clé est confiée à un service externe (un KMS cloud, un HSM, un autre Vault). OpenBao la récupère tout seul au démarrage.

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 :

  • un vol du disque ou d’une sauvegarde ne donne rien : sans les parts, les données d’OpenBao ne sont qu’un bloc chiffré ;
  • pas de dépendance à un fournisseur cloud : un auto-unseal par KMS ferait dépendre tout le parc d’un compte cloud et de sa disponibilité ;
  • un seul serveur : l’auto-unseal se justifie surtout pour des grappes qui redémarrent souvent, ce qui n’est pas mon cas.

Le prix à payer

Après n’importe quel redémarrage (mise à jour du noyau, coupure de courant, plantage), OpenBao repart scellé. Et comme tous les playbooks lisent leurs secrets dedans, aucun déploiement n’est possible sur aucune machine tant que quelqu’un n’a pas saisi les parts. Le symptôme côté Ansible est explicite :

fatal: … Vault is sealed

C’est un compromis assumé : un peu d’intervention humaine après un redémarrage, en échange d’un coffre qui ne s’ouvre jamais tout seul. Il faut juste ne pas l’oublier, et c’est l’objet du billet suivant.


Ce billet fait partie d’une série de notes d’administration système (tag « carnet d’admin »).
Série « OpenBao scellé », partie 1/2. · Billet suivant → · English version

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

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

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

Le serveur qui ne revenait pas (3/3) : coupable, la carte mère

Récapitulons les preuves : l’arrêt systemd s’est terminé, les systèmes de fichiers ont été démontés proprement, puis la machine n’a plus rien fait pendant trente minutes. Le coupable se trouve donc entre l’appel de redémarrage du noyau et le premier écran du BIOS. Autrement dit, la carte mère n’a jamais effectué sa réinitialisation.

Comment Linux redémarre une machine

À la fin de l’arrêt, le noyau appelle reboot(), et celui-ci doit déclencher un vrai reset matériel. Sur x86, le noyau essaie plusieurs méthodes, dans un ordre par défaut qui dépend de la machine :

  • acpi : écrire dans le registre de reset déclaré par le firmware (table ACPI FADT) ;
  • kbd : la méthode historique, une impulsion via le contrôleur clavier ;
  • pci : écrire dans le port 0xCF9, le registre de reset du chipset ;
  • efi : demander au firmware UEFI de redémarrer ;
  • triple : provoquer une « triple faute » du processeur, en dernier recours.

Si la méthode choisie ne fonctionne pas sur une carte précise, le noyau a tout arrêté et attend un reset qui ne vient jamais. C’est exactement le tableau observé : écran noir, ventilateurs qui tournent, plus aucune activité réseau.

Pourquoi cette fois-là, et pas les autres ?

Le redémarrage suivant, sur le nouveau noyau, a pris 34 secondes, sans aucun souci. Plusieurs facteurs ont pu jouer le soir du blocage : l’ancien noyau avec 35 jours d’uptime, et une mise à jour des firmwares (linux-firmware) dans la même transaction. Ces blocages au reset sont connus pour être intermittents sur certaines cartes mères grand public. Je n’ai pas pu reproduire le problème, et je n’ai donc rien changé dans l’immédiat.

Si ça se reproduit

La piste est de forcer une autre méthode de reset avec le paramètre noyau reboot=, puis de tester plusieurs redémarrages :

grubby --update-kernel=ALL --args="reboot=pci"     # ou reboot=efi, reboot=acpi
grubby --info=DEFAULT | grep args                  # vérifier
reboot                                             # tester, plusieurs fois
grubby --update-kernel=ALL --remove-args="reboot"  # pour revenir en arrière

reboot=pci (le registre 0xCF9 du chipset) est souvent la plus fiable sur le matériel Intel grand public, et reboot=efi sur les firmwares UEFI récents. Le paramètre s’applique à tous les noyaux installés, y compris les prochains.

Ce qu’il faut retenir

  • avant d’accuser une mise à jour, vérifiez si l’arrêt s’est terminé : wtmp et les montages « clean » le disent sans ambiguïté ;
  • un blocage après un arrêt propre relève du matériel ou du firmware, pas du système : aucun retour arrière de paquets ne l’aurait corrigé ;
  • si la machine héberge un service dont tout dépend, notez la procédure de reprise. Ici, le coffre à secrets doit être descellé à la main après chaque redémarrage : c’est le sujet de la série suivante.

Ce billet fait partie d’une série de notes d’administration système (tag « carnet d’admin »).
Série « Le serveur qui ne revenait pas », partie 3/3. · ← Billet précédent · English version

Dans les fichiers d’une base de données (2/3) : PostgreSQL

Après InnoDB, PostgreSQL. Les deux moteurs résolvent les mêmes problèmes (stocker des lignes, les retrouver vite, survivre à un crash, laisser lire pendant qu’on écrit) mais ont fait des choix presque opposés. Les connaître explique le besoin de VACUUM, le gonflement des tables (bloat), ou pourquoi le premier SELECT après un gros chargement écrit sur le disque.

Le répertoire de données

Arborescence du répertoire PGDATA
PGDATA : un répertoire par base, un fichier par table ou index, nommés par numéro.

Premier réflexe à perdre : chercher ses tables par leur nom. PostgreSQL désigne tout par des numéros. Chaque base a un OID, et c’est le nom de son répertoire sous base/. Chaque table, index ou séquence a un relfilenode, et c’est le nom de son fichier. Ce numéro change quand la table est réécrite (TRUNCATE, VACUUM FULL, CLUSTER, certains ALTER TABLE) : PostgreSQL crée un nouveau fichier et bascule, ce qui rend ces opérations sûres en cas de crash.

SELECT oid, datname FROM pg_database;
SELECT pg_relation_filepath('orders');        -- base/16384/24576
SELECT pg_size_pretty(pg_total_relation_size('orders'));   -- table + index + TOAST
  • global/ contient les catalogues partagés par toutes les bases (rôles, liste des bases) et pg_control, le petit fichier qui dit où reprendre après un crash ;
  • pg_wal/ est le journal d’écriture anticipée (WAL), en segments de 16 Mo : l’équivalent du redo d’InnoDB ;
  • pg_xact/ garde l’état de chaque transaction sur deux bits : en cours, validée, annulée. C’est lui qui permet de savoir si une version de ligne est visible ;
  • pg_tblspc/ contient des liens symboliques vers les tablespaces créés ailleurs. Chez PostgreSQL, un tablespace n’est qu’un répertoire sur un autre disque.

Une relation : des forks et des segments

Les forks d'une relation PostgreSQL
Le main fork contient les données ; fsm et vm sont des fichiers auxiliaires.

Chaque table est en réalité un petit ensemble de fichiers. Le main fork contient les données, découpé en fichiers de 1 Go au plus (24576, 24576.1…). Cette limite date d’une époque où tous les systèmes de fichiers ne géraient pas les gros fichiers, et elle simplifie toujours la sauvegarde et la copie. La free space map indique approximativement la place libre de chaque page, pour qu’un INSERT trouve vite une page où écrire. La visibility map garde deux bits par page : « toutes les lignes sont visibles par tout le monde » et « toutes les lignes sont gelées ». Elle permet à VACUUM de sauter les pages déjà propres, et aux index-only scans de ne pas lire la table.

Anatomie d’une page

Structure d'une page PostgreSQL de 8 Ko
Une page de table : en-tête, pointeurs de ligne, espace libre, tuples.

Les pages font 8 Ko (une constante de compilation, BLCKSZ). Leur structure est la même pour les tables et les index :

  • l’en-tête de 24 octets contient le pd_lsn, la position dans le WAL de la dernière modification. PostgreSQL l’utilise pour une règle simple : une page ne peut pas être écrite sur le disque avant que le WAL qui la décrit le soit. Il contient aussi la somme de contrôle (activée par défaut à l’initialisation depuis PostgreSQL 18, à demander avec initdb -k avant), et deux décalages, pd_lower et pd_upper, qui bornent l’espace libre ;
  • les line pointers forment un tableau en début de page. Chacun contient le décalage et la longueur d’un tuple. L’adresse d’une ligne, son ctid, vaut (numéro de page, numéro de pointeur), par exemple (0,2). Cette indirection permet de déplacer un tuple dans la page pour la défragmenter sans changer son adresse, donc sans toucher aux index ;
  • les tuples sont écrits depuis la fin de la page, vers le haut. Quand pd_lower (fin des pointeurs) rejoint pd_upper (début des tuples), la page est pleine ;
  • l’espace spécial en fin de page est vide pour une table. Les index B-tree y rangent les liens vers les pages voisines et le niveau dans l’arbre.

On peut tout regarder avec l’extension pageinspect, sur une base de test :

CREATE EXTENSION pageinspect;
SELECT lower, upper, special, pagesize FROM page_header(get_raw_page('orders', 0));
SELECT lp, lp_off, lp_len, t_xmin, t_xmax, t_ctid
FROM heap_page_items(get_raw_page('orders', 0));

Un tuple

En-tête d'un tuple PostgreSQL
Les 23 octets d’en-tête qui précèdent chaque version de ligne.

Chaque version de ligne commence par 23 octets d’en-tête, arrondis à 24 par l’alignement. Les champs essentiels sont t_xmin (la transaction qui a créé cette version), t_xmax (celle qui l’a supprimée ou remplacée) et t_ctid (l’adresse de la version suivante). Trois détails pratiques :

  • l’alignement : chaque colonne est alignée selon son type (8 octets pour bigint et timestamp). Une colonne boolean suivie d’un bigint laisse 7 octets perdus. Ordonner les colonnes de la plus grande à la plus petite peut réduire une grosse table de 10 à 20 % ;
  • les NULL ne prennent qu’un bit dans le bitmap, présent seulement si la ligne contient au moins un NULL ;
  • TOAST : quand une ligne dépasse environ 2 Ko, PostgreSQL compresse d’abord les grandes valeurs (pglz, ou lz4 si on le demande), puis, si ça ne suffit pas, les déplace dans une table annexe, pg_toast.pg_toast_<oid>, découpées en morceaux. La ligne ne garde qu’un pointeur de 18 octets. C’est transparent, mais un SELECT * qui lit une colonne TOAST va chercher ces morceaux : ne sélectionnez que les colonnes utiles.

Un UPDATE ne modifie rien sur place

Un UPDATE PostgreSQL crée une nouvelle version
L’ancienne version reste dans la page, marquée par son xmax, jusqu’au VACUUM.

C’est la différence fondamentale avec InnoDB et Oracle. Chez PostgreSQL, un UPDATE est un DELETE suivi d’un INSERT : la transaction écrit une nouvelle version complète de la ligne, et inscrit son numéro dans le xmax de l’ancienne. Il n’y a pas de journal d’undo. Les anciennes versions sont l’undo, et elles vivent dans la table.

Pour décider si une version est visible, une transaction compare xmin et xmax à son instantané (snapshot) : la version est visible si sa création est validée et antérieure à l’instantané, et si sa suppression n’a pas eu lieu, a été annulée ou est postérieure. Consulter pg_xact à chaque fois coûterait cher. La première transaction qui lit une version note donc le résultat dans des hint bits de l’en-tête (« xmin validé », « xmax annulé »…). Cela explique un phénomène qui surprend souvent : le premier SELECT après un gros chargement écrit sur le disque, parce qu’il pose ces bits sur chaque page.

Ce que ça implique : VACUUM, HOT et le gel

  • VACUUM retire les versions que plus aucune transaction ne peut voir, rend leur place réutilisable (la free space map est mise à jour) et met à jour la visibility map. Il ne rend pas l’espace au système de fichiers, sauf pour les pages vides en fin de fichier. Seul VACUUM FULL (ou l’extension pg_repack, sans verrou long) réécrit la table ;
  • une transaction ouverte trop longtemps bloque le nettoyage de toutes les versions plus récentes qu’elle, dans toute la base. C’est la première chose à vérifier quand une table gonfle : SELECT pid, xact_start, state FROM pg_stat_activity ORDER BY xact_start; ;
  • les mises à jour HOT (Heap-Only Tuple) : si aucune colonne indexée ne change et que la nouvelle version tient dans la même page, PostgreSQL ne touche pas aux index. L’index pointe toujours vers l’ancien pointeur, qui mène à la nouvelle version par la chaîne de ctid. Laisser de la place dans les pages augmente les chances de HOT : ALTER TABLE orders SET (fillfactor = 90); ;
  • le gel (freezing) : les identifiants de transaction tiennent sur 32 bits et finissent par boucler, après environ deux milliards de transactions. Pour qu’une très vieille ligne ne paraisse pas soudain « dans le futur », VACUUM la marque comme gelée, visible par tous. Si autovacuum prend trop de retard, PostgreSQL finit par refuser les écritures pour se protéger : c’est pourquoi on ne désactive jamais autovacuum.

Les index et le WAL

Un index B-tree utilise les mêmes pages de 8 Ko. Ses entrées contiennent la clé et le ctid du tuple, c’est-à-dire une adresse physique, contrairement à InnoDB qui stocke la clé primaire. Une recherche par index est donc une seule descente plus une lecture de page. Mais l’index ne sait rien de la visibilité : il faut lire la table pour savoir si la version trouvée est visible, sauf si la visibility map dit que la page est entièrement visible. C’est ce qui rend les index-only scans efficaces sur une table bien nettoyée.

Enfin, la protection contre les pages déchirées : le problème est le même que chez InnoDB (8 Ko écrits par un disque qui garantit 4 Ko), la solution est différente. Avec full_page_writes = on, la première modification d’une page après chaque checkpoint écrit l’image complète de la page dans le WAL. Après un crash, PostgreSQL repart de cette image intacte. C’est l’équivalent du doublewrite buffer, et la raison pour laquelle le volume de WAL grimpe juste après chaque checkpoint.

Dernier billet de la série : Oracle, qui partage avec InnoDB l’undo séparé, mais pousse la logique plus loin, avec des verrous rangés dans les blocs eux-mêmes.


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 2/3. · ← Billet précédent · Billet suivant → · English version

Page 1 of 32

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