
إثبات المفهوم لـ 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 | التأثير |
|---|---|
RESOLVE_NO_SYMLINKS | يرفض اتباع أي رابط رمزي → يعيد ELOOP |
RESOLVE_BENEATH | يرفض الخروج من المجلد الأساسي → يعيد EXDEV |
RESOLVE_IN_ROOT | يتعامل مع dirfd كجذر لنظام الملفات |
RESOLVE_NO_XDEV | يرفض عبور نقاط التركيب |
مع 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 | المترجم + الترجمة المتقاطعة |
| Docker | أي إصدار | لتنفيذ ثنائي Linux على Mac |
| colima | أي إصدار | بيئة تشغيل Docker على macOS (أو Docker Desktop) |
تُجمَّع الثنائي بشكل ثابت (مكتبة 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، وما إلى ذلك)، مما يجعل السباق موثوقًا جدًا في الظروف الحقيقية.