
POC de CVE-2021-25741 en Zig
Exploit educativo de la condición de carrera TOCTOU (Time Of Check, Time Of Use) que está en el origen de CVE-2021-25741, una vulnerabilidad en el kubelet de Kubernetes.
El proyecto demuestra localmente (sin clúster K8s) cómo un intercambio concurrente de symlink puede saltarse una verificación de ruta entre lstat() y open(), y prueba que openat2() con los flags RESOLVE_* es la solución.
Cuando el kernel resuelve una ruta como /a/b/c/file, lo hace componente por componente. En cada paso, si el componente es un symlink, el kernel lo sigue automáticamente. Esta resolución no es atómica — el sistema de archivos puede cambiar entre dos pasos.
El kubelet de Kubernetes hacía exactamente este patrón vulnerable:
1. CHECK : lstat(subPath) → "es un directorio, es seguro"
↕ VENTANA DE RACE — un proceso en el contenedor cambia el directorio por un symlink
2. USE : mount(subPath) → sigue el symlink, monta el sistema de archivos host
Entre la verificación y el mount, un proceso malicioso en el contenedor podía reemplazar el subPath por un symlink hacia / del host, obteniendo así acceso completo al sistema de archivos host.
La syscall openat2 (kernel 5.6+) resuelve la ruta y abre el archivo de manera atómica, con restricciones:
| Flag | Efecto |
|---|---|
RESOLVE_NO_SYMLINKS | Rechaza seguir cualquier symlink → devuelve ELOOP |
RESOLVE_BENEATH | Rechaza salir del directorio base → devuelve EXDEV |
RESOLVE_IN_ROOT | Trata el dirfd como la raíz del sistema de archivos |
RESOLVE_NO_XDEV | Rechaza atravesar puntos de montaje |
Con openat2, no hay ventana TOCTOU: si aparece un symlink durante la resolución, la syscall falla inmediatamente.
Dos hilos cooperan para explotar la ventana TOCTOU:
workdir/
├── legit_dir/
│ └── secret.txt → contiene "LEGIT"
├── symlink_target/
│ └── secret.txt → contiene "PWNED"
└── target/ → intercambiado entre directorio real y symlink
Corre en un bucle ultrarrápido e intercambia atómicamente target/ entre dos estados mediante renameat2(RENAME_EXCHANGE):
target/ es un directorio real (contiene secret.txt = "LEGIT")target/ es un symlink hacia symlink_target/ (contiene secret.txt = "PWNED")Una sola syscall por intercambio = ventana de race máxima.
Reproduce el patrón vulnerable del kubelet:
fstatat("target", AT_SYMLINK_NOFOLLOW) — verifica que es un directorioopenat(dirfd, "target/secret.txt", O_RDONLY) — abre el archivoread() — lee el contenido"PWNED" → race win (el symlink fue seguido)"LEGIT" → race loss (era el directorio real)En modo protegido (--use-openat2), el paso 3 usa openat2 con RESOLVE_NO_SYMLINKS | RESOLVE_BENEATH. Si hay un symlink, el kernel devuelve ELOOP en lugar de seguirlo.
src/
├── main.zig # Punto de entrada: parsea CLI, crea el estado compartido, lanza los
│ # hilos, mide el tiempo, muestra los resultados
│
├── racer.zig # Hilo racer: prepara el estado inicial (target → symlink),
│ # luego buclea con renameat2(RENAME_EXCHANGE) para intercambiar
│ # target/ y legit_dir/ continuamente
│
├── victim.zig # Hilo victim: buclea N iteraciones de fstatat → delay →
│ # openat/openat2 → read → compara "LEGIT" vs "PWNED"
│
├── setup.zig # Crea el árbol de prueba: workdir/, legit_dir/,
│ # symlink_target/, target/ con los archivos centinela
│
├── syscalls.zig # Constantes y wrappers para syscalls raw:
│ # - struct open_how (UAPI del kernel, 3×u64)
│ # - flags RESOLVE_*
│ # - RENAME_EXCHANGE (= 2)
│ # - openat2() mediante linux.syscall4(.openat2, ...)
│ # - rename_exchange() mediante linux.renameat2()
│ # - Helpers: is_err(), to_errno(), to_fd()
│
└── stats.zig # Contadores atómicos lock-free (std.atomic.Value(u64))
# para wins, losses, errors, eloop + salida formateada
Todas las syscalls se invocan mediante std.os.linux.* directamente (sin wrappers de std.fs o std.posix). La única syscall sin wrapper en la stdlib de Zig 0.15 es openat2, que se llama mediante linux.syscall4(.openat2, ...) con una estructura open_how definida manualmente a partir de los headers UAPI del kernel.
| Herramienta | Versión | Por qué |
|---|---|---|
| Zig | 0.15.x | Compilador + compilación cruzada |
| Docker | cualquiera | Para ejecutar el binario Linux en Mac |
| colima | cualquiera | Runtime Docker en macOS (o Docker Desktop) |
El binario se compila en estático (musl libc) y funciona en cualquier Linux sin dependencias.
Kernel mínimo:
renameat2(RENAME_EXCHANGE)openat2 con RESOLVE_* (necesario solo para --use-openat2)Docker Desktop y colima en Mac usan un kernel 6.x — todo está soportado.
# ARM64 (Mac M1/M2/M3 → Docker colima / EC2 ARM)
zig build -Dtarget=aarch64-linux-musl -Doptimize=ReleaseSafe
# x86_64 (para nodos x86)
zig build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe
El binario está en zig-out/bin/race-exploit.
$ file zig-out/bin/race-exploit
ELF 64-bit LSB executable, ARM aarch64, statically linked
# Iniciar el runtime Docker (si macOS)
colima start
# Ejecutar el exploit (modo vulnerable por defecto)
docker run --rm -v $(pwd)/zig-out/bin:/app alpine /app/race-exploit --iterations 10000
# Modo vulnerable (openat) — la race funciona
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 10000
# Modo protegido (openat2) — la race está bloqueada
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 10000 --use-openat2
O con el script que lanza ambos:
./scripts/run_in_docker.sh --iterations 10000
El parámetro --delay-us añade un retardo entre el lstat (check) y el openat (use). Cuanto más largo sea el retardo, más ancha es la ventana TOCTOU, y mayor es el win rate:
# Sin retardo — 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