
CVE-2021-25741 POC em Zig
Exploit educacional da condição de corrida TOCTOU (Time Of Check, Time Of Use) que está na origem de CVE-2021-25741, uma vulnerabilidade no kubelet do Kubernetes.
O projeto demonstra localmente (sem cluster K8s) como uma troca concorrente de symlink pode contornar uma verificação de caminho entre lstat() e open(), e prova que openat2() com os flags RESOLVE_* é a solução.
Quando o kernel resolve um caminho como /a/b/c/file, ele o faz componente por componente. A cada etapa, se o componente for um symlink, o kernel o segue automaticamente. Essa resolução não é atômica — o filesystem pode mudar entre duas etapas.
O kubelet do Kubernetes fazia exatamente esse padrão vulnerável:
1. CHECK : lstat(subPath) → "é um diretório, é seguro"
↕ JANELA DE CORRIDA — um processo no container troca o diretório por um symlink
2. USE : mount(subPath) → segue o symlink, monta o filesystem do host
Entre a verificação e o mount, um processo malicioso no container podia substituir o subPath por um symlink para / do host, obtendo assim acesso completo ao filesystem do host.
O syscall openat2 (kernel 5.6+) resolve o caminho e abre o arquivo de forma atômica, com restrições:
| Flag | Efeito |
|---|---|
RESOLVE_NO_SYMLINKS | Recusa seguir qualquer symlink → retorna ELOOP |
RESOLVE_BENEATH | Recusa sair do diretório base → retorna EXDEV |
RESOLVE_IN_ROOT | Trata o dirfd como a raiz do filesystem |
RESOLVE_NO_XDEV | Recusa atravessar pontos de montagem |
Com openat2, não existe janela TOCTOU: se um symlink aparecer durante a resolução, o syscall falha imediatamente.
Duas threads cooperam para explorar a janela TOCTOU:
workdir/
├── legit_dir/
│ └── secret.txt → contém "LEGIT"
├── symlink_target/
│ └── secret.txt → contém "PWNED"
└── target/ → alternado entre dir real e symlink
Fica em loop ultrarrápido e alterna atomicamente target/ entre dois estados via renameat2(RENAME_EXCHANGE):
target/ é um diretório real (contém secret.txt = "LEGIT")target/ é um symlink para symlink_target/ (contém secret.txt = "PWNED")Um único syscall por troca = janela de corrida máxima.
Reproduz o padrão vulnerável do kubelet:
fstatat("target", AT_SYMLINK_NOFOLLOW) — verifica que é um diretórioopenat(dirfd, "target/secret.txt", O_RDONLY) — abre o arquivoread() — lê o conteúdo"PWNED" → race win (o symlink foi seguido)"LEGIT" → race loss (era mesmo o diretório real)Em modo protegido (--use-openat2), o passo 3 usa openat2 com RESOLVE_NO_SYMLINKS | RESOLVE_BENEATH. Se um symlink estiver presente, o kernel retorna ELOOP em vez de segui-lo.
src/
├── main.zig # Ponto de entrada: faz parse da CLI, cria o shared state, spawna
│ # as threads, mede o tempo, exibe os resultados
│
├── racer.zig # Thread racer: prepara o estado inicial (target → symlink),
│ # depois fica em loop com renameat2(RENAME_EXCHANGE) para alternar
│ # target/ e legit_dir/ continuamente
│
├── victim.zig # Thread victim: faz loop de N iterações de fstatat → delay →
│ # openat/openat2 → read → compara "LEGIT" vs "PWNED"
│
├── setup.zig # Cria a árvore de teste: workdir/, legit_dir/,
│ # symlink_target/, target/ com os arquivos sentinela
│
├── syscalls.zig # Constantes e wrappers para os syscalls raw:
│ # - struct open_how (UAPI do kernel, 3×u64)
│ # - flags RESOLVE_*
│ # - RENAME_EXCHANGE (= 2)
│ # - openat2() via linux.syscall4(.openat2, ...)
│ # - rename_exchange() via 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 + exibição formatada
Todos os syscalls são chamados via std.os.linux.* diretamente (sem wrappers std.fs ou std.posix). O único syscall sem wrapper na stdlib do Zig 0.15 é openat2, que é chamado via linux.syscall4(.openat2, ...) com uma struct open_how definida manualmente a partir dos headers UAPI do kernel.
| Ferramenta | Versão | Porquê |
|---|---|---|
| Zig | 0.15.x | Compilador + cross-compilação |
| Docker | qualquer | Para executar o binário Linux no Mac |
| colima | qualquer | Runtime Docker no macOS (ou Docker Desktop) |
O binário é compilado como estático (musl libc) e roda em qualquer Linux sem dependências.
Kernel mínimo:
renameat2(RENAME_EXCHANGE)openat2 com RESOLVE_* (necessário apenas para --use-openat2)Docker Desktop e colima no Mac usam kernel 6.x — tudo é suportado.
# ARM64 (Mac M1/M2/M3 → Docker colima / EC2 ARM)
zig build -Dtarget=aarch64-linux-musl -Doptimize=ReleaseSafe
# x86_64 (para nodes x86)
zig build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe
O binário está em zig-out/bin/race-exploit.
$ file zig-out/bin/race-exploit
ELF 64-bit LSB executable, ARM aarch64, statically linked
# Iniciar o runtime Docker (se macOS)
colima start
# Executar o exploit (modo vulnerável por padrão)
docker run --rm -v $(pwd)/zig-out/bin:/app alpine /app/race-exploit --iterations 10000
# Modo vulnerável (openat) — a corrida funciona
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 10000
# Modo protegido (openat2) — a corrida é bloqueada
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 10000 --use-openat2
Ou com o script que executa os dois:
./scripts/run_in_docker.sh --iterations 10000
O parâmetro --delay-us adiciona um atraso entre o lstat (check) e o openat (use). Quanto maior o atraso, maior a janela TOCTOU e maior a taxa de vitória:
# Sem atraso — 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, o racer alterna muito mais rápido)
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 10000 --delay-us 1000