
CVE-2021-25741 POC in Zig
Exploit didattico della race condition TOCTOU (Time Of Check, Time Of Use) all'origine di CVE-2021-25741, una vulnerabilità nel kubelet di Kubernetes.
Il progetto dimostra localmente (senza cluster K8s) come uno swap concorrente di symlink possa aggirare un controllo del percorso tra lstat() e open(), e prova che openat2() con i flag RESOLVE_* è la soluzione.
Quando il kernel risolve un percorso come /a/b/c/file, lo fa componente per componente. A ogni passo, se il componente è un symlink, il kernel lo segue automaticamente. Questa risoluzione non è atomica — il filesystem può cambiare tra due fasi.
Il kubelet di Kubernetes implementava esattamente questo pattern vulnerabile:
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
Tra il controllo e il mount, un processo malintenzionato nel container poteva sostituire il subPath con un symlink verso / dell'host, ottenendo così accesso completo al filesystem dell'host.
La syscall openat2 (kernel 5.6+) risolve il percorso e apre il file in modo atomico, con dei vincoli:
| Flag | Effetto |
|---|---|
RESOLVE_NO_SYMLINKS | Rifiuta di seguire qualsiasi symlink → restituisce ELOOP |
RESOLVE_BENEATH | Impedisce di uscire dalla directory di base → restituisce EXDEV |
RESOLVE_IN_ROOT | Tratta il dirfd come la radice del filesystem |
RESOLVE_NO_XDEV | Impedisce di attraversare i punti di mount |
Con openat2 non c'è finestra TOCTOU: se un symlink compare durante la risoluzione, la syscall fallisce immediatamente.
Due thread cooperano per sfruttare la finestra TOCTOU:
workdir/
├── legit_dir/
│ └── secret.txt → contient "LEGIT"
├── symlink_target/
│ └── secret.txt → contient "PWNED"
└── target/ → swappé entre vrai dir et symlink
Esegue un loop ultra-veloce e scambia atomicamente target/ tra due stati tramite renameat2(RENAME_EXCHANGE):
target/ è una vera directory (contiene secret.txt = "LEGIT")target/ è un symlink verso symlink_target/ (contiene secret.txt = "PWNED")Una sola syscall per swap = finestra di race massima.
Riproduce il pattern vulnerabile del kubelet:
fstatat("target", AT_SYMLINK_NOFOLLOW) — verifica che sia una directoryopenat(dirfd, "target/secret.txt", O_RDONLY) — apre il fileread() — legge il contenuto"PWNED" → race win (il symlink è stato seguito)"LEGIT" → race loss (era davvero la directory reale)In modalità protetta (--use-openat2), il passo 3 usa openat2 con RESOLVE_NO_SYMLINKS | RESOLVE_BENEATH. Se è presente un symlink, il kernel restituisce ELOOP invece di seguirlo.
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é
Tutte le syscall sono chiamate direttamente via std.os.linux.* (niente wrapper std.fs o std.posix). L'unica syscall senza wrapper nella stdlib di Zig 0.15 è openat2, che viene chiamata tramite linux.syscall4(.openat2, ...) con una struct open_how definita manualmente dagli header UAPI del kernel.
| Strumento | Versione | Perché |
|---|---|---|
| Zig | 0.15.x | Compilatore + cross-compilazione |
| Docker | Qualsiasi | Per eseguire il binario Linux su Mac |
| colima | Qualsiasi | Runtime Docker su macOS (o Docker Desktop) |
Il binario è compilato in statico (musl libc) e gira su qualsiasi Linux senza dipendenze.
Kernel minimo:
renameat2(RENAME_EXCHANGE)openat2 con RESOLVE_* (necessario solo per --use-openat2)Docker Desktop e colima su Mac usano un kernel 6.x: tutto è supportato.
# 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
Il binario si trova in 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
Oppure con lo script che lancia entrambi:
./scripts/run_in_docker.sh --iterations 10000
Il parametro --delay-us aggiunge un ritardo tra lstat (check) e openat (use). Più il ritardo è lungo, più la finestra TOCTOU è ampia e più il win rate aumenta:
# 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