
CVE-2021-25741 POC in Zig
Kubernetes kubelet の脆弱性である CVE-2021-25741 の原因となった TOCTOU(Time Of Check, Time Of Use)レースコンディションの教育的エクスプロイトです。
このプロジェクトは、ローカル(K8s クラスタなし)で、lstat() と open() の間のパス検証を、同時実行されるシンボリックリンクのスワップがどのように回避できるかを実証し、RESOLVE_* フラグを備えた openat2() が解決策であることを示します。
カーネルが /a/b/c/file のようなパスを解決するとき、コンポーネントごとに解決します。各ステップで、コンポーネントがシンボリックリンクの場合、カーネルは自動的にそれを辿ります。この解決はアトミックではありません — 2 つのステップの間にファイルシステムが変更される可能性があります。
Kubernetes kubelet はまさにこの脆弱なパターンを使用していました:
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
検証とマウントの間に、コンテナ内の悪意のあるプロセスが subPath をホストの / へのシンボリックリンクに置き換えることができ、ホストのファイルシステムへの完全なアクセスを取得できました。
openat2 システムコール(カーネル 5.6+)は、パスを解決し、ファイルをアトミックに開きます。制約は次のとおりです:
| Flag | 効果 |
|---|---|
RESOLVE_NO_SYMLINKS | すべてのシンボリックリンクの追跡を拒否 → ELOOP を返す |
RESOLVE_BENEATH | ベースディレクトリの外に出ることを拒否 → EXDEV を返す |
RESOLVE_IN_ROOT | dirfd をファイルシステムのルートとして扱う |
RESOLVE_NO_XDEV | マウントポイントの横断を拒否 |
openat2 ではTOCTOU ウィンドウはありません: 解決中にシンボリックリンクが現れた場合、システムコールは即座に失敗します。
2 つのスレッドが連携して TOCTOU ウィンドウを悪用します:
workdir/
├── legit_dir/
│ └── secret.txt → contient "LEGIT"
├── symlink_target/
│ └── secret.txt → contient "PWNED"
└── target/ → swappé entre vrai dir et symlink
超高速ループで回転し、renameat2(RENAME_EXCHANGE) を介して target/ を 2 つの状態間でアトミックにスワップします:
target/ は実際のディレクトリです(secret.txt = "LEGIT" を含む)target/ は symlink_target/ へのシンボリックリンクです(secret.txt = "PWNED" を含む)スワップごとに 1 つのシステムコール = 最大のレースウィンドウ。
kubelet の脆弱なパターンを再現します:
fstatat("target", AT_SYMLINK_NOFOLLOW) — ディレクトリであることを確認openat(dirfd, "target/secret.txt", O_RDONLY) — ファイルを開くread() — 内容を読み取る"PWNED" の場合 → レース勝利(シンボリックリンクが辿られた)"LEGIT" の場合 → レース負け(実際のディレクトリだった)保護モード(--use-openat2)では、ステップ 3 は RESOLVE_NO_SYMLINKS | RESOLVE_BENEATH を指定した openat2 を使用します。シンボリックリンクが存在する場合、カーネルはそれを辿らずに ELOOP を返します。
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é
すべてのシステムコールは、std.os.linux.* を介して直接呼び出されます(std.fs や std.posix のラッパーは使用しません)。Zig 0.15 の stdlib にラッパーがない唯一のシステムコールは openat2 で、カーネルの UAPI ヘッダーから手動で定義した open_how 構造体を linux.syscall4(.openat2, ...) で呼び出します。
| ツール | バージョン | 理由 |
|---|---|---|
| Zig | 0.15.x | コンパイラ + クロスコンパイル |
| Docker | 任意 | Mac で Linux バイナリを実行するため |
| colima | 任意 | macOS 上の Docker ランタイム(または Docker Desktop) |
バイナリは静的(musl libc)にコンパイルされており、依存関係なしで任意の Linux 上で動作します。
最小カーネル:
renameat2(RENAME_EXCHANGE) 用RESOLVE_* 付きの openat2 用(--use-openat2 でのみ必要)Mac の Docker Desktop と colima は 6.x カーネルを使用するため、すべてサポートされています。
# 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
バイナリは zig-out/bin/race-exploit にあります。
$ file zig-out/bin/race-exploit
ELF 64-bit LSB executable, ARM aarch64, statically linked
# 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
# 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
または、両方を起動するスクリプトを使用します:
./scripts/run_in_docker.sh --iterations 10000
--delay-us パラメータは、lstat(チェック)と openat(使用)の間に遅延を追加します。遅延が長いほど TOCTOU ウィンドウが広がり、勝率が上がります:
# 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
実際の kubelet では、subPath の検証とバインドマウントの間の遅延は数ミリ秒程度(API 呼び出し、マウント名前空間の準備など)であり、実際の条件ではレースが非常に信頼性の高いものになります。
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
エクスプロイトは動作に Kubernetes を必要としません — これは基本的な Linux のレースコンディションです。ただし、実際のノードで実行して、kubelet と同じ条件下でテストすることができます。
# 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
バイナリを組み込んで実行する Pod を作成します: