
CVE-2026-31431-killed page-cache exploit — execução de código em containers que compartilham a mesma camada de imagem
Exploit AF_ALG aead de escape entre contêineres -- pivot de um contêiner comprometido para todos os contêineres irmãos que compartilham a mesma camada de imagem libc.so.6.
Esta é uma primitiva de escape: executa de dentro de um contêiner sem privilégios que o atacante já comprometeu, e usa o bug de escrita arbitrária de 4 bytes por rotação ESN authencesn do AF_ALG (CVE-2026-31431) para plantar um hook persistente de read() nas páginas do page-cache de libc.so.6. Como o Docker / containerd fazem o backing das camadas inferiores do overlayfs com inodes compartilhados, essas páginas são visíveis para todos os contêineres irmãos instanciados a partir da mesma imagem -- o hook dispara também nos processos deles, e o atacante obtém execução de comandos dentro de cada um.
victim)
em um host que executa outros contêineres (siblings) a partir da mesma
imagem que victim.victim executa com a postura padrão do Docker/k8s: uid sem privilégios
dentro do user namespace do contêiner, perfil seccomp padrão,
perfil AppArmor padrão, sem capabilities especiais, sem bind mounts do host.victim tem apenas:
/usr/lib/x86_64-linux-gnu/libc.so.6
ou onde a distro a instala)socket(AF_ALG, ...)splice / vmsplicechmod +x (ex.: /tmp)algif_aead + authencesn anterior ao fix de revert upstream).Isto é tudo. Sem CAP_* especial, sem acesso ao filesystem do host.
O atacante deposita um binário estático autocontido dentro do
contêiner, o executa, e a corrupção do page-cache -- e,
portanto, o hook -- torna-se visível para todos os irmãos.
Identidade da página do page-cache. Dentro de um contêiner overlayfs,
/usr/lib/.../libc.so.6 é servido pelo inode ext4 da camada inferior da
imagem. Todo contêiner iniciado a partir da mesma imagem compartilha
esse inode de backing, e o page-cache do kernel é indexado pelo
inode subjacente -- não pelo overlay nem pelo namespace. Portanto, uma
única escrita de 4 bytes em uma página do page-cache é visível para todos
os processos dos contêineres irmãos que têm essa página mmap'd.
A vuln AF_ALG aead transforma uma dessas escritas em muitas. algif_aead
encadeia o iovec RX do usuário com os bytes authsize finais do
SGL TX com splice, e a rotação ESN de authencesn estaciona 4 bytes
do campo seq_high do AAD em dst[assoclen + cryptlen] -- que
é o primeiro byte dessa cauda externa encadeada. A página com splice
é uma página do page-cache de um arquivo ao qual o atacante tem apenas
acesso de leitura, mas o cipher copia bytes para ela mesmo assim, sem
bookkeeping de dirty. (Veja crypto/algif_aead.c e crypto/authencesn.c
para a mecânica subjacente.)
Bootstrapping de uma primitiva chamável. A primeira coisa que
page_inject faz é fazer o bootstrap da Zona A -- uma
re-implementação codificada em asm da mesma dança AF_ALG (),
colocada na cave da libc. Isso torna a escrita de 4 bytes um
regular a partir de qualquer payload de hook futuro, sem necessidade
de setup de socket a cada chamada.
O injetor é compilado fora do contêiner vítima -- tipicamente
na própria máquina de desenvolvimento do atacante -- porque a maioria das
imagens de contêiner de produção não inclui um compilador. Um ambiente de
desenvolvimento Linux x86_64 padrão com gcc (com suporte a link
-static) e nasm é suficiente.
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
O resultado é um único ELF estaticamente linkado (./page_inject) que
executa em qualquer kernel Linux x86_64 moderno.
Uma vez que o atacante tem shell em victim, ele envia o binário para um
diretório gravável (tipicamente /tmp):
# inside the compromised container, attacker session
victim$ ./page_inject
Sem argumentos, page_inject usa como padrão
/usr/lib/x86_64-linux-gnu/libc.so.6 (o local pós-merge do Debian/Ubuntu).
Para outras distros a libc está em um caminho diferente; ou passe-o
explicitamente ou use --root / para escanear a tabela de lookup
embutida a partir da raiz do contêiner:
# 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 /
Qualquer uma das invocações faz a mesma coisa: faz parse de ELF da libc
dentro do contêiner, instala o hook no page-cache dela, monitora a tabela
de slots por ~30 s enquanto os irmãos se registram, e executa um id
one-shot contra o primeiro irmão que se registrou como verificação de sanidade.
Após o bootstrap, entre no shell de comando para dirigir qualquer irmão registrado:
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 limpa o hook de todos os contêineres irmãos de uma só vez
e deixa os filhos hook se autoterminarem.
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
Builds diferentes de glibc deixam diferentes quantidades de espaço de cave
.text entre o segmento LOAD executável e o próximo LOAD somente leitura.
page_inject seleciona entre dois layouts no momento da injeção:
Caminho A -- somente libc (padrão). Tanto a Zona C quanto a Zona A
ficam na cave .text da libc. As áreas de tabela de slots + CMD + OUTPUT
ficam na seção .hash da libc -- dados de hash SysV legados que o ld.so
não lê em tempo de execução, já que ele usa .gnu.hash em vez disso.
Quando .hash está ausente (toolchain moderna do Arch), page_inject
esculpe a região de slots a partir da cauda de .eh_frame_hdr em vez
disso, depois de primeiro encolher o campo fde_count para que o unwinder
não considere mais os bytes liberados como parte do índice de busca
binária FDE (o unwinder cai de forma transparente em uma varredura linear
de .eh_frame para qualquer IP cujo FDE costumava estar no intervalo
truncado -- comportamento exigido pela LSB).
Caminho B -- trampolim na libc + payload em ld.so. Alguns builds de
glibc encolhem a cave da libc para abaixo do tamanho necessário para o
payload completo de Zona C + Zona A (Ubuntu 24.04 / glibc 2.39 traz uma
cave de 711 B). Nesse caso, page_inject escreve um trampolim de 36 B na
cave da libc -- ele faz o gate da chave .bss do fast-path intra-libc --
e no slow path calcula a base em tempo de execução do ld.so a partir do
slot GOT da libc para (um símbolo do lado do ld.so que toda
glibc importa privadamente) e salta para uma variante de base-registrador
da Zona C na cave do . A tabela de slots + CMD + OUTPUT +
chave permanecem na libc; a Zona C do lado do ld.so as alcança via
depois que o trampolim semeia .
Se nenhum dos layouts couber, page_inject recusa de forma limpa, sem
escrever nada na libc nem no ld.so em disco ou no page-cache.
read()Versões diferentes de glibc emitem sequências de abertura diferentes em
read(). O injetor reconhece cada uma, lê de volta os bytes que o hook
desloca e os emula no fast-path da Zona C para que read() single-threaded
retome corretamente em read+N:
O slot de emulação do fast-path na Zona C é dimensionado para o maior
prólogo conhecido (8 bytes) mais o jmp rel32 de 5 bytes; prólogos mais
curtos preenchem o byte final do slot com um filler NOP para que o
comprimento total do slot seja constante.
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.
O exploit foi verificado de ponta a ponta nas seguintes distros snap de
contêiner. Em cada entrada, page_inject foi injetado de dentro de um
contêiner e seu hook foi visto disparando em um contêiner irmão iniciado a
partir da mesma imagem; os comandos executaram corretamente via o canal de
page-cache; e o unhook restaurou o estado das páginas da libc de forma limpa.
page_inject é estaticamente linkado de propósito para que o
próprio processo do atacante não seja afetado pelo hook que ele instala.page_inject reconhece uma libc "já com hook" (E9 + nops no
prólogo de read()) e se recusa a reinjetar. Se você estiver em um
ambiente de teste e seu page-cache estiver travado nesse estado, pare todos
os contêineres que usam a imagem e faça drop_caches para redefinir.write_cache.asm.textcallInstalação do hook. O injetor então escreve a Zona C
(zone_c.asm) na cave .text da libc e patcha os primeiros
7-12 bytes de read() com um salto E9 disp32 para ela. Os
bytes deslocados do prólogo são emulados fielmente no fast-path da Zona C
(três prólogos glibc distintos são reconhecidos --
veja "Tratamento do prólogo de read()" abaixo). O hook agora está vivo no
page-cache da libc.
Propagação do hook. Todo contêiner irmão executa processos que
chamam read() constantemente (daemons de log, healthchecks, cat /etc/hostname, qualquer coisa). Na primeira dessas chamadas dentro de um
contêiner irmão, o prólogo sequestrado salta para a Zona C, que:
stat("/") do inode raiz do contêiner (um ID por namespace
estável), usando-o como chave de slot do contêiner,fork() de um filho de longa
duração command-loop que faz polling da área CMD por ordens,read()+N para que o chamador não perceba nada.
O processo irmão original continua executando. A partir de agora o
atacante tem um daemon dentro desse contêiner.Canal de comando. O atacante usa o mesmo binário page_inject
no modo --shell para escrever comandos na área CMD da região de slots.
Cada filho hook registrado de cada contêiner irmão faz polling,
faz fork de /bin/sh -c <cmd>, captura stdout/stderr na área OUTPUT,
sinaliza a conclusão e volta ao polling. O shell mostra a saída. Como toda
escrita CMD/OUTPUT também passa pela primitiva da vuln, nenhum privilégio
especial é necessário.
Remoção do hook. Quando terminar, unhook restaura os bytes
originais do prólogo de read() e zera a tabela de slots; os filhos hook
veem um slot vazio na próxima iteração e se autoterminam. As
modificações do page-cache em si são limpas (o kernel nunca marcou
as páginas modificadas como dirty), então, uma vez que todo contêiner
que tem a libc mmap'd seja parado, um drop_caches reverte o cache
completamente -- nenhum artefato em disco permanece.
_rtld_global.text.bssrbp + offsetrbp = libc_base| Faixa de glibc | Prólogo (após endbr64 opcional) | Notas |
|---|
| 2.36 / 2.39 | cmpb $0x0, __libc_single_threaded(%rip) | 7 bytes; o cmpb emulado define ZF para o jne .Lthreaded original. |
| 2.43 | push rbp; movsxd rdi,edi; xor r9d,r9d | 7 bytes; emulado byte a byte. |
| 2.31 / 2.35 | mov eax, fs:[0x18] | 8 bytes; emulado byte a byte ([disp32] prefixado com FS é absoluto, não relativo a RIP, então a cópia de bytes é fiel). |
| Imagem | glibc | Caminho de injeção | Prólogo de read() | Região de slots |
|---|
debian:bookworm | 2.36 | A | cmpb | .hash |
ubuntu:24.04 | 2.39 | B | cmpb | .hash (lado da libc, endereçado via rbp a partir do 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 (cauda truncada) |