
CVE-2026-31431-killed page-cache exploit — code exec into containers sharing the same image layer
AF_ALG aead vulnerability cross-container exploit -- pivot from one compromised container into every sibling container that shares the same libc.so.6 image layer.
This is an escape primitive: it runs from
inside an unprivileged container that the attacker has already
compromised, and uses the AF_ALG authencesn ESN-rotation 4-byte
arbitrary-write bug (CVE-2026-31431) to plant a persistent read()
hook in the page-cache pages of libc.so.6. Because Docker /
containerd back overlayfs lower-layer files with shared inodes,
those pages are visible to every sibling container instantiated from
the same image -- the hook fires in their processes too, and the
attacker gets command execution inside each one.
victim)
on a host that runs other containers (siblings) from the same
image as victim.victim runs with default Docker/k8s posture: unprivileged uid
inside the container's user namespace, default seccomp profile,
default AppArmor profile, no special capabilities, no host bind
mounts.victim has only:
/usr/lib/x86_64-linux-gnu/libc.so.6
or wherever the distro installs it)socket(AF_ALG, ...) syscall familysplice / vmsplice syscallschmod +x (e.g. /tmp)algif_aead + authencesn build prior to the upstream revert fix).That is all. No special CAP_*, no host filesystem
access. The attacker drops a self-contained statically-linked binary
inside the container, runs it, and the page-cache corruption -- and
therefore the hook -- becomes visible to every sibling.
Page-cache page identity. Inside an overlayfs container,
/usr/lib/.../libc.so.6 is served by the lower image layer's
ext4 inode. Every container started from the same image shares
that backing inode, and the kernel's page cache is keyed by the
underlying inode -- not by the overlay or by the namespace. So a
single 4-byte write into a page-cache page is visible to all
sibling containers' processes that have that page mmap'd.
AF_ALG aead vuln turns one such write into many. algif_aead
chains the user RX iovec with the trailing authsize bytes of the
spliced TX SGL, and authencesn's ESN rotation parks 4 bytes of
the AAD's seq_high field at dst[assoclen + cryptlen] -- which
is the first byte of that chained foreign tail. The spliced page
is a page-cache page of a file the attacker only has read access
to, but the cipher copies bytes into it anyway, with no dirty
bookkeeping. (See crypto/algif_aead.c and crypto/authencesn.c
for the underlying mechanics.)
Bootstrapping a callable primitive. The first thing
page_inject does is bootstrap Zone A -- an asm-encoded
re-implementation of the same AF_ALG dance (write_cache.asm),
placed inside libc's .text cave. This makes the 4-byte write a
regular call from inside any future hook payload, no per-call
socket setup needed.
Installing the hook. The injector then writes Zone C
(zone_c.asm) into libc's .text cave and patches the first
7-12 bytes of read() with an E9 disp32 jump to it. The
prologue's displaced bytes are emulated faithfully in Zone C's
fast-path (three different glibc prologues are recognised --
see "Prologue handling" below). The hook is now live in libc's
page cache.
Hook propagation. Every sibling container runs processes that
call read() constantly (logging daemons, healthchecks, cat /etc/hostname, anything). On the first such call inside a sibling
container, the hijacked prologue jumps into Zone C, which:
stat("/")s the container's root inode (a stable per-namespace
ID), uses it as the container's slot key,fork()s a long-lived
command-loop child that polls the CMD area for orders,read()+N so the caller is none the wiser.
The original sibling process keeps running. From now on the
attacker has a daemon inside that container.Command channel. The attacker uses the same page_inject
binary in --shell mode to write commands into the slot region's
CMD area. Each registered sibling container's hook child polls,
forks /bin/sh -c <cmd>, captures stdout/stderr into the OUTPUT
area, signals completion, and goes back to polling. The shell
shows the output. Because every CMD/OUTPUT write also goes through
the vuln primitive, no special privilege is needed.
Unhook. When done, unhook restores read()'s original
prologue bytes and zeros the slot table; hook children see an
empty slot on their next iteration and self-terminate. The
page-cache modifications themselves are clean (the kernel never
marked the modified pages dirty), so once every container that
has libc mmap'd is stopped, a drop_caches reverts the cache
fully -- no on-disk artefact remains.
The injector is built outside the victim container -- typically
on the attacker's own development machine -- because most production
container images don't ship a compiler. A standard Linux x86_64 dev
environment with gcc (with -static-link support) and nasm is
enough.
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
The output is a single statically-linked ELF (./page_inject) that
runs on any modern x86_64 Linux kernel.
Once the attacker has shell on victim, they upload the binary to a
writable directory (typically /tmp):
# inside the compromised container, attacker session
victim$ ./page_inject
With no arguments, page_inject defaults to
/usr/lib/x86_64-linux-gnu/libc.so.6 (the post-merge Debian/Ubuntu
location). For other distros the libc is at a different path; either
pass it explicitly or use --root / to scan the built-in lookup
table from the container's 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 /
Either invocation does the same thing: ELF-parse the in-container
libc, install the hook in its page cache, monitor the slot table for
~30 s while siblings register, and run a one-shot id against the
first sibling that registered as a sanity check.
After bootstrap, drop into the command shell to drive any registered sibling:
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 cleans the hook out of every sibling container in one shot
and lets the hook children self-terminate.
Usage: page_inject [OPTIONS] [LIBC_PATH]