当你将一个 eBPF 程序加载至内核时,校验过程会检查程序的所有可能执行路径,并确保每条指令的安全性。同时,校验器还会对字节码进行若干修正,使其处于可执行状态。

eBPF 校验器的处理对象是 eBPF 字节码,而非直接处理源代码。字节码的生成取决于编译器的输出结果。受编译器优化等因素的影响,源代码的改动不一定会在字节码中产生你预期的变化,相应地,校验器给出的校验结果也可能与你的预期不符。

6.1 The Verification Process

校验器会对程序进行分析,以评估其所有可能的执行路径。它会按顺序逐条遍历指令,对指令进行校验评估,而非实际执行指令。

在这一过程中,校验器会通过一个名为 bpf_reg_state 的结构体,持续跟踪记录每个寄存器的状态。该结构体包含一个名为 bpf_reg_type 的字段,用于描述对应寄存器中存储值的类型。这类值的类型有多种可能,其中包括以下几种:

  • NOT_INIT, 表示寄存器尚未被赋值。

  • SCALAR_VALUE, 表示寄存器中存储的值为非指针类型。

  • 多种 PTR_TO_* 类型:表示寄存器中存储的是指向某一对象的指针。

    • PTR_TO_CTX: 寄存器中存储的指针,指向传入 eBPF 程序的上下文参数。
    • PTR_TO_PACKET: 寄存器中存储的指针,指向网络数据包(在内核中以 skb->data 的形式存储)。
    • PTR_TO_MAP_KEY or PTR_TO_MAP_VALUE

还有其他几种 PTRTO* 类型参考 linux/bpf.h

bpf_reg_state 结构体还会跟踪记录寄存器可能存储的值的取值范围。校验器会利用这些信息,判断程序是否存在尝试执行非法操作的行为。

每当校验器遇到分支结构 —— 也就是需要决定是按顺序继续执行,还是跳转到其他指令的位置时,它会将所有寄存器的当前状态生成一份副本并压入栈中,随后再去遍历其中一条可能的执行路径。对每一种可能性逐一进行校验,会产生高昂的计算开销。因此在实际应用中,会采用一种名为状态剪枝(state pruning)的优化手段,来避免对程序中本质上等价的执行路径进行重复评估。

相关阅读:

6.2 The Verifier Log

当程序校验失败时,校验器会生成一份日志,记录其判定该程序无效的完整过程。

  • 若你使用 bpftool prog load 命令加载程序,校验器日志会输出至标准错误流(stderr)。

  • 当你基于 libbpf 编写程序时,可以调用 libbpf_set_print() 函数来设置一个处理函数,该函数可用于显示各类错误信息(或对这些错误信息执行其他实用操作)。

校验器日志会包含一份校验器工作量汇总,内容示例如下:

1
processed 61 insns (limit 1000000) max_states_per_insn 0 total_states 4 peak_states 4 mark_read 3

在本示例中,校验器共处理了 61 条指令,其中包含通过不同执行路径到达同一条指令、进而对其进行多次处理的情况。需要注意的是,一百万条指令的复杂度上限是程序指令数量的阈值;而在实际场景中,若代码内存在分支结构,校验器会对部分指令执行多次处理。

本次存储的状态总数为 4 个,对于这个简单的程序而言,该数值与存储状态的峰值是一致的。如果其中部分状态被执行了剪枝操作,那么状态峰值就有可能低于这个总数。

日志输出包含校验器已分析的 BPF 指令,以及对应的 C 语言源代码行(前提是目标文件在编译时添加了 -g 参数以包含调试信息),同时还会附带校验器状态信息的摘要。

以下是一段校验器日志的示例片段,内容对应 hello-verifier.bpf.c 程序开头的几行代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
0: (bf) r6 = r
# 日志中会包含源代码行信息,以此帮助开发者更直观地理解日志输出与源代码之间的对应关系。
# 这些源代码信息能够被展示,是因为编译阶段使用了 -g 参数来嵌入调试信息。
; data.counter = c;
1: (18) r1 = 0xffff800008178000
3: (61) r2 = *(u32 *)(r1 +0)
# 以下是日志中输出的一段寄存器状态信息示例。
# 这段信息表明,在当前阶段,寄存器 1 中存储的是映射值,寄存器 6 中保存的是上下文,寄存器 10 则是用于存放局部变量的栈帧指针。
R1_w=map_value(id=0,off=0,ks=4,vs=16,imm=0) R6_w=ctx(id=0,off=0,imm=0) R10=fp0
; c++;
4: (bf) r3 = r2
5: (07) r3 += 1
6: (63) *(u32 *)(r1 +0) = r3
# 这是另一段寄存器状态信息的示例。
# 在这段信息中,你不仅能看到每个已初始化寄存器中存储的值类型,还能查看寄存器 2 与寄存器 3 的可能取值范围。
R1_w=map_value(id=0,off=0,ks=4,vs=16,imm=0) R2_w=inv(id=1,umax_value=4294967295,
var_off=(0x0; 0xffffffff)) R3_w=inv(id=0,umin_value=1,umax_value=4294967296,
var_off=(0x0; 0x1ffffffff)) R6_w=ctx(id=0,off=0,imm=0) R10=fp0

当一个eBPF 程序被调用时,寄存器 1 总会存放传入该程序的上下文参数(context)。那么,为什么要把上下文参数复制到寄存器 6 中呢?

原因在于:调用 BPF 辅助函数(helper funciton)时,函数的入参需要通过寄存器 1 至寄存器 5 来传递;而辅助函数不会修改寄存器 6 至寄存器 9 中的内容。因此,将上下文暂存到寄存器 6 中,就可以让程序在调用辅助函数时,不会丢失对上下文的访问权限。

寄存器 0 既用于存储辅助函数(helper funciton)的返回值,也用于存储 eBPF 程序本身的返回值。

寄存器 10 则始终存放指向 eBPF 栈帧的指针(且该寄存器的值不允许被 eBPF 程序修改)。

校验器不仅会利用各寄存器的状态信息,还会结合每个寄存器的取值范围信息,来确定程序的所有可能执行路径。这些信息同样会被用于前文提及的状态剪枝优化:如果校验器在代码的同一位置,遇到了与此前完全相同的寄存器值类型及取值范围,那么就无需再对这条路径进行后续评估。

6.3 Visualizing Control Flow

校验器会遍历 eBPF 程序的所有可能执行路径。当你尝试调试程序问题时,直观查看这些执行路径会对你有所帮助。

bpftool 工具可实现这一功能:它能生成程序的控制流图并以 DOT 格式导出,之后你就可以将该文件转换为图像格式,操作方式如下:

1
2
$ bpftool prog dump xlated name kprobe_exec visual > out.dot
$ dot -Tpng out.dot > out.png

6.4 Validating Helper Functions

不允许从 eBPF 程序中直接调用任意内核函数(除非该函数已被注册为 kfunc,这部分内容将在下一章介绍),但 eBPF 提供了若干辅助函数(helper functions),可让程序从内核中获取信息。

系统中设有一份 bpf-helpers 手册页,试图对这些辅助函数进行完整说明。

不同的辅助函数适用于不同类型的 BPF 程序。例如,辅助函数 bpf_get_current_pid_tgid() 可用于获取当前用户态进程 ID 与线程 ID,但在 XDP 程序中调用该函数是毫无意义的 —— 因为 XDP 程序由网络接口接收数据包这一事件触发,其执行流程并不涉及任何用户态进程。

6.5 Helper Function Arguments

当你查看内核代码目录下的 kernel/bpf/helpers.c 文件时,会发现每个辅助函数都对应一个 bpf_func_proto 结构体,以下是辅助函数 bpf_map_lookup_elem() 的结构体示例:

1
2
3
4
5
6
7
8
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,
};

该结构体定义了辅助函数入参与返回值的约束条件。由于校验器会持续跟踪每个寄存器中存储的值类型,因此它能够检测出向辅助函数传入非法参数的行为。

示例: [hello-verifier.bpf.c]

1
2
3
- p = bpf_map_lookup_elem(&my_config, &uid);
// The first argument needs to be a pointer to a map; the following won't be accepted
+ p = bpf_map_lookup_elem(&data, &uid);

从编译器的角度来看,这样的写法是合法的,因此你可以成功编译出 BPF 目标文件 hello-verifier.bpf.o;但当你尝试将该程序加载到内核时,会在校验器日志中看到如下错误信息:

1
2
3
4
$ ./hello-verifier
...
27: (85) call bpf_map_lookup_elem#1
R1 type=fp expected=map_ptr

6.6 Checking the License

校验器还会进行一项检查:若你使用的 BPF 辅助函数采用 GPL 许可证授权,那么你的程序也必须配备与 GPL 许可证兼容的授权协议。

示例: [hello-verifier.bpf.c]

1
- char LICENSE[] SEC("license") = "Dual BSD/GPL";
1
2
3
$ ./hello-verifier
...
cannot call GPL-restricted function from non-GPL compatible program

查看辅助函数是否采用 GPL 许可证授权:

https://github.com/iovisor/bcc/blob/master/docs/kernel-versions.md#helpers

相关阅读:

6.7 Checking Memory Access

校验器会执行多项检查,以确保 BPF 程序仅访问其被允许访问的内存区域。例如,在处理网络数据包时,XDP 程序仅被允许访问构成该数据包的内存区域。

绝大多数 XDP 程序的开头代码都与以下示例极为相似:

1
2
3
4
5
6
SEC("xdp")
int xdp_load_balancer(struct xdp_md *ctx)
{
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
...

作为上下文参数传入程序的 xdp_md 结构体,用于描述接收到的网络数据包。该结构体中的 ctx->data 字段指向数据包在内存中的起始位置,ctx->data_end 字段则指向数据包的末尾位置。校验器会确保程序不会越界访问这些内存范围。

ctx->data_end 越界

例如,hello_verifier.bpf.c 文件中的以下这段程序是合法的:

1
2
3
4
5
6
7
SEC("xdp")
int xdp_hello(struct xdp_md *ctx) {
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
bpf_printk("%x %x", data, data_end);
return XDP_PASS;
}

你的程序必须检查所有从数据包中读取的值,确保其不会超出该位置的范围,而且校验器也不会允许你通过修改 data_end 的值来 “钻空子”。你可以试着在 bpf_printk() 函数调用的前一行添加以下代码:

1
2
3
4
5
6
7
8
9
10
SEC("xdp")
int xdp_hello(struct xdp_md *ctx) {
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;

+ data_end++;

bpf_printk("%x %x", data, data_end);
return XDP_PASS;
}

校验器会输出如下报错:

1
2
3
; data_end++;
2: (07) r4 += 1
R4 pointer arithmetic on pkt_end prohibited

数组越界

再举一个例子,访问数组时,你必须确保绝对不会出现访问数组边界外索引的情况。示例代码中有一段从 message 数组中读取单个字符的代码,如下所示:

1
2
3
4
if (c < sizeof(message)) {
char a = message[c];
bpf_printk("%c", a);
}

这样写是合法的,原因在于代码中添加了显式检查,确保计数器变量 c 的值不会超过 message 数组的长度。

但如果出现如下所示的简单差一错误,代码就会变为非法:

1
2
3
4
5
+ if (c <= sizeof(message)) {
- if (c < sizeof(message)) {
char a = message[c];
bpf_printk("%c", a);
}

校验器会判定这段代码无效,并输出类似如下的错误信息:

1
2
invalid access to map value, value_size=16 off=16 size=1
R2 max value is outside of the allowed memory range

如果你要调试这个错误,就需要深入查看日志,找出是源代码中的哪一行导致了这个问题。在校验器输出错误信息之前,日志的末尾内容如下:

1
2
3
4
5
6
7
8
9
10
; if (c <= sizeof(message)) {
44: (61) r1 = *(u32 *)(r7 +0) ; R1_w=scalar(umax=4294967295,var_off=(0x0; 0xffffffff)) R7=map_value(off=0,ks=4,vs=16,imm=0)
; if (c <= sizeof(message)) {
45: (25) if r1 > 0xc goto pc+24 ; R1_w=scalar(umax=12,var_off=(0x0; 0xf))
; char a = message[c];
46: (18) r2 = 0xffffb05f401f4004 ; R2_w=map_value(off=4,ks=4,vs=16,imm=0)
48: (0f) r2 += r1 ; R1_w=scalar(umax=12,var_off=(0x0; 0xf)) R2_w=map_value(off=4,ks=4,vs=16,umax=12,var_off=(0x0; 0
xf),s32_max=15,u32_max=15)
49: (71) r3 = *(u8 *)(r2 +0)
invalid access to map value, value_size=16 off=16 size=1

message 数组被声明为全局变量,你或许还记得第 3 章提到的内容:全局变量是通过 map(映射) 来实现的。这也解释了为什么错误信息中会提到 “对映射值(map value)的非法访问”。

6.8 Checking Pointers Before Dereferencing Them

让 C 程序崩溃的一个简单方法,就是对值为零的指针(也称为空指针)进行解引用操作。指针的作用是指示某个值在内存中的存储位置,而零并不是一个有效的内存地址。eBPF 校验器要求所有指针在被解引用之前都必须经过检查,以此避免这类崩溃问题的发生。

hello_verifier.bpf.c 文件中的示例代码,会通过以下这行代码,在用户对应的 my_config 哈希表映射中,查找可能存在的自定义消息:

1
p = bpf_map_lookup_elem(&my_config, &uid);

如果该映射中不存在与 uid 对应的条目,那么变量 p —— 也就是指向 msg_t 消息结构体的指针 —— 会被赋值为零。以下是一段额外添加的代码,它尝试对这个可能为空的指针执行解引用操作:

1
2
char a = p->message[0];
bpf_printk("%d", a);

这段代码编译可以正常通过,但会被校验器以如下方式拒绝加载:

1
2
3
4
5
6
7
8
; p = bpf_map_lookup_elem(&my_config, &uid);
25: (18) r1 = 0xffff96bd19e3c400 ; R1_w=map_ptr(off=0,ks=4,vs=12,imm=0)
27: (85) call bpf_map_lookup_elem#1 ; R0_w=map_value_or_null(id=2,off=0,ks=4,vs=12,imm=0)
28: (bf) r7 = r0 ; R0_w=map_value_or_null(id=2,off=0,ks=4,vs=12,imm=0) R7_w=map_value_or_null(id=2,off=0,ks=4,vs=1
2,imm=0)
; char a = p->message[0];
29: (71) r3 = *(u8 *)(r7 +0)
R7 invalid mem access 'map_value_or_null'

校验器会拒绝这种对空指针进行解引用的操作,但如果代码中添加了显式检查(示例如下),程序就能通过校验:

1
2
3
4
+ if (p != 0) {
char a = p->message[0];
bpf_printk("%d", a);
+ }

部分辅助函数会自动帮你完成指针检查。例如,当你查阅 bpf-helpers 的手册页时,会发现 bpf_probe_read_kernel() 函数的签名如下:

1
long bpf_probe_read_kernel(void *dst, u32 size, const void *unsafe_ptr)

该函数的第三个参数名为 unsafe_ptr。这是一个 BPF 辅助函数的典型示例,它会自动帮开发者完成相关检查,助力编写安全的代码。你可以向这个名为 unsafe_ptr 的第三个参数传入一个可能为空的指针,辅助函数会先检查该指针是否为空,之后再尝试对其进行解引用操作。

6.9 Accessing Context

每个 eBPF 程序都会以参数形式接收一些上下文信息,但根据程序类型与挂载方式的不同,程序可能仅被允许访问这些上下文信息中的一部分。例如,跟踪点(tracepoint)程序会接收一个指向跟踪点数据的指针。这些数据的格式由具体的跟踪点决定,且它们都以若干公共字段开头 —— 但这些公共字段无法被 eBPF 程序访问。程序仅能访问公共字段之后那些跟踪点专属的字段。

如果尝试对不允许访问的字段执行读写操作,就会触发 invalid bpf_context access(非法 bpf 上下文访问) 错误。本章末尾的练习题中就有一个相关示例。

6.10 Running to Completion

校验器会确保 eBPF 程序能够完整运行结束,否则程序就有可能出现无限消耗系统资源的风险。

校验器通过限制可处理的指令总数来实现这一目标,该指令数上限被设定为 100 万条。这个限制是硬编码在内核中的,并非可配置项。如果校验器在处理完该上限数量的指令前,仍未执行到 eBPF 程序的结尾,就会拒绝加载这个程序。

6.11 Loops

编写一段永不终止的程序,最简便的方法就是构造一个无限循环。接下来我们看看如何在 eBPF 程序中创建循环。

为确保程序能够完整运行结束,在 5.3 版本之前的内核中,eBPF 对循环存在限制。

循环执行相同指令需要向后跳转至更早的指令位置,而彼时的校验器是不允许这种操作的。为了规避这一限制,eBPF 开发者会使用 #pragma unroll 编译器指令,指示编译器为循环的每一次迭代,生成一段完全相同(或高度相似)的字节码指令。这种做法省去了开发者重复编写相同代码行的麻烦,但最终生成的字节码中会出现大量重复的指令。

从 5.3 版本开始,校验器在检查所有可能执行路径的过程中,不仅会正向追踪分支,还会反向追踪分支。这意味着,只要程序的执行路径不超过 100 万条指令的限制,校验器就可以接受部分循环结构。

你可以在示例程序 xdp_hello 中找到一个循环的例子。以下是一段能够通过校验的循环代码示例:

1
2
3
for (int i=0; i < 10; i++) {
bpf_printk("Looping %d", i);
}

校验通过后生成的日志会显示,校验器已沿着该循环的执行路径完整遍历了 10 次。

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
; bpf_printk("Looping %d", i);
2: (18) r1 = 0xffffb05f42e1b009 ; R1_w=map_value(off=9,ks=4,vs=26,imm=0)
4: (b7) r2 = 11 ; R2_w=11
5: (b7) r3 = 0 ; R3_w=0
6: (85) call bpf_trace_printk#6 ; R0_w=scalar()
7: (18) r1 = 0xffffb05f42e1b009 ; R1_w=map_value(off=9,ks=4,vs=26,imm=0)
9: (b7) r2 = 11 ; R2_w=11
10: (b7) r3 = 1 ; R3_w=1
11: (85) call bpf_trace_printk#6 ; R0=scalar()
12: (18) r1 = 0xffffb05f42e1b009 ; R1_w=map_value(off=9,ks=4,vs=26,imm=0)
14: (b7) r2 = 11 ; R2_w=11
15: (b7) r3 = 2 ; R3_w=2
16: (85) call bpf_trace_printk#6 ; R0_w=scalar()
17: (18) r1 = 0xffffb05f42e1b009 ; R1_w=map_value(off=9,ks=4,vs=26,imm=0)
19: (b7) r2 = 11 ; R2_w=11
20: (b7) r3 = 3 ; R3_w=3
21: (85) call bpf_trace_printk#6 ; R0=scalar()
22: (18) r1 = 0xffffb05f42e1b009 ; R1_w=map_value(off=9,ks=4,vs=26,imm=0)
24: (b7) r2 = 11 ; R2_w=11
25: (b7) r3 = 4 ; R3_w=4
26: (85) call bpf_trace_printk#6 ; R0_w=scalar()
27: (18) r1 = 0xffffb05f42e1b009 ; R1_w=map_value(off=9,ks=4,vs=26,imm=0)
29: (b7) r2 = 11 ; R2_w=11
30: (b7) r3 = 5 ; R3_w=5
31: (85) call bpf_trace_printk#6 ; R0=scalar()
32: (18) r1 = 0xffffb05f42e1b009 ; R1_w=map_value(off=9,ks=4,vs=26,imm=0)
34: (b7) r2 = 11 ; R2_w=11
35: (b7) r3 = 6 ; R3_w=6
36: (85) call bpf_trace_printk#6 ; R0_w=scalar()
37: (18) r1 = 0xffffb05f42e1b009 ; R1_w=map_value(off=9,ks=4,vs=26,imm=0)
39: (b7) r2 = 11 ; R2_w=11
40: (b7) r3 = 7 ; R3_w=7
41: (85) call bpf_trace_printk#6 ; R0=scalar()
42: (18) r1 = 0xffffb05f42e1b009 ; R1_w=map_value(off=9,ks=4,vs=26,imm=0)
44: (b7) r2 = 11 ; R2_w=11
45: (b7) r3 = 8 ; R3_w=8
46: (85) call bpf_trace_printk#6 ; R0_w=scalar()
47: (18) r1 = 0xffffb05f42e1b009 ; R1_w=map_value(off=9,ks=4,vs=26,imm=0)
49: (b7) r2 = 11 ; R2_w=11
50: (b7) r3 = 9 ; R3_w=9

在此过程中,指令处理量并未触及 100 万条的复杂度上限。本章的练习题中,还提供了该循环的另一个版本 —— 这个版本的代码会触发上述指令数限制,从而无法通过校验。

内核 5.17 版本中引入了一个全新的辅助函数 bpf_loop(),该函数不仅能让校验器更轻松地接受循环结构,还能以效率更高的方式完成这一过程。

这个辅助函数的第一个参数为最大迭代次数,同时还会传入一个迭代回调函数—— 每次循环迭代时都会调用该函数。无论这个回调函数会被调用多少次,校验器都只需要对其中的 BPF 指令执行一次合法性验证。

回调函数可以返回一个非零值,以此表示无需再继续调用该函数,这一机制可用于在得到预期结果后提前终止循环。

还有一个辅助函数 bpf_for_each_map_elem(),它会针对映射中的每一个元素,调用传入的回调函数。

6.12 Checking the Return Code

eBPF 程序的返回值会被存储在寄存器 0(R0)中。如果程序未对 R0 进行初始化,校验器就会判定程序无效,报错信息如下:

1
R0 !read_ok

你可以通过注释掉函数内的所有代码来测试这一点;例如,将 xdp_hello 示例程序修改为如下形式:

1
2
3
4
5
6
7
8
SEC("xdp")
int xdp_hello(struct xdp_md *ctx) {
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;

// bpf_printk("%x %x", data, data_end);
// return XDP_PASS;
}

这段代码会无法通过校验器的检查。但如果你把调用辅助函数 bpf_printf() 的那行代码恢复回去,即便源代码里没有显式设置返回值,校验器也不会再报错。

这是因为寄存器 0(R0)同时也被用于存储辅助函数的返回值。在 eBPF 程序中,当辅助函数执行完毕返回后,寄存器 0 就不再处于未初始化的状态了。

6.13 Invalid Instructions

eBPF 程序由一系列字节码指令构成。校验器会检查程序中的指令是否为合法的字节码指令 —— 例如,是否只使用了已知的操作码。

如果编译器生成了非法字节码,这会被视作一个编译器缺陷;因此,除非你(出于某些自己才清楚的原因)选择手动编写 eBPF 字节码,否则一般不会遇到这类校验器错误。

不过,近年来内核新增了一些指令,比如原子操作指令。如果你的编译后字节码中使用了这些指令,那么程序在较旧版本的内核上运行时,就会无法通过校验。

6.14 Unreachable Instructions

校验器还会拒绝包含不可达指令的程序。不过,这类指令通常都会被编译器在优化阶段自动剔除。

6.15 Summary

eBPF 虚拟机在逐条执行 eBPF 程序指令的过程中,会借助一组寄存器来暂存数据;而校验器会持续追踪每个寄存器的类型及其数值的可能范围,以此确保 eBPF 程序的运行安全性。

6.16 Exercises