
CVE-2021-25741 POC in Zig
Bildungs-Exploit der TOCTOU-Race-Condition (Time Of Check, Time Of Use), die die Ursache von CVE-2021-25741 ist, einer Schwachstelle im Kubernetes-Kubelet.
Das Projekt demonstriert lokal (ohne K8s-Cluster), wie ein gleichzeitiger Symlink-Swap eine Pfadprüfung zwischen lstat() und open() umgehen kann, und beweist, dass openat2() mit den Flags RESOLVE_* die Lösung ist.
Wenn der Kernel einen Pfad wie /a/b/c/file auflöst, tut er dies Komponente für Komponente. Bei jedem Schritt, wenn die Komponente ein Symlink ist, folgt der Kernel ihm automatisch. Diese Auflösung ist nicht atomar – das Dateisystem kann sich zwischen zwei Schritten ändern.
Der Kubernetes-Kubelet verwendete genau dieses anfällige Muster:
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
Zwischen der Überprüfung und dem Mount konnte ein bösartiger Prozess im Container das subPath durch einen Symlink auf / des Hosts ersetzen und so vollständigen Zugriff auf das Host-Dateisystem erhalten.
Der Syscall openat2 (Kernel 5.6+) löst den Pfad und öffnet die Datei atomar, mit Einschränkungen:
| Flag | Wirkung |
|---|---|
RESOLVE_NO_SYMLINKS | Weigert sich, Symlinks zu folgen → gibt ELOOP zurück |
RESOLVE_BENEATH | Weigert sich, über das Basisverzeichnis hinauszugehen → gibt EXDEV zurück |
RESOLVE_IN_ROOT | Behandelt den dirfd als Wurzel des Dateisystems |
RESOLVE_NO_XDEV | Weigert sich, Mountpunkte zu durchqueren |
Mit openat2 gibt es kein TOCTOU-Fenster: Wenn während der Auflösung ein Symlink erscheint, schlägt der Syscall sofort fehl.
Zwei Threads arbeiten zusammen, um das TOCTOU-Fenster auszunutzen:
workdir/
├── legit_dir/
│ └── secret.txt → contient "LEGIT"
├── symlink_target/
│ └── secret.txt → contient "PWNED"
└── target/ → swappé entre vrai dir et symlink
Läuft in einer ultraschnellen Schleife und tauscht target/ atomar zwischen zwei Zuständen über renameat2(RENAME_EXCHANGE) aus:
target/ ist ein echtes Verzeichnis (enthält secret.txt = "LEGIT")target/ ist ein Symlink auf symlink_target/ (enthält secret.txt = "PWNED")Ein einziger Syscall pro Tausch = maximales Race-Fenster.
Reproduziert das anfällige Muster des Kubelet:
fstatat("target", AT_SYMLINK_NOFOLLOW) — überprüft, dass es ein Verzeichnis istopenat(dirfd, "target/secret.txt", O_RDONLY) — öffnet die Dateiread() — liest den Inhalt"PWNED" → Race Win (dem Symlink wurde gefolgt)"LEGIT" → Race Loss (es war das echte Verzeichnis)Im geschützten Modus (--use-openat2) verwendet Schritt 3 openat2 mit RESOLVE_NO_SYMLINKS | RESOLVE_BENEATH. Wenn ein Symlink vorhanden ist, gibt der Kernel ELOOP zurück, anstatt ihm zu folgen.
src/
├── main.zig # Einstiegspunkt: parst CLI, erstellt Shared State, startet die
│ # Threads, misst die Zeit, zeigt Ergebnisse an
│
├── racer.zig # Racer-Thread: bereitet den Anfangszustand vor (target → symlink),
│ # schleift dann über renameat2(RENAME_EXCHANGE), um target/ und
│ # legit_dir/ kontinuierlich zu tauschen
│
├── victim.zig # Victim-Thread: schleift N Iterationen von fstatat → delay →
│ # openat/openat2 → read → vergleicht "LEGIT" vs "PWNED"
│
├── setup.zig # Erstellt die Testverzeichnisstruktur: workdir/, legit_dir/,
│ # symlink_target/, target/ mit den Sentinel-Dateien
│
├── syscalls.zig # Konstanten und Wrapper für die rohen Syscalls:
│ # - open_how-Struktur (Kernel-UAPI, 3×u64)
│ # - RESOLVE_*-Flags
│ # - RENAME_EXCHANGE (= 2)
│ # - openat2() über linux.syscall4(.openat2, ...)
│ # - rename_exchange() über linux.renameat2()
│ # - Helfer: is_err(), to_errno(), to_fd()
│
└── stats.zig # Lock-freie atomare Zähler (std.atomic.Value(u64)) für Wins,
# Losses, Errors, ELOOP + formatierte Ausgabe
Alle Syscalls werden direkt über std.os.linux.* aufgerufen (keine Wrapper std.fs oder std.posix). Der einzige Syscall ohne Wrapper in der Zig 0.15 Standardbibliothek ist openat2, den wir über linux.syscall4(.openat2, ...) mit einer manuell aus den UAPI-Headern des Kernels definierten open_how-Struktur aufrufen.
| Werkzeug | Version | Grund |
|---|---|---|
| Zig | 0.15.x | Compiler + Cross-Kompilierung |
| Docker | beliebig | Zum Ausführen des Linux-Binärs auf dem Mac |
| colima | beliebig | Docker-Laufzeit auf macOS (oder Docker Desktop) |
Das Binär wird statisch kompiliert (musl libc) und läuft auf jedem Linux ohne Abhängigkeiten.
Mindestkernel:
renameat2(RENAME_EXCHANGE)openat2 mit RESOLVE_* (nur für --use-openat2 erforderlich)Docker Desktop und colima auf dem Mac verwenden einen Kernel 6.x – alles wird unterstützt.
# ARM64 (Mac M1/M2/M3 → Docker colima / EC2 ARM)
zig build -Dtarget=aarch64-linux-musl -Doptimize=ReleaseSafe
# x86_64 (für x86-Knoten)
zig build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe
Das Binär befindet sich in zig-out/bin/race-exploit.
$ file zig-out/bin/race-exploit
ELF 64-bit LSB executable, ARM aarch64, statically linked
# Docker-Laufzeit starten (falls macOS)
colima start
# Exploit ausführen (Standard: anfälliger Modus)
docker run --rm -v $(pwd)/zig-out/bin:/app alpine /app/race-exploit --iterations 10000
# Anfälliger Modus (openat) — die Race funktioniert
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 10000
# Geschützter Modus (openat2) — die Race wird blockiert
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 10000 --use-openat2
Oder mit dem Skript, das beide ausführt:
./scripts/run_in_docker.sh --iterations 10000
Der Parameter --delay-us fügt eine Verzögerung zwischen lstat (Check) und openat (Use) hinzu. Je länger die Verzögerung, desto größer das TOCTOU-Fenster und desto höher die Win-Rate:
# Ohne Verzögerung — 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