
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+)은 경로를 해석하고 파일을 원자적으로 열며, 제약 조건을 적용할 수 있습니다:
| 플래그 | 효과 |
|---|---|
RESOLVE_NO_SYMLINKS | 모든 심볼릭 링크 추적 거부 → ELOOP 반환 |
RESOLVE_BENEATH | 기본 디렉터리 위로 올라가지 못하게 함 → EXDEV 반환 |
RESOLVE_IN_ROOT | dirfd를 파일 시스템 루트로 처리 |
RESOLVE_NO_XDEV | 마운트 지점 통과 거부 |
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 | 아무거나 | 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 (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가 있는 볼륨의 바인드 마운트를 준비하는 동안 경쟁 조건을 악용해야 합니다: