
CVE-2026-31431-killed page-cache эксплойт — выполнение кода в контейнерах, совместно использующих один и тот же слой образа
Межконтейнерный эксплойт уязвимости AF_ALG aead — переход из одного скомпрометированного контейнера во все соседние контейнеры, использующие тот же слой образа libc.so.6.
Это примитив эскейпа: он запускается изнутри непривилегированного контейнера, который атакующий уже скомпрометировал, и использует 4-байтовую ошибку произвольной записи при ротации ESN в authencesn из AF_ALG (CVE-2026-31431), чтобы внедрить постоянный хук read() в страницы page-cache файла libc.so.6. Поскольку в Docker / containerd файлы нижних слоёв overlayfs опираются на общие inode, эти страницы видны каждому соседнему контейнеру, созданному из того же образа: хук срабатывает и в их процессах, и атакующий получает выполнение команд в каждом из них.
victim) на хосте, где работают другие контейнеры (siblings) из , что и .victimvictim работает в стандартной конфигурации Docker/k8s: непривилегированный uid внутри пользовательского пространства имён контейнера, профиль seccomp по умолчанию, профиль AppArmor по умолчанию, без специальных capabilities, без bind-монтирований хоста.victim имеет только:
/usr/lib/x86_64-linux-gnu/libc.so.6 или туда, куда её устанавливает дистрибутив)socket(AF_ALG, ...)splice / vmsplicechmod +x (например, /tmp)algif_aead + authencesn до исправления-отката в апстриме).На этом всё. Никаких специальных CAP_*, никакого доступа к файловой системе хоста. Атакующий помещает самодостаточный статически слинкованный бинарник в контейнер, запускает его — и повреждение page-cache, а следовательно и хук, становится видимым для всех соседних контейнеров.
Идентичность страниц page-cache. В контейнере на overlayfs путь /usr/lib/.../libc.so.6 обслуживается ext4-inode нижнего слоя образа. Каждый контейнер, запущенный из того же образа, разделяет этот нижележащий inode, а page-cache ядра индексируется по нижележащему inode — не по overlay и не по пространству имён. Поэтому одна 4-байтовая запись в страницу page-cache видна всем процессам соседних контейнеров, у которых эта страница отображена через mmap.
Уязвимость AF_ALG aead превращает одну такую запись во множество. algif_aead сцепляет пользовательский RX iovec с хвостовыми authsize байтами сцепленного TX SGL, а при ротации ESN в authencesn 4 байта поля seq_high из AAD размещаются в dst[assoclen + cryptlen] — то есть в первом байте этого сцепленного чужого хвоста. Сцепленная страница — это страница page-cache файла, к которому атакующий имеет только доступ на чтение, но шифр всё равно копирует в неё байты, не помечая страницы как dirty. (Подробнее о механике — в crypto/algif_aead.c и crypto/authencesn.c.)
Инициализация вызываемого примитива. Первым делом page_inject разворачивает зону A — закодированную на asm повторную реализацию той же схемы AF_ALG (write_cache.asm), размещённую в кармане секции .text libc. Это превращает 4-байтовую запись в обычный call из любого будущего кода хука — без необходимости настраивать сокет для каждого вызова.
Установка хука. Затем инжектор записывает зону C (zone_c.asm) в карман секции .text libc и патчит первые 7–12 байт read() переходом E9 disp32 на неё. Вытесняемые байты пролога точно эмулируются в быстром пути зоны C (распознаются три разных пролога glibc — см. «Обработка пролога read()» ниже). Теперь хук активен в page-cache libc.
Распространение хука. В каждом соседнем контейнере процессы постоянно вызывают read() (демоны журналирования, healthcheck-скрипты, cat /etc/hostname — что угодно). При первом таком вызове внутри соседнего контейнера перехваченный пролог прыгает в зону C, которая:
stat("/") для корневого inode контейнера (стабильный идентификатор для каждого пространства имён) и использует его как ключ слота контейнера;fork()-ает долгоживущего дочернего процесса командного цикла, который опрашивает область CMD на предмет команд;read()+N, так что вызывающий код ничего не замечает.
Исходный процесс соседнего контейнера продолжает работать. С этого момента у атакующего есть демон внутри этого контейнера.Канал команд. Атакующий использует тот же бинарник page_inject в режиме --shell, чтобы записывать команды в область CMD региона слотов. Дочерний процесс хука каждого зарегистрированного соседнего контейнера опрашивает её, запускает /bin/sh -c <cmd>, захватывает stdout/stderr в область OUTPUT, сигнализирует о завершении и возвращается к опросу. Shell показывает вывод. Поскольку каждая запись в CMD/OUTPUT тоже идёт через примитив уязвимости, никаких особых привилегий не требуется.
Снятие хука. По завершении работы unhook восстанавливает исходные байты пролога read() и обнуляет таблицу слотов; дочерние процессы хуков при следующей итерации видят пустой слот и завершаются сами. Сами изменения page-cache чистые (ядро никогда не помечало изменённые страницы как dirty), поэтому как только все контейнеры с отображённой через mmap libc остановлены, drop_caches полностью восстанавливает кэш — на диске не остаётся никаких следов.
Инжектор собирается вне контейнера-жертвы — как правило, на собственной машине разработки атакующего, — потому что в большинстве продакшен-образов контейнеров нет компилятора. Достаточно стандартного окружения разработки Linux x86_64 с gcc (с поддержкой статической линковки -static) и nasm.
make # assembles .asm sources via gen_arrays.sh, links static page_inject
make shellcode # also produces inspectable .bin flat binaries
make clean # removes generated files and the binary
Результат сборки — единый статически слинкованный ELF (./page_inject), который запускается на любом современном ядре Linux x86_64.
Получив shell в victim, атакующий загружает бинарник в доступный для записи каталог (обычно /tmp):
# inside the compromised container, attacker session
victim$ ./page_inject
Без аргументов page_inject использует путь по умолчанию /usr/lib/x86_64-linux-gnu/libc.so.6 (расположение Debian/Ubuntu после объединения каталогов). В других дистрибутивах libc лежит по другому пути; можно либо передать его явно, либо использовать --root /, чтобы просканировать встроенную таблицу путей от корня контейнера:
# Fedora / Rocky / CentOS
victim$ ./page_inject /usr/lib64/libc.so.6
# Arch
victim$ ./page_inject /usr/lib/libc.so.6
# Auto-detect, regardless of distro:
victim$ ./page_inject --root /
Оба варианта запуска делают одно и то же: разбирают ELF внутриконтейнерной libc, устанавливают хук в её page-cache, ~30 секунд наблюдают за таблицей слотов, пока регистрируются соседние контейнеры, и в качестве проверки работоспособности однократно выполняют id в первом зарегистрировавшемся соседнем контейнере.
После начальной установки можно войти в интерактивную командную оболочку и управлять любым зарегистрированным соседним контейнером:
victim$ ./page_inject --shell --no-bootstrap
=== page-cache shell ===
Containers (3):
[0] 0x0018598d <- target
[1] 0x001859ab
[2] 0x001859cd
inject:0018598d> exec id
uid=0(root) gid=0(root) groups=0(root)
inject:0018598d> target 0x001859ab
inject:001859ab> exec hostname
1ccd66abee9d
inject:001859ab> exec cat /etc/shadow
root:$6$.....
inject:001859ab> unhook
... read() prologue restored, slot table zeroed ...
unhook одним действием убирает хук из всех соседних контейнеров и позволяет дочерним процессам хука завершиться самостоятельно.
Usage: page_inject [OPTIONS] [LIBC_PATH]
Options:
--root <prefix> Auto-resolve libc.so.6 under <prefix> using the
built-in fixed-path lookup table. Inside the
victim container that's normally --root / .
--shell [0xKEY] Drop into interactive command shell after
injection. Optional KEY pre-selects the target.
--no-bootstrap Skip injection (shell-only; hook must already
be live in the page cache).
--timeout SEC Slot monitoring timeout in --shell mode
(default 30 s).
--help, -h Show help.
Default libc (when no --root and no LIBC_PATH given):
/usr/lib/x86_64-linux-gnu/libc.so.6
В разных сборках glibc между исполняемым LOAD-сегментом и следующим LOAD-сегментом, доступным только на чтение, остаётся разный объём кармана в .text. page_inject в момент инжекции выбирает одну из двух компоновок:
Путь A — только libc (по умолчанию). И зона C, и зона A располагаются в кармане .text libc. Таблица слотов + области CMD + OUTPUT располагаются в секции .hash libc — это унаследованные SysV-хеш-данные, которые ld.so во время выполнения не читает, поскольку вместо них использует .gnu.hash. Когда .hash отсутствует (современный тулчейн Arch), page_inject вместо этого вырезает область слотов из хвоста .eh_frame_hdr, предварительно уменьшив поле fde_count, чтобы unwinder больше не считал освобождённые байты частью индекса бинарного поиска FDE (для любого IP, чей FDE ранее находился в усечённом диапазоне, unwinder прозрачно переходит к линейному сканированию .eh_frame — поведение, предписанное стандартом LSB).
Путь B — трамплин в libc + payload в ld.so. Некоторые сборки glibc уменьшают карман libc до размера меньше необходимого для полного payload зоны C + зоны A (Ubuntu 24.04 / glibc 2.39 поставляется с карманом в 711 байт). В этом случае page_inject записывает в карман libc 36-байтовый трамплин: он выполняет проверку .bss-ключа в быстром пути внутри libc, а в медленном пути вычисляет базовый адрес ld.so во время выполнения по GOT-слоту libc для _rtld_global (символ на стороне ld.so, который каждая glibc импортирует приватно) и прыгает в вариант зоны C с базовым регистром, расположенный в кармане .text ld.so. Таблица слотов + CMD + OUTPUT + ключ .bss остаются в libc; зона C на стороне ld.so обращается к ним через rbp + offset, после того как трамплин инициализирует rbp = libc_base.
Если не подходит ни одна компоновка, page_inject чисто отказывается от работы, ничего не записывая в libc или ld.so — ни на диск, ни в page-cache.
read()Разные версии glibc порождают разные начальные последовательности в read(). Инжектор распознаёт каждую из них, считывает байты, которые вытесняет хук, и эмулирует их в быстром пути зоны C, чтобы однопоточный вызов read() корректно продолжался с read()+N:
| Диапазон glibc | Пролог (после опционального endbr64) | Примечания |
|---|---|---|
| 2.36 / 2.39 | cmpb $0x0, __libc_single_threaded(%rip) | 7 байт; эмулируемый cmpb выставляет ZF для исходного jne .Lthreaded. |
| 2.43 | push rbp; movsxd rdi,edi; xor r9d,r9d | 7 байт; эмулируется побайтово. |
| 2.31 / 2.35 | mov eax, fs:[0x18] | 8 байт; эмулируется побайтово (обращение fs:[disp32] абсолютное, а не RIP-относительное, поэтому побайтовое копирование точно). |
Слот эмуляции в быстром пути зоны C рассчитан на самый длинный из известных прологов (8 байт) плюс 5-байтовый rel32-переход; более короткие прологи заполняют последний байт слота NOP-заполнителем, чтобы общая длина слота оставалась постоянной.
page_inject/
page_inject.c Main injector: ELF parsing, vuln primitive,
dual-path layout selection, inject + unhook.
zone_c.asm Path-A hook dispatcher shellcode.
zone_c_ld.asm Path-B hook dispatcher (rbp-base variant).
trampoline.asm Path-B 36-byte libc-side stub.
write_cache.asm Zone A (vuln write primitive shellcode).
gen_arrays.sh Assemble .asm -> asm_bytecode.c.
asm_bytecode.c [generated] shellcode byte arrays.
Makefile Build system.
Эксплойт был проверен по всей цепочке (end-to-end) на следующих дистрибутивах, упакованных в снапшоты контейнеров. Для каждой записи page_inject внедрялся изнутри одного контейнера, и его хук срабатывал в соседнем контейнере, запущенном из того же образа; команды корректно выполнялись через канал page-cache; а unhook чисто восстанавливал состояние страниц libc.
| Образ | glibc | Путь инжекции | Пролог read() | Область слотов |
|---|---|---|---|---|
debian:bookworm | 2.36 | A | cmpb | .hash |
ubuntu:24.04 | 2.39 | B | cmpb | .hash (на стороне libc, адресация через rbp из ld.so) |
ubuntu:22.04 | 2.35 | A | TLS-fs | .hash |
fedora:40 | 2.39 | A | cmpb | .hash |
archlinux:latest | 2.43 | A | push-rbp | .eh_frame_hdr (усечённый хвост) |
page_inject намеренно слинкован статически, чтобы собственный процесс атакующего не затрагивался установленным им хуком.page_inject распознаёт уже «захваченную» libc (E9 + NOP в прологе read()) и отказывается от повторной инжекции. Если вы находитесь в тестовом окружении и ваш page-cache застрял в таком состоянии, остановите все контейнеры, использующие этот образ, и выполните drop_caches для сброса.