Pourquoi cet article
Ce qui m'amuse le plus en ce moment, c'est de prendre les PoC des dernières vulnérabilités Linux et de les réécrire dans un autre langage que l'original. Pas pour publier - pour comprendre. Il y a quelque chose d'unique dans l'exercice : quand je dois réimplémenter chaque appel système, chaque structure de données, chaque contrainte de timing, je ne peux pas me contenter de lire le code en diagonale. Je dois comprendre pourquoi chaque ligne est là.
Le printemps 2026 a été particulièrement chargé côté vulnérabilités noyau Linux. En l'espace de quelques semaines, cinq bugs distincts ont été publiés, qui partagent tous une caractéristique frappante : le noyau écrit dans ses propres fichiers en lecture seule, sans toucher au disque, sans race condition, sans corruption mémoire au sens classique du terme. Copyfail d'abord, en avril. Puis la famille dirty frag en mai - quatre variantes du même primitif. Et pintheft, qui arrive au même résultat par un chemin complètement différent.
Cet article est ma synthèse de ce que j'ai appris en plongeant dans chacun de ces exploits. Je ne suis pas l'auteur de ces découvertes - le crédit va à Theori / Xint Code pour copyfail, à V4bel pour dirty frag, et à Aaron Esau / V12 pour dirtydecrypt, fragnesia et pintheft. Mon apport, c'est l'analyse unifiée.

le noyau en train de déchiffrer dans son propre page cache
Vibe général de cette famille de bugs
Sommaire
- Le page cache Linux - pourquoi c'est intéressant
- splice() et vmsplice() - la plomberie commune
- CVE-2026-31431 - copyfail (AF_ALG / authencesn)
- Le bug commun dirty frag : skb_cow_data() manquant
- CVE-2026-43284 - dirty frag, chemin XFRM / ESP-in-UDP
- CVE-2026-43500 - dirty frag, chemin RxRPC / rxkad
- CVE-2026-46300 - fragnesia, chemin ESP-in-TCP / ULP
- CVE-2026-31635 - dirtydecrypt, chemin RxRPC / rxgk
- Tableau comparatif - la famille page cache
- pintheft - un primitif différent, même résultat
- Détection et mitigation
- Ce que ça apprend
1. Le page cache Linux - pourquoi c'est intéressant
Linux maintient en RAM une copie des contenus de fichiers qu'il appelle le page cache. Chaque fichier est représenté par une struct address_space qui indexe des struct page - des blocs de 4096 octets correspondant aux pages du fichier sur disque.
Quand un processus lit un fichier, le noyau charge la page depuis le disque dans ce cache, puis sert les lectures depuis la copie mémoire. Lors de la prochaine lecture, le noyau sert directement depuis le cache sans toucher le disque. C'est ce qui rend les accès fichiers rapides.
struct page du cache contourne entièrement ce chemin.Ce détail est crucial pour tous les exploits qui suivent. Quand le noyau modifie le page cache de /usr/bin/su, le fichier sur disque reste intact. Un md5sum ou un scan antivirus sur le disque ne verra rien. Mais la prochaine fois qu'un processus exécutera su, le noyau chargera l'ELF depuis le page cache - et exécutera le shellcode.
Un echo 1 | tee /proc/sys/vm/drop_caches ou un reboot évince les pages modifiées et restaure l'état propre depuis le disque. L'exploitation est entièrement en mémoire, sans persistance.
Vérifier l'état du page cache vs disque
# Sur disque, le fichier est intact
sha256sum /usr/bin/su
# ab12... (hash original)
# Mais la lecture depuis le page cache retourne le shellcode
dd if=/usr/bin/su bs=1 count=8 skip=120 2>/dev/null | xxd
# 00000000: 31ff 31f6 31c0 b06a 1.1.1..j <- shellcode injecté
# Après drop_caches
echo 1 > /proc/sys/vm/drop_caches
sha256sum /usr/bin/su
# ab12... (identique, le disque n'a pas bougé)2. splice() et vmsplice() - la plomberie commune
splice(2) est un appel système Linux qui transfère des données entre un file descriptor et un pipe sans copie. Quand la source est un fichier ordinaire, le noyau ne copie pas les octets - il ajoute simplement une référence à la page cache du fichier dans la liste des buffers du pipe.
Concrètement, un struct pipe_buffer dans le pipe contient un pointeur struct page* vers la page cache du fichier source. Pas de copie, juste une référence. Plusieurs descripteurs peuvent partager la même page simultanément.
splice() - zéro copie vers un pipe
int file_fd = open("/usr/bin/su", O_RDONLY);
int pipe_fds[2];
pipe(pipe_fds);
// La page cache de /usr/bin/su[offset] est maintenant
// référencée dans le pipe, sans copie.
// pipe_buffer[0].page pointe vers la struct page du fichier.
off_t off = 0;
splice(file_fd, &off, pipe_fds[1], NULL, 4096, SPLICE_F_MOVE);vmsplice(2) fait l'inverse : il mappe de la mémoire utilisateur dans un pipe. Dans les exploits dirty frag, il sert à injecter l'en-tête du paquet (header ESP ou RxRPC) dans le pipe avant la page fichier, de sorte que le noyau voie un paquet réseau cohérent suivi du payload qui est en réalité du page cache.

Deux références, une seule struct page - c'est le primitif
3. CVE-2026-31431 - copyfail (AF_ALG / authencesn)
Copyfail est chronologiquement le premier de cette vague - publié par Taeyang Lee et l'équipe Theori / Xint Code en avril 2026. C'est lui qui m'a mis sur la piste de toute la famille.
Le bug réside dans algif_aead, l'interface socket du sous-système crypto noyau (AF_ALG). En 2017, une optimisation a été introduite pour permettre le déchiffrement AEAD en place : quand les données source et destination se trouvent dans le même segment mémoire, pas besoin de copier. Le problème : quand les données arrivent via un splice() depuis un fichier, ce segment mémoire est une page du page cache. La même page se retrouve dans le scatterlist source ET destination de l'opération AEAD. Le déchiffrement écrit dans le page cache.
Le mécanisme concret
L'exploit utilise l'algorithme authencesn(hmac(sha256),cbc(aes)) via AF_ALG. La logique à chaque itération est la suivante : configurer une opération de déchiffrement avec des données d'association connues (4 octets "AAAA" + 4 octets de payload = 8 octets au total), puis splice la page du fichier cible à l'offset voulu dans le socket op_fd. Le noyau déchiffre en place, et les 4 octets contrôlés atterrissent dans le page cache.
copyfail - une itération d'écriture (C inline dans le script bash)
// Ouvrir l'interface AF_ALG AEAD
int alg_fd = socket(AF_ALG, SOCK_SEQPACKET, 0);
struct sockaddr_alg sa = {
.salg_family = AF_ALG,
.salg_type = "aead",
.salg_name = "authencesn(hmac(sha256),cbc(aes))",
};
bind(alg_fd, &sa, sizeof(sa));
setsockopt(alg_fd, SOL_ALG, ALG_SET_KEY, key, 40);
setsockopt(alg_fd, SOL_ALG, ALG_SET_AEAD_AUTHSIZE, NULL, 4);
int op_fd = accept4(alg_fd, 0, 0, 0);
// sendmsg : données d'association + 4 octets de payload voulus
sendmsg(op_fd, &mhdr, MSG_MORE);
// splice : page cache du fichier cible à l'offset i
loff_t off = 0;
splice(file_fd, &off, pipe[1], NULL, i + 4, 0);
splice(pipe[0], NULL, op_fd, NULL, i + 4, 0);
// Le noyau "déchiffre" en place -> écrit dans le page cache
// Le disque est intact.Pourquoi authencesn et pas juste aes ou hmac
Un détail qui mérite d'être noté : c'est l'algorithme combiné AEAD authencesn(hmac(sha256),cbc(aes)) qui déclenche le chemin de code vulnérable dans algif_aead. Un déchiffrement AES-CBC seul ne suffit pas. La raison est que le chemin in-place optimisé de 2017 ne s'applique qu'aux opérations AEAD - c'est précisément pour éviter la copie du scatterlist source vers destination dans ce cas là que l'optimisation a été introduite. C'est aussi pour ça que la mitigation module-level (blacklister algif_aead) bloque l'exploit sans toucher aux autres sous-systèmes crypto du noyau.
Caractéristiques
- - Déterministe :: 4 octets par trigger, autant de triggers que nécessaire
- - Cible :: n'importe quel binaire SUID lisible
- - Architectures :: x86_64, aarch64, i386, arm
- - Modules requis :: algif_aead, authencesn, hmac, cbc
- - Noyaux affectés :: 4.14 (août 2017) à mainline avril 2026
- - Patch :: commit a664bf3d603d (revert de l'optimisation in-place 2017)
4. Le bug commun dirty frag : skb_cow_data() manquant

Le raisonnement du noyau Linux version 2017-2026
À partir de là, on entre dans la famille dirty frag - publiée par V4bel et l'équipe V12 en mai 2026. Quatre CVE, quatre chemins différents, un bug identique : avant de modifier les données d'un skb en place (décryptage, suppression d'en-têtes...), le code noyau doit appeler skb_cow_data().
Cette fonction vérifie si les fragments de données du skb sont partagés avec d'autres références, et si oui, en fait une copie privée avant modification. Quand la page est arrivée via un splice() depuis un fichier, elle est effectivement partagée : elle appartient simultanément au page cache du fichier et au skb réseau. Sans ce check, le déchiffrement s'effectue sur la page partagée - et écrit dans le page cache.
Avant le patch (exemple XFRM) - pas de skb_cow_data()
// net/xfrm/xfrm_input.c - AVANT patch CVE-2026-43284
static int xfrm_input(struct sk_buff *skb, ...)
{
// ICI : skb->data pointe potentiellement vers le page cache
// Aucun check de partage avant modification
err = x->type->input(x, skb); // déchiffrement en place -> écrit le page cache
}Après le patch - copie si partagé
static int xfrm_input(struct sk_buff *skb, ...)
{
// Copie les données si le buffer est partagé
err = skb_cow_data(skb, 0, &trailer);
if (err < 0) return err;
err = x->type->input(x, skb); // déchiffrement sur la copie
}5. CVE-2026-43284 - dirty frag, chemin XFRM / ESP-in-UDP
Le premier chemin dirty frag utilise le sous-système XFRM (le framework IPsec du noyau) en mode transport ESP-in-UDP. L'idée est d'installer des Security Associations via NETLINK_XFRM et de coder le payload voulu directement dans les champs de ces SAs.
L'astuce seq_hi
Chaque SA stocke un état de rejeu ESN contenant notamment le champ seq_hi. Pendant le déchiffrement ESP d'un paquet, le noyau met à jour cet état en écrivant seq_hi dans le buffer de données du skb - qui est en fait le page cache du fichier. En choisissant soigneusement cette valeur pour chaque SA, l'attaquant contrôle exactement quels 4 octets seront écrits, et où.
Installer 48 SAs - chaque seq_hi encode 4 octets du shellcode
// 48 SAs pour écrire 192 octets (4 octets par SA)
for i in range(48):
spi = 0xDEADBE10 + i
seq_hi = (shellcode[i*4+0] << 24 |
shellcode[i*4+1] << 16 |
shellcode[i*4+2] << 8 |
shellcode[i*4+3])
install_xfrm_sa(spi=spi, seq_hi=seq_hi)
# Ensuite, pour chaque SA :
# vmsplice(esp_header) + splice(file[offset+i*4]) -> pipe -> UDP socket
# XFRM déchiffre en place -> écrit seq_hi à l'offset i*4 dans le page cacheLe problème des namespaces
L'installation de SAs XFRM requiert CAP_NET_ADMIN. L'exploit contourne ça via CLONE_NEWUSER | CLONE_NEWNET : dans un user+network namespace isolé, l'utilisateur non-privilégié obtient automatiquement CAP_NET_ADMIN sur son propre namespace. C'est un usage parfaitement légitime des namespaces - aucun privilege requis côté hôte.
Caractéristiques
- - Déterministe :: 48 triggers, 4 octets chacun, 192 octets total
- - Cible :: /usr/bin/su (ELF shellcode en page cache)
- - Modules requis :: esp4, esp6 (autochargés)
- - Namespaces :: CLONE_NEWUSER + CLONE_NEWNET
- - Patch upstream :: lists.openwall.net/netdev/2026/05/06/112
6. CVE-2026-43500 - dirty frag, chemin RxRPC / rxkad
Le deuxième chemin utilise AF_RXRPC, le sous-système AFS/RxRPC du noyau, avec la couche de sécurité rxkad basée sur fcrypt - un vieux chiffrement par blocs de 8 octets issu de Kerberos v4. Ce qui m'a le plus frappé dans ce chemin, c'est qu'on ne touche pas à /usr/bin/su - on cible /etc/passwd.
rxkad_verify_packet_1() et le déchiffrement en place
La fonction rxkad_verify_packet_1() déchiffre le payload d'un paquet RxRPC entrant avec PCBC(fcrypt). Si ce payload vient d'un splice() de page cache, elle écrit dans la page cache d'/etc/passwd. L'objectif est de transformer root:x:0:0:... en root::0:0:GGGGGG:/root:/bin/bash (mot de passe vide). Trois écritures de 8 octets aux offsets 4, 6 et 8, avec un chevauchement intentionnel (last-write-wins).
Le bruteforce hors-ligne des clés fcrypt
fcrypt est un algorithme Feistel de 16 rounds avec 56 bits effectifs. Il n'a pas été conçu pour résister aux attaques modernes. Pour chaque bloc de 8 octets à écrire, on cherche une clé K telle que fcrypt_decrypt(C, K) = P - où C est le contenu actuel du fichier à cet offset et P est le contenu désiré.
Le bruteforce est parallélisé sur tous les cœurs via un PRNG (splitmix64) avec des graines différentes par worker. Les prédicats sont volontairement permissifs - on cherche K_A tel que decrypt(C, K_A)[0:2] == "::" - donc la probabilité de succès à chaque essai est 1/65536, pas 1/2^56. En pratique, quelques secondes sur du matériel moderne.
Un détail important : PCBC(fcrypt) avec un IV nul sur un bloc unique se réduit à fcrypt_decrypt(C, K) seul. C'est ce que fait exactement rxkad_verify_packet_1(). Comprendre ça en lisant crypto/fcrypt.c du noyau est indispensable pour comprendre pourquoi le bruteforce offline fonctionne et pourquoi trois triggers suffisent.
Caractéristiques
- - Déterministe :: 3 triggers fixes après bruteforce offline
- - Cible :: /etc/passwd (root sans mot de passe)
- - Prérequis :: bruteforce fcrypt offline (quelques secondes)
- - Module requis :: rxrpc (autochargé)
- - Namespaces :: aucun nécessaire
- - Patch upstream :: lists.openwall.net/netdev/2026/05/06/114
7. CVE-2026-46300 - fragnesia, chemin ESP-in-TCP / ULP
Fragnesia, de William Bowling (V12), utilise une surface différente : TCP_ULP espintcp, une option TCP qui installe un handler de couche supérieure pour chiffrer/déchiffrer ESP directement dans la couche TCP. Ce qui rend ce chemin élégant, c'est le timing de l'installation du ULP.
L'installation tardive du ULP
Le sender splice les données du fichier cible dans le socket TCP - elles entrent dans la file de réception du receiver en tant que page cache. Ensuite seulement, le receiver installe TCP_ULP espintcp via setsockopt. Le ULP traite les données déjà en attente en place - et déchiffre in-place le page cache déjà mis en file.
Synchronisation sender/receiver
// Thread receiver
conn = accept(srv)
sleep(30ms) // attendre que le sender ait splicé le fichier dans le recv buffer
setsockopt(conn, IPPROTO_TCP, TCP_ULP, "espintcp")
// -> ULP traite les données déjà en queue, in-place -> XOR keystream dans le page cache
// Thread sender
sock = connect(receiver)
send(sock, esp_header_prefix) // 18 octets : len(2) + header ESP(16)
sleep(1ms)
splice(file_fd, &offset, pipe_write, NULL, 4096, 0)
splice(pipe_read, NULL, sock, NULL, 4096, 0)
// données du fichier maintenant dans le recv buffer du receiverAES-GCM comme XOR contrôlé
AES-128-GCM génère un keystream en chiffrant un counter block : salt(4) || IV(8) || counter_be32. Le counter block à la position 2 est salt || IV || 0x00000002. En variant l'IV, on contrôle le keystream, et donc ce qui sera XOR-é dans la page cache. L'exploit construit une table de correspondance via AF_ALG : pour chaque valeur de keystream possible (0x00 à 0xFF), quelle valeur d'IV la produit ? Les 256 valeurs sont couvertes dans les 65536 premiers nonces.
Table de keystream AES-GCM
// Construire la table une fois au démarrage
for nonce in range(65536):
counter_block = salt + nonce_as_iv + b'