Article - Kernel Security

Dirty Frag et compagnie : anatomie d'une vague de LPE Linux

Ce qui m'amuse le plus en ce moment, c'est de réécrire les PoC des dernières vulnérabilités Linux dans d'autres langages que l'original. Cet article est le résultat de ce travail appliqué à cinq bugs du printemps 2026 - copyfail, la famille dirty frag et pintheft.

Category: Kernel Security
Tags: Linux · LPE · Kernel · XFRM · RxRPC · io_uring
Date: 2026-06-01

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.

This is fine meme

le noyau en train de déchiffrer dans son propre page cache

Vibe général de cette famille de bugs

Sommaire

  1. Le page cache Linux - pourquoi c'est intéressant
  2. splice() et vmsplice() - la plomberie commune
  3. CVE-2026-31431 - copyfail (AF_ALG / authencesn)
  4. Le bug commun dirty frag : skb_cow_data() manquant
  5. CVE-2026-43284 - dirty frag, chemin XFRM / ESP-in-UDP
  6. CVE-2026-43500 - dirty frag, chemin RxRPC / rxkad
  7. CVE-2026-46300 - fragnesia, chemin ESP-in-TCP / ULP
  8. CVE-2026-31635 - dirtydecrypt, chemin RxRPC / rxgk
  9. Tableau comparatif - la famille page cache
  10. pintheft - un primitif différent, même résultat
  11. Détection et mitigation
  12. 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.

Le point clé : modifier le page cache ne modifie pas le fichier sur disque. Le chemin d'écriture normal passe par le mécanisme de dirty page tracking du noyau, qui finit par appeler les routines du filesystem (ext4_writepage, etc.). Écrire directement dans une 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.

La conséquence directe : quand ce pipe est ensuite envoyé dans un socket réseau ou dans une interface crypto (AF_ALG), le noyau construit un skb ou un scatterlist dont les fragments pointent vers ces mêmes pages cache. Si le sous-système en question chiffre ou déchiffre ces données en place, il écrit le résultat directement dans le page cache du fichier source.
Spider-Man pointing meme

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)
Amplitude historique : copyfail est probablement le bug de cette vague avec la plus grande surface d'exposition. Neuf ans de noyaux vulnérables, toutes les distributions majeures confirmées à la divulgation. Les backports distros ont commencé à arriver vers le 29 avril 2026.

4. Le bug commun dirty frag : skb_cow_data() manquant

Drake meme

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
}
Pourquoi quatre sous-systèmes affectés ? Parce que XFRM, RxRPC et TCP ULP ont chacun leur propre chemin de traitement de paquets, écrits par des équipes différentes. Chacun a réimplémenté sa propre logique de décryptage in-place. Chacun a oublié le même check. Ce n'est pas un bug isolé - c'est un pattern manquant.

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 cache

Le 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 receiver

AES-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''
    keystream_byte = AES_ECB_encrypt(key, counter_block)[0]
    if keystream_byte not in table:
        table[keystream_byte] = nonce

// Pour écrire un octet à l'offset i :
current  = read_byte(file, i)
needed   = current ^ desired   // keystream nécessaire
nonce    = table[needed]
// Lancer un trigger avec cet IV :
//   current XOR needed = current XOR (current XOR desired) = desired

Caractéristiques

  • - Déterministe :: 1 SA, table de keystream, 1 octet par trigger
  • - Cible :: /usr/bin/su (même shellcode que dirtyfrag)
  • - Module requis :: esp6 (IPv6 loopback)
  • - Namespaces :: aucun nécessaire
  • - Patch upstream :: lists.openwall.net/netdev/2026/05/13/79

8. CVE-2026-31635 - dirtydecrypt, chemin RxRPC / rxgk

Dirtydecrypt d'Aaron Esau (V12) est la version probabiliste de la famille. Il utilise rxgk (AFS Kerberos 5, en opposition à rxkad qui est Kerberos 4). L'algorithme de chiffrement est AES-128-CTS (krb5enc, RFC 3961). C'est le plus lent des quatre à s'exécuter, mais le principe est fascinant.

Pourquoi probabiliste

Contrairement aux variantes précédentes, l'exploit ne peut pas choisir exactement ce qui sera écrit à chaque trigger. La clé de session rxgk est générée aléatoirement à chaque essai via add_key("rxrpc", ...). Le premier octet du bloc AES-128-CTS décrypté vers la page cache est donc uniformément aléatoire : probabilité 1/256 que cet octet soit exactement la valeur ciblée.

La technique de la fenêtre glissante

Un seul trigger couvre 16 octets consécutifs : page_cache[i..i+15]. L'astuce de la fenêtre glissante exploite ce fait pour réparer les "dégâts collatéraux" :

  • - Tirer sur l'offset i jusqu'à ce que page_cache[i] == target[i] (~256 essais en moyenne)
  • - Avancer à i+1. Le prochain trigger écrit page_cache[i+1..i+16]
  • - Il corrompt les octets i+1..i+16, mais l'octet i est hors de la fenêtre - il ne sera plus touché
  • - Le progrès est uniquement vers l'avant, jamais en arrière

Fenêtre glissante - logique

for i in range(192):                      # pour chaque octet du shellcode
    while mmap[i] != shellcode[i]:         # tant que l'octet n'est pas bon
        generate_random_aes128_key()       # nouvelle clé rxgk aléatoire
        trigger_rxgk_decrypt(offset=i)     # byte i devient aléatoire -> 1/256 de succès
    // byte i est correct, avancer
    // prochain trigger à offset i+1 n'affecte pas byte i
En pratique : ~256 essais par octet, ~49 152 triggers pour 192 octets. Ça correspond à quelques minutes d'exploitation sur un système avec rxrpc chargé. Le PoC borne à 10 000 essais max par octet (~1,9M de triggers au pire) pour éviter une boucle infinie.

Caractéristiques

  • - Probabiliste :: 1/256 par octet, fenêtre glissante
  • - Espérance :: ~256 triggers/octet, ~49 152 total
  • - Cible :: /usr/bin/su
  • - Module requis :: rxrpc (autochargé)
  • - Namespaces :: aucun nécessaire

9. Tableau comparatif - la famille page cache

CVE-2026-31431

path :: AF_ALG / authencesn

modèle :: Déterministe

granule :: 4 octets / trigger

triggers :: plen/4 (variable)

crypto :: authencesn(hmac-sha256, cbc-aes)

cible :: tout binaire SUID

module :: algif_aead

CVE-2026-43284

path :: XFRM / ESP-in-UDP

modèle :: Déterministe

granule :: 4 octets / SA

triggers :: 48 SAs

crypto :: CBC-AES + HMAC-SHA256

cible :: /usr/bin/su

module :: esp4 / esp6

CVE-2026-43500

path :: RxRPC / rxkad

modèle :: Déterministe (bruteforce offline)

granule :: 8 octets / trigger

triggers :: 3 clés fcrypt

crypto :: PCBC(fcrypt)

cible :: /etc/passwd

module :: rxrpc

CVE-2026-46300

path :: ESP-in-TCP / ULP

modèle :: Déterministe (table keystream)

granule :: 1 octet / trigger

triggers :: 1 SA, 192 triggers max

crypto :: AES-128-GCM

cible :: /usr/bin/su

module :: esp6 (IPv6)

CVE-2026-31635

path :: RxRPC / rxgk

modèle :: Probabiliste (1/256)

granule :: 1 octet / trigger

triggers :: ~49 152 en moyenne

crypto :: AES-128-CTS (krb5enc)

cible :: /usr/bin/su

module :: rxrpc

CVERaceNamespacesDisque modifiéReboot safe
CVE-2026-31431NonAucunNonOui
CVE-2026-43284NonNEWUSER + NEWNETNonOui
CVE-2026-43500NonAucunNonOui
CVE-2026-46300NonAucunNonOui
CVE-2026-31635NonAucunNonOui
Wait it's all page cache astronaut meme

5 bugs, 5 chemins différents, 1 seul primitif

10. pintheft - un primitif différent, même résultat

Pintheft, également d'Aaron Esau (V12), arrive au même résultat - écrire dans le page cache d'un binaire SUID - mais par un chemin radicalement différent. Pas de splice, pas de crypto in-place. C'est un double décrément du refcount d'une page, combiné à io_uring qui garde un pointeur qui pend dans le vide.

Le changement de registre : les cinq bugs précédents sont des erreurs de vérification - on a oublié de copier avant de modifier. Pintheft est une erreur de comptage - on décrémente deux fois un compteur de références qui devrait l'être une seule fois. Le résultat final est similaire (page cache corrompu, root shell), mais le chemin est plus tortueux à comprendre.

Le bug RDS zcopy : double-free du refcount

Le bug se trouve dans rds_message_zcopy_from_user(). La fonction pin les pages utilisateur une par une via FOLL_GET. Si une page faulte (par exemple une page guard en PROT_NONE), le chemin d'erreur appelle put_page() sur les pages déjà pinnées, puis rds_message_purge() appelle __free_page() sur elles à nouveau - parce que op_mmp_znotifier a été NULLé mais op_nents / les entrées sg sont restées intactes. Chaque sendmsg qui échoue vole exactement 1 référence à la première page.

Le rôle de io_uring et CONFIG_INIT_ON_ALLOC

Pour contourner CONFIG_INIT_ON_ALLOC_DEFAULT_ON (qui zeroise les pages à l'allocation et déclenche des checks), la page cible est d'abord enregistrée dans io_uring via IORING_REGISTER_BUFFERS. Cette opération ajoute GUP_PIN_COUNTING_BIAS (= 1024) au refcount via FOLL_PIN. On vole ensuite 1024 fois via des zcopy RDS qui échouent. Le refcount revient à ~1 (juste le mapping PTE). munmap passe par le chemin normal de libération, sans déclencher bad_page.

La chaîne complète

pintheft - la chaîne de bout en bout

// 1. Pin à CPU 0, drainer le PCP
sched_setaffinity(0, cpu0)
// Drainer 512 pages du PCP (Per-CPU Page list) pour contrôler l'ordre LIFO

// 2. Enregistrer la page dans io_uring (refcount +1024 via FOLL_PIN)
IORING_REGISTER_BUFFERS(ring, &buf, page_size)
// refcount = 1025

// 3. Cloner le ring -> daemon daemon.Close() empêche unpin_user_folio
IORING_REGISTER_CLONE_BUFFERS(ring2, ring)  // imu->refs = 2
// daemon forké garde ring2 ouvert : unpin_user_folio sautera (refs > 1)

// 4. Voler 1024 refs via RDS zcopy échouants (PROT_NONE guard page)
for i in range(1024):
    rds_sendmsg(buf, buf + page_size)  // faulte sur la guard page -> -1 ref
// refcount = ~1

// 5. Évincer le page cache du binaire cible
fadvise(su_fd, 0, page_size, POSIX_FADV_DONTNEED)

// 6. Section critique : munmap + pread en raw syscalls consécutifs
// munmap -> page libérée, atterrit en tête du PCP LIFO
// pread(su_fd) -> page cache allocator prend la tête du PCP -> notre page
munmap(buf, page_size)       // raw syscall, pas d'interruption entre les deux
pread(su_fd, buf2, page_size, 0)

// 7. io_uring a toujours un bvec dangling vers notre page = page cache de su
// READ_FIXED -> lit le shellcode depuis le payload file et l'écrit via bvec dangling
IORING_OP_READ_FIXED(ring, payload_fd, buf, page_size)

// 8. Vérifier et spawn PTY root shell
verify(su_fd, shellELF) && exec_pty(su_path)

La section critique munmap / pread

La section critique entre munmap et pread doit être exécutée avec le moins d'instructions possible entre les deux. Si une autre allocation se glisse entre les deux syscalls, une autre page prend la tête du PCP avant le pread - et c'est cette page qui devient le page cache de su, pas la nôtre. Le PoC original résout ça avec des raw syscalls consécutifs et l'affinité CPU pour éviter les préemptions entre les deux appels.

Caractéristiques

  • - Primitif :: double-free RDS + dangling bvec io_uring
  • - Cible :: tout binaire SUID lisible
  • - Modules requis :: CONFIG_RDS=m, CONFIG_IO_URING=y
  • - Exposition :: Arch Linux particulièrement exposé (RDS chargé par défaut)
  • - Disque modifié :: Non (page cache uniquement)
  • - Retries :: 5 max (dépend du timing PCP LIFO)

11. Détection et mitigation

La majorité des postes n'ont aucune raison d'avoir rxrpc, algif_aead ou esp4/esp6 chargés. Les désactiver coupe net tous les chemins d'exploitation de cette famille.

Blacklist des modules vulnérables

# Décharger si présents
rmmod rxrpc algif_aead esp4 esp6 2>/dev/null

# Blacklist permanente
cat > /etc/modprobe.d/no-lpe-2026.conf << 'EOF'
install rxrpc /bin/false
install algif_aead /bin/false
install esp4 /bin/false
install esp6 /bin/false
install rds /bin/false
EOF

# Vérification
lsmod | grep -E 'rxrpc|algif_aead|esp4|esp6|^rds '
# -> aucune sortie = modules non chargés

Ubuntu 26.04, Fedora 40+ et CentOS Stream 10 bloquent déjà le chargement de ces modules par défaut via AppArmor / SELinux. Les distributions plus conservatrices (Arch Linux notamment) ne le font pas.

# Chargement inhabituel de modules réseau

auditd - détecter le chargement de modules suspects

-a always,exit -F arch=b64 -S init_module,finit_module    -k kernel_module_load

# Chercher dans les logs :
ausearch -k kernel_module_load | grep -E 'rxrpc|algif|esp4|esp6|rds '

# Activité NETLINK_XFRM anormale

CVE-2026-43284 installe 48 SAs XFRM en rafale depuis un processus non-VPN. Hors contexte IPsec légitime, c'est un signal fort.

Lister les SAs actives

ip xfrm state
# Un burst de 48+ SAs avec des SPIs séquentiels (0xDEADBE10, 0xDEADBE11...)
# depuis un processus sans CAP_NET_ADMIN hors namespace est anormal

# eBPF - tracer splice() + send() suspects

La chaîne splice(file, pipe) suivie de splice(pipe, socket) depuis un processus non-root est caractéristique du primitif d'exploitation.

bpftrace - détecter la chaîne splice/send

tracepoint:syscalls:sys_enter_splice
/uid != 0/
{
    printf("splice() par %s (pid %d): fd_in=%d fd_out=%d\n",
           comm, pid, args->fd_in, args->fd_out);
}

CVE-2026-43284 nécessite la création de user namespaces non-privilégiés. Les désactiver bloque ce vecteur spécifique (les autres n'en ont pas besoin).

Désactiver les user namespaces non-privilégiés

sysctl -w kernel.unprivileged_userns_clone=0
echo "kernel.unprivileged_userns_clone=0" >> /etc/sysctl.d/99-security.conf

Cette mesure casse Podman rootless et certains sandboxes de navigateurs. À évaluer selon le contexte avant de l'appliquer en production.

12. Ce que ça apprend

La correction est identique dans les quatre cas dirty frag : ajouter skb_cow_data() avant chaque opération de cryptographie en place. Pour copyfail, le fix revient en arrière sur l'optimisation in-place de 2017. Pour pintheft, le patch corrige le double put_page dans le chemin d'erreur de rds_message_zcopy_from_user().

Ce qui me frappe dans cette vague, c'est l'absence de corruption mémoire traditionnelle. Pas d'overflow, pas d'UAF, pas de type confusion. Chaque exploit tire parti de sémantiques correctes utilisées de manière non prévue :

  • - splice() est conçu pour le zéro-copie :: c'est son comportement normal
  • - le déchiffrement in-place est une optimisation légitime :: c'est son comportement normal
  • - la composition des deux crée une écriture arbitraire dans le page cache

L'invariant manquant - "avant de modifier un buffer, vérifier qu'il nous appartient" - est facile à oublier quand on travaille sur un sous-système isolé. XFRM, RxRPC et TCP ULP ont été écrits par des équipes différentes, à des époques différentes, et chacun a oublié le même check. C'est un argument fort pour centraliser ce type de garde-fou dans la primitive elle-même plutôt que de compter sur chaque appelant.

Pour ce qui me concerne : réécrire ces cinq exploits m'a forcé à lire des parties du noyau que je n'aurais jamais touchées autrement - algif_aead, xfrm_input, rxkad_verify_packet_1, le code io_uring SQ/CQ... La lecture seule ne suffit pas. C'est en réimplémentant chaque structure de données et chaque appel système que les détails qui font la différence deviennent évidents.

Surprised Pikachu meme

execve(/usr/bin/su) après l'exploit

Crédits et références

# Découvertes originales

# Patches upstream

  • - CVE-2026-31431 :: github.com/torvalds/linux/commit/a664bf3d603d
  • - CVE-2026-43284 :: lists.openwall.net/netdev/2026/05/06/112
  • - CVE-2026-43500 :: lists.openwall.net/netdev/2026/05/06/114
  • - CVE-2026-46300 :: lists.openwall.net/netdev/2026/05/13/79