
CVE-2021-25741 POC на Zig
Образовательный эксплойт состояния гонки TOCTOU (Time Of Check, Time Of Use), которое лежит в основе CVE-2021-25741, уязвимости в kubelet Kubernetes.
Проект демонстрирует локально (без кластера K8s), как конкурентная подмена симлинка может обойти проверку пути между lstat() и open(), и доказывает, что openat2() с флагами RESOLVE_* является решением.
Когда ядро разрешает путь, например /a/b/c/file, оно делает это компонент за компонентом. На каждом шаге, если компонент является симлинком, ядро автоматически следует за ним. Это разрешение не атомарно — файловая система может измениться между двумя шагами.
kubelet Kubernetes в точности реализовывал этот уязвимый шаблон:
1. ПРОВЕРКА: lstat(subPath) → "это каталог, всё безопасно"
↕ ОКНО ГОНКИ — процесс в контейнере заменяет каталог на симлинк
2. ИСПОЛЬЗОВАНИЕ: 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/ → переключается между реальным каталогом и симлинком
Бесконечно быстро переключает target/ между двумя состояниями с помощью renameat2(RENAME_EXCHANGE):
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" → race win (симлинк был использован)"LEGIT" → race loss (это был реальный каталог)В защищённом режиме (--use-openat2) шаг 3 использует openat2 с RESOLVE_NO_SYMLINKS | RESOLVE_BENEATH. Если присутствует симлинк, ядро возвращает ELOOP вместо того, чтобы следовать за ним.
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 ядра.
| Инструмент | Версия | Зачем |
|---|---|---|
| Zig | 0.15.x | Компилятор + кросс-компиляция |
| Docker | любая | Для запуска Linux-бинарника на Mac |
| colima | любая | Среда выполнения Docker на macOS (или Docker Desktop) |
Бинарник компилируется в статически (musl libc) и работает на любом Linux без зависимостей.
Минимальное ядро:
renameat2(RENAME_EXCHANGE)openat2 с RESOLVE_* (требуется только для --use-openat2)Docker Desktop и colima на Mac используют ядро 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, и тем выше win rate:
# Без задержки — 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