
POC CVE-2021-25741 en 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 :
| 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 |
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 |
| Docker | quelconque | Pour exécuter le binaire Linux sur Mac |
| colima | quelconque | Runtime Docker sur macOS (ou Docker Desktop) |
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