
Preuve de concept démontrant une évasion de conteneur sur Kubernetes via la corruption du cache de pages du noyau CVE-2026-31431, permettant l'exécution de code au niveau du nœud depuis un conteneur non privilégié.
Une preuve de concept démontrant comment un conteneur totalement non privilégié peut atteindre une exécution de code au niveau du nœud sur Kubernetes en exploitant le bug de corruption du cache de pages du noyau Linux CVE-2026-31431 via les couches d'images conteneur partagées.
Avertissement : Ce dépôt est publié à des fins éducatives et défensives uniquement. Utilisez-le exclusivement sur des systèmes que vous possédez ou pour lesquels vous disposez d'une autorisation explicite de test.
CVE-2026-31431 (« Copy Fail ») est une vulnérabilité du noyau Linux dans le chemin Copy-on-Write (CoW) du cache de pages. Une course AF_ALG splice permet à un processus non privilégié de corrompre les pages du cache de pages d'un fichier en lecture seule. La corruption persiste dans le cache de pages du noyau et est visible par chaque processus qui lit ou exécute ensuite le fichier — y compris les processus d'autres conteneurs ou de l'hôte.
Pour tous les détails sur la vulnérabilité d'origine, voir copy.fail.
La chaîne d'attaque comporte trois étapes : corruption du cache de pages, propagation inter-conteneurs et exécution privilégiée.
Le sous-système AF_ALG (crypto) du noyau expose une interface basée sur les sockets pour les opérations cryptographiques de l'espace utilisateur. L'exploit abuse d'une condition de course dans la façon dont le noyau gère splice() depuis un fichier vers un socket AF_ALG :
/usr/sbin/ipset) en lecture seule.authesn(hmac(sha256),cbc(aes)).MSG_MORE, indiquant au noyau d'attendre plus de données.splice() le contenu du fichier cible depuis un fd → pipe → socket AF_ALG.L'exploit répète cette opération pour chaque fenêtre de 4 octets jusqu'à ce que toutes les pages en cache du binaire cible soient écrasées avec une charge utile personnalisée.
Aucune permission d'écriture sur le fichier n'est nécessaire. Le fichier sur disque est inchangé — seul le cache de pages en mémoire est corrompu.
Les runtimes de conteneurs (containerd, CRI-O) utilisent des systèmes de fichiers overlay. Lorsque deux conteneurs partagent la même couche d'image, le noyau sert leurs lectures de fichiers depuis les mêmes pages du cache de pages.
Cette image PoC est construite FROM registry.k8s.io/kube-proxy:v1.35.2. Le DaemonSet kube-proxy sur chaque nœud Kubernetes utilise exactement la même couche de base. Par conséquent, /usr/sbin/ipset dans les deux conteneurs correspond au même ensemble de pages du cache de pages.
Lorsque le conteneur PoC non privilégié corrompt le cache de pages d'ipset, la corruption est immédiatement visible par le conteneur kube-proxy privilégié sur le même nœud — sans aucune communication inter-conteneurs.
kube-proxy s'exécute en tant que DaemonSet privilégié avec hostNetwork: true. Il invoque périodiquement /usr/sbin/ipset pour gérer les règles iptables/ipset. Lorsqu'il exécute ensuite ipset, le noyau charge les pages corrompues du cache de pages, exécutant la charge utile de l'attaquant avec tous les privilèges de kube-proxy :
La charge utile de cette PoC (payload/payload.c) monte simplement le système de fichiers racine de l'hôte et écrit un fichier marqueur dans /root/res comme preuve d'exécution de code au niveau du nœud.
┌──────────────────────────┐ ┌──────────────────────────┐
│ Conteneur PoC │ │ Conteneur kube-proxy │
│ (non privilégié) │ │ (privilégié) │
│ │ │ │
│ 1. Ouvre /usr/sbin/ipset│ │ │
│ (lecture seule) │ │ │
│ │ │ │
│ 2. Course AF_ALG splice │ │ │
│ corrompt le cache │ │ │
│ de pages │ │ │
│ │ │ │ │
└──────────┼───────────────┘ └──────────────────────────┘
│ │
▼ │
┌─────────────────────┐ │
│ Cache de pages du │ │
│ noyau │ │
│ /usr/sbin/ipset │◄────────────────────┘
│ (CORROMPU) │ 3. kube-proxy exécute ipset
│ contient les octets│ → charge les pages corrompues
│ de la charge utile │ → la charge utile s'exécute en root
│ de l'attaquant │ sur l'hôte
└─────────────────────┘
.
├── cmd/copyfail/main.go # Point d'entrée ; intègre la charge utile compilée
├── internal/
│ ├── exploit/
│ │ ├── exploit.go # Exploit principal : boucle de course AF_ALG splice
│ │ └── patch.go # Divise la charge utile en fenêtres de patch de 4 octets
│ └── alg/
│ └── alg.go # Abstraction du socket AF_ALG AEAD
├── payload/
│ ├── payload.c # Charge utile de validation (monte le fs hôte, écrit le marqueur)
│ └── nolibc/ # Mini libc du noyau pour charges utiles statiques sans dépendances
├── deploy/
│ └── poc.yaml # Manifeste de déploiement Kubernetes
├── Dockerfile # Construit FROM kube-proxy pour partager les couches d'images
├── Makefile # Orchestration de compilation
└── docs/ # Preuves de validation d'ACK (Alibaba Cloud)
x86_64-linux-gnu-gcc)imagePullPolicy: IfNotPresent (par défaut)# Compiler la charge utile + le binaire Go
make build
# Compiler l'image Docker
make docker-build
# Compiler et pousser vers GHCR
make docker-push IMAGE=ghcr.io/<vous>/copy-fail-poc TAG=latest
Pour les cibles arm64 :
make build CC=aarch64-linux-gnu-gcc GOARCH=arm64
kubectl apply -f deploy/poc.yaml
Le Deployment crée un seul pod non privilégié. Il :
/bin/copyfail -target /usr/sbin/ipset pour corrompre le cache de pages.Après que kube-proxy exécute ensuite ipset (cela se produit généralement en quelques secondes en raison de sa boucle de réconciliation, ou lors de son prochain redémarrage), vérifiez le nœud :
# SSH dans le nœud, ou utilisez un pod de débogage privilégié
cat /root/res
# Sortie attendue : [*] success
La présence de /root/res sur le système de fichiers de l'hôte prouve que le code fourni par l'attaquant s'est exécuté avec des privilèges au niveau du nœud — écrit depuis le contexte du conteneur privilégié de kube-proxy.
kubectl delete -f deploy/poc.yaml
# Sur le(s) nœud(s) affecté(s), supprimez le marqueur et redémarrez kube-proxy :
rm -f /root/res
systemctl restart kubelet # ou supprimez le pod kube-proxy pour forcer un re-tirage
kube-proxy est une cible idéale car il est :
privileged: true, hostNetwork: true.imagePullPolicy: IfNotPresent — une fois que l'image de l'attaquant est tirée et partage la même couche de base, les pages du répertoire inférieur overlay sont partagées.Tout DaemonSet privilégié dont l'image contient un binaire prévisible pourrait être ciblé de la même manière.
La charge utile par défaut (payload/payload.c) est un programme de validation uniquement qui écrit un fichier marqueur. Pour construire une charge utile personnalisée :
payload/payload.c. Le programme est compilé contre nolibc (la bibliothèque C minimale du noyau) pour un binaire statique sans dépendances.make payload pour compiler en croisé.//go:embed.tools/include/nolibc/)Le code d'exploit Go de ce dépôt est fourni tel quel à des fins de recherche.
La charge utile (payload/payload.c) est dérivée de copy-fail-c et est sous double licence LGPL-2.1-or-later OU MIT. Voir LICENSE-LGPL et LICENSE-MIT.