当你将一个 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_KEYorPTR_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 | 0: (bf) r6 = r |
当一个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 | $ bpftool prog dump xlated name kprobe_exec visual > out.dot |
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 | const struct bpf_func_proto bpf_map_lookup_elem_proto = { |
该结构体定义了辅助函数入参与返回值的约束条件。由于校验器会持续跟踪每个寄存器中存储的值类型,因此它能够检测出向辅助函数传入非法参数的行为。
示例: [hello-verifier.bpf.c]
1 | - p = bpf_map_lookup_elem(&my_config, &uid); |
从编译器的角度来看,这样的写法是合法的,因此你可以成功编译出 BPF 目标文件 hello-verifier.bpf.o;但当你尝试将该程序加载到内核时,会在校验器日志中看到如下错误信息:
1 | $ ./hello-verifier |
6.6 Checking the License
校验器还会进行一项检查:若你使用的 BPF 辅助函数采用 GPL 许可证授权,那么你的程序也必须配备与 GPL 许可证兼容的授权协议。
示例: [hello-verifier.bpf.c]
1 | - char LICENSE[] SEC("license") = "Dual BSD/GPL"; |
1 | $ ./hello-verifier |
查看辅助函数是否采用 GPL 许可证授权:
https://github.com/iovisor/bcc/blob/master/docs/kernel-versions.md#helpers
相关阅读:
6.7 Checking Memory Access
校验器会执行多项检查,以确保 BPF 程序仅访问其被允许访问的内存区域。例如,在处理网络数据包时,XDP 程序仅被允许访问构成该数据包的内存区域。
绝大多数 XDP 程序的开头代码都与以下示例极为相似:
1 | SEC("xdp") |
作为上下文参数传入程序的 xdp_md 结构体,用于描述接收到的网络数据包。该结构体中的 ctx->data 字段指向数据包在内存中的起始位置,ctx->data_end 字段则指向数据包的末尾位置。校验器会确保程序不会越界访问这些内存范围。
ctx->data_end 越界
例如,hello_verifier.bpf.c 文件中的以下这段程序是合法的:
1 | SEC("xdp") |
你的程序必须检查所有从数据包中读取的值,确保其不会超出该位置的范围,而且校验器也不会允许你通过修改 data_end 的值来 “钻空子”。你可以试着在 bpf_printk() 函数调用的前一行添加以下代码:
1 | SEC("xdp") |
校验器会输出如下报错:
1 | ; data_end++; |
数组越界
再举一个例子,访问数组时,你必须确保绝对不会出现访问数组边界外索引的情况。示例代码中有一段从 message 数组中读取单个字符的代码,如下所示:
1 | if (c < sizeof(message)) { |
这样写是合法的,原因在于代码中添加了显式检查,确保计数器变量 c 的值不会超过 message 数组的长度。
但如果出现如下所示的简单差一错误,代码就会变为非法:
1 | + if (c <= sizeof(message)) { |
校验器会判定这段代码无效,并输出类似如下的错误信息:
1 | invalid access to map value, value_size=16 off=16 size=1 |
如果你要调试这个错误,就需要深入查看日志,找出是源代码中的哪一行导致了这个问题。在校验器输出错误信息之前,日志的末尾内容如下:
1 | ; if (c <= sizeof(message)) { |
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 | char a = p->message[0]; |
这段代码编译可以正常通过,但会被校验器以如下方式拒绝加载:
1 | ; p = bpf_map_lookup_elem(&my_config, &uid); |
校验器会拒绝这种对空指针进行解引用的操作,但如果代码中添加了显式检查(示例如下),程序就能通过校验:
1 | + if (p != 0) { |
部分辅助函数会自动帮你完成指针检查。例如,当你查阅 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 | for (int i=0; i < 10; i++) { |
校验通过后生成的日志会显示,校验器已沿着该循环的执行路径完整遍历了 10 次。
1 | ; bpf_printk("Looping %d", i); |
在此过程中,指令处理量并未触及 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 | SEC("xdp") |
这段代码会无法通过校验器的检查。但如果你把调用辅助函数 bpf_printf() 的那行代码恢复回去,即便源代码里没有显式设置返回值,校验器也不会再报错。
这是因为寄存器 0(R0)同时也被用于存储辅助函数的返回值。在 eBPF 程序中,当辅助函数执行完毕返回后,寄存器 0 就不再处于未初始化的状态了。
6.13 Invalid Instructions
eBPF 程序由一系列字节码指令构成。校验器会检查程序中的指令是否为合法的字节码指令 —— 例如,是否只使用了已知的操作码。
如果编译器生成了非法字节码,这会被视作一个编译器缺陷;因此,除非你(出于某些自己才清楚的原因)选择手动编写 eBPF 字节码,否则一般不会遇到这类校验器错误。
不过,近年来内核新增了一些指令,比如原子操作指令。如果你的编译后字节码中使用了这些指令,那么程序在较旧版本的内核上运行时,就会无法通过校验。
6.14 Unreachable Instructions
校验器还会拒绝包含不可达指令的程序。不过,这类指令通常都会被编译器在优化阶段自动剔除。
6.15 Summary
eBPF 虚拟机在逐条执行 eBPF 程序指令的过程中,会借助一组寄存器来暂存数据;而校验器会持续追踪每个寄存器的类型及其数值的可能范围,以此确保 eBPF 程序的运行安全性。