sgkdev/page_inject

GitHub: sgkdev/page_inject

利用 CVE-2026-31431 内核 AF_ALG 漏洞的页缓存写入原语,在共享同一镜像层的容器之间实现无特权的跨容器代码执行逃逸。

Stars: 40 | Forks: 5

# page_inject - AF_ALG aead 跨容器逃逸 **AF_ALG aead 漏洞跨容器利用 -- 从一个已被攻破的容器渗透到共享同一 `libc.so.6` 镜像层的每一个兄弟容器。** 这是一个**逃逸原语**:它运行在 攻击者已经攻破的**非特权**容器内部, 并利用 AF_ALG `authencesn` ESN 轮换 4 字节 任意写入漏洞 (CVE-2026-31431) 在 `libc.so.6` 的页缓存页面中植入一个持久的 `read()` 钩子。由于 Docker / containerd 在 overlayfs 底层文件中使用了**共享 inode**, 这些页面对从同一镜像实例化的每一个兄弟容器都是可见的——该钩子也会在它们的进程中触发,并且 攻击者能在每个容器内部获得命令执行权限。 ## 威胁模型 * 攻击者拥有对主机上单个容器的 shell 访问权限(称之为 `victim`), 该主机上运行着与 `victim` 源自**相同镜像**的其他容器(`siblings`)。 * `victim` 以默认的 Docker/k8s 姿态运行:容器用户命名空间内的非特权 uid、默认 seccomp 配置文件、默认 AppArmor 配置文件、无特殊 capabilities、无主机绑定挂载。 * `victim` **仅**拥有: * 对其自身 libc 的读取访问权限(`/usr/lib/x86_64-linux-gnu/libc.so.6` 或发行版安装它的任何位置) * 标准的 `socket(AF_ALG, ...)` 系统调用族 * 标准的 `splice` / `vmsplice` 系统调用 * 对一个可以执行 `chmod +x` 的目录的写权限(例如 `/tmp`) * 内核必须容易受到 CVE-2026-31431 的攻击(任何在上游撤销修复之前的 `algif_aead + authencesn` 构建版本)。 这就是全部条件。不需要特殊的 `CAP_*`,也不需要主机文件系统 访问权限。攻击者在容器内放置一个独立的静态链接二进制文件, 运行它,页缓存破坏——以及由此产生的钩子——就会对每个兄弟容器变得可见。 ## 漏洞利用链的工作原理 1. **页缓存页面一致性。** 在 overlayfs 容器内部, `/usr/lib/.../libc.so.6` 由底层镜像层的 ext4 inode 提供。从同一镜像启动的每个容器都共享 该后备 inode,并且内核的页缓存由 底层 inode 作为键值——而不是由 overlay 或命名空间决定。因此,对页缓存页面的单次 4 字节写入对所有 已经 mmap 了该页面的兄弟容器进程都是可见的。 2. **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`。) 3. **引导一个可调用的原语。** `page_inject` 做的第一件事就是引导 **Zone A**——一个经过汇编编码的、对同一 AF_ALG 操作的重新实现(`write_cache.asm`), 并将其放置在 libc 的 `.text` 空洞内。这使得 4 字节写入变成一个 在未来任何钩子 payload 内部常规的 `call`,无需为每次调用 建立 socket。 4. **安装钩子。** 注入器接着将 **Zone C**(`zone_c.asm`)写入 libc 的 `.text` 空洞,并用一个跳转到它的 `E9 disp32` 指令修补 `read()` 的前 7-12 个字节。在 Zone C 的快速路径中会忠实地模拟被替换的序言字节(能识别三种不同的 glibc 序言——参见下面的“序言处理”)。钩子现在已经在 libc 的页缓存中激活。 5. **钩子传播。** 每个兄弟容器都运行着不断调用 `read()` 的进程(日志守护进程、健康检查、`cat /etc/hostname` 等)。在兄弟容器中首次发生此类调用时,被劫持的序言会跳入 Zone C,该模块会: * 对容器根 inode 执行 `stat("/")`(一个稳定的按命名空间 ID),将其用作该容器的插槽键, * 扫描插槽表以查找具有该键的现有条目, * 如果不存在,则注册该键并 `fork()` 出一个长期存在的 **命令循环子进程**,该子进程会轮询 CMD 区域以获取命令, * 返回到 `read()+N`,使得调用方毫无察觉。 原始的兄弟进程继续运行。从现在起,攻击者在该容器内拥有了一个守护进程。 6. **命令通道。** 攻击者在 `--shell` 模式下使用相同的 `page_inject` 二进制文件将命令写入到插槽区域的 CMD 区域中。每个已注册的兄弟容器的钩子子进程会轮询、 `fork` 并执行 `/bin/sh -c `,将 stdout/stderr 捕获到 OUTPUT 区域中, 发出完成信号,然后继续轮询。该 shell 会显示输出。因为每次 CMD/OUTPUT 写入也是通过漏洞原语进行的,所以不需要任何特殊权限。 7. **解除钩子。** 完成后,`unhook` 会恢复 `read()` 的原始序言字节并将插槽表清零;钩子子进程在下一次迭代时看到空插槽并自动终止。页缓存本身的修改是干净的(内核从未将修改的页面标记为脏页),因此一旦每个 mmap 了 libc 的容器停止运行,一次 `drop_caches` 即可完全恢复缓存——不会留下任何磁盘上的痕迹。 ## 构建 注入器在受害者容器**外部**构建——通常 在攻击者自己的开发机器上——因为大多数生产 容器镜像不附带编译器。具有 `gcc`(支持 `-static` 链接)和 `nasm` 的标准 Linux x86_64 开发环境就足够了。 ``` 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 ``` 输出是一个单一的静态链接 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 Auto-resolve libc.so.6 under 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 ``` ## 双重注入路径 不同的 glibc 构建版本在可执行 LOAD 段和下一个只读 LOAD 段之间留下的 `.text` 空洞空间大小不同。`page_inject` 在注入时会在两种布局之间进行选择: * **路径 A -- 仅 libc(默认)。** Zone C 和 Zone A 都位于 libc 的 `.text` 空洞内。插槽表 + CMD + OUTPUT 区域位于 libc 的 `.hash` 段中——这是遗留的 SysV 哈希数据,ld.so 在运行时不会读取它,因为它现在改用 `.gnu.hash`。当 `.hash` 不存在时(Arch 的现代工具链),`page_inject` 会转而从 `.eh_frame_hdr` 的尾部 carving 出插槽区域,首先会缩小 `fde_count` 字段,使得展开器不再将释放的字节视为 FDE 二分搜索索引的一部分 (展开器会透明地回退到对 `.eh_frame` 进行线性扫描,以查找其 FDE 曾经位于被截断范围内的任何 IP——这是 LSB 强制要求的行为)。 * **路径 B -- libc 跳板 + ld.so 负载。** 某些 glibc 构建版本将 libc 空洞缩小到低于完整 Zone C + Zone A 负载所需的尺寸(Ubuntu 24.04 / glibc 2.39 附带了 711 B 的空洞)。 在这种情况下,`page_inject` 将一个 36 B 的跳板写入 libc 的 空洞中——它负责处理快速路径的 `.bss`-键门控库内调用——而在 慢速路径中,它从 libc 的 `_rtld_global`(每个 glibc 都会私有导入的 ld.so 侧符号)的 GOT 插槽计算 ld.so 的运行时基址,并跳转到位于 **ld.so** 的 `.text` 空洞中的 Zone C 的基址寄存器变体。插槽表 + CMD + OUTPUT + `.bss` 键全部保留在 libc 中;ld.so 侧的 Zone C 在跳板初始化 `rbp = libc_base` 之后,通过 `rbp + offset` 访问它们。 如果两种布局都不适合,`page_inject` 会干净地拒绝,而不会 向磁盘或页缓存中的 libc 或 ld.so 写入任何内容。 ## `read()` 序言处理 不同的 glibc 版本在 `read()` 中生成不同的开启指令序列。 注入器能识别每一种序列,读回被钩子替换的字节, 并在 Zone C 的快速路径中模拟它们,以便单线程的 `read()` 能够在 `read+N` 处正确恢复: | Glibc 范围 | 序言(在可选的 `endbr64` 之后) | 备注 | |-------------|--------------------------------------------------|-------| | 2.36 / 2.39 | `cmpb $0x0, __libc_single_threaded(%rip)` | 7 字节;模拟的 cmpb 为原始的 `jne .Lthreaded` 设置 ZF。 | | 2.43 | `push rbp; movsxd rdi,edi; xor r9d,r9d` | 7 字节;逐字节模拟。 | | 2.31 / 2.35 | `mov eax, fs:[0x18]` | 8 字节;逐字节模拟(带 FS 前缀的 `[disp32]` 是绝对寻址,而非 RIP 相对寻址,因此字节复制是忠实的)。 | Zone C 中的快速路径模拟插槽按已知最长的序言(8 字节)加上 5 字节的 `rel32` jmp 进行了尺寸调整;较短的序言会用一个 NOP 填充尾随的插槽字节,使得总插槽长度保持恒定。 ## 文件布局 ``` 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. ``` ## 已测试并支持的矩阵 该漏洞利用已在以下容器 snap 发行版上进行了端到端的验证。对于每个条目,都已经在一个容器内部注入了 `page_inject`,并观察到其钩子在从同一镜像启动的兄弟容器中触发;命令通过页缓存通道正确执行;并且取消钩子干净地恢复了 libc 页面状态。 | 镜像 | 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` (截断的尾部) | ## 操作说明 * `page_inject` 被故意设计为静态链接,因此攻击者自己的进程不会受到其安装的钩子的影响。 * `page_inject` 能识别“已被挂钩”的 libc(`read()` 序言处为 E9 + nops)并拒绝重新注入。如果你在测试环境中且你的页缓存卡在了那种状态,请停止所有使用该镜像的容器并执行 `drop_caches` 以重置。
标签:AF_ALG, authencesn, Chrome Headless, CVE-2026-31431, Docker安全, Hook注入, Kubernetes安全, libc.so.6, Linux内核安全, overlayfs, PE 加载器, vmsplice, Web截图, Web报告查看器, 任意地址写, 共享镜像层, 内存破坏, 内核漏洞, 客户端加密, 容器安全, 容器逃逸, 容器隔离突破, 提权, 横向移动, 红队武器, 编程规范, 请求拦截, 逃逸原语, 页面缓存污染