eBPF verifier常见错误浅析

时间:2022-12-20 16:11:54

本文摘自毛文安公众号《酷玩BPF》文章,作者毛文安。

​收藏:eBPF verifier常见错误整理​

如今eBPF程序的编写,很多都是基于bcc或者bpftrace进行,也有开发者直接基于libbpf库进行,我们目前使用最多的是基于Coolbpf编写。

但是不管怎样,编写的xx.bpf.c程序,在加载到内核时,都必须经过内核的verifier校验器进行各种边界和内存检查,经常会碰到各种奇奇怪怪的 verfier 报错,导致 eBPF 程序加载失败。有些错误,我们可能要花费大量的时间去分析并修改程序,并祈祷程序能够加载成功。特别是在低版本的内核运行低版本Clang编译器编译的eBPF程序,错误提示非常糟糕,经常找不到出错点,这就大大增加了开发难度。

为此,本文梳理了一些常见的 eBPF verifier 报错,避免更多的人走弯路,写出能成功加载的 eBPF 程序。同时,本文通过讲解 eBPF verifier 检查原理及给出示例程序,来分析为什么 eBPF verifier 会报错,使读者能够知其然知其所以然,达到融会贯通。

1、简介

eBPF verifier 是一个位于内核的校验器,用于验证 eBPF 程序的安全性,保证 eBPF 程序不会破坏内核,导致内核崩溃。对于一个 eBPF 程序, verifier 会对其进行两次检查( first pass 和 second pass)(暂且这么翻译)。

第一次检查通过 dfs 算法检查 eBPF 程序是否为有向无环图(DAG),也就是 eBPF 程序不能回跳,比如使用了 goto、for 循环、while 循环等则有可能导致第一次检查失败。

在第二次检查时,verifier 会遍历 eBPF 程序的每条指令,同时保存寄存器的类型、值域等状态信息。通过保存的寄存器状态信息,verifier 可以检查 eBPF 程序内存访问的安全性,比如数组是否越界、helper 函数参数类型是否匹配等等。

2、第一次检查

eBPF verifier 通过 dfs 算法检查程序是否为有向无环图(DAG)。在此阶段,以下几种情况的 eBPF 程序将会被 verifier 拒绝:

  1. eBPF 程序指令数超过允许的最大值;
  2. 存在环,即存在指令回跳;
  3. 存在 unreachable 指令;
  4. 非法 jump,如 jump 到 eBPF 程序范围之外。


其中,第 1 种场景在 4.19 版本内核很少遇见,因为从该版本开始,指令数限制为 1000000 条。第 3 和第 4 两种场景,大部分开发者很少遇见。因为我们都是使用高级语言编程,由编译器负责生成相应指令,所以一般不会产生 unreachable 指令和非法 jump。但是如果直接使用 eBPF 指令来写 eBPF 程序则有可能遇到此类问题。

3、存在环

因为指令回跳会增加指令分析的复杂度,所以 verifier 直接禁止出现指令回跳。下面是一个使用 for 循环引入指令回跳的场景:

SEC("kprobe/tcp_sendmsg")
int BPF_KPROBE(tcp_sendmsg, struct sock *sk)
{
int i;
for (i=0;i<1000;i++)
bpf_printk("%d\n", i);
return 0;
}

上述eBPF代码在运行时,会报 back-edge from insn 12 to 2 错误。这个报错意思是 eBPF 程序的第 12 条指令跳转到第 2 条指令,形成会跳,即存在环。具体可以看下面的指令信息:

// bpftool
int tcp_sendmsg(struct pt_regs * ctx):
; int BPF_KPROBE(tcp_sendmsg, struct sock *sk)
0: (b7) r6 = 0
1: (b7) r7 = 680997
; bpf_printk("%d\n", i);
2: (63) *(u32 *)(r10 -4) = r7
3: (bf) r1 = r10
;
4: (07) r1 += -4
; bpf_printk("%d\n", i);
5: (b7) r2 = 4
6: (bf) r3 = r6
7: (85) call bpf_trace_printk#-57568
; for (i=0;i<1000;i++)
8: (07) r6 += 1
9: (bf) r1 = r6
10: (67) r1 <<= 32
11: (77) r1 >>= 32
; for (i=0;i<1000;i++)
12: (55) if r1 != 0x3e8 goto pc-11
; int BPF_KPROBE(tcp_sendmsg, struct sock *sk)
13: (b7) r0 = 0
14: (95) exit

对于该错误的一般解决方法是:在 for 循环前面添加 #pragma unroll,进行循环展开,避免指令回跳。

注意:在5.10内核版本是支持有限循环的,所以上述代码是可以在 5.10 内核正常运行。

4、第二次检查

verfier 第二次检查会遍历所有的分支,并记录寄存器状态。在此阶段,以下几种情况的 eBPF 程序将会被 verifier 拒绝:

  • 栈访问非法,如栈溢出、栈偏移为变量等;
  • helper 函数入参的参数类型不匹配;
  • 未做范围检查,可能导致内存访问越界;
  • 指针未对齐。

4.1、栈访问非法

栈访问也是我们写代码经常碰到的问题。

4.1.1、栈限制512字节

因为 verifier 会保存栈内存的状态,所以栈的大小是有限的,目前是 512 字节。当栈内存大小超过 512 字节时,则会被 verifier 拒绝。

SEC("kprobe/tcp_sendmsg")
int BPF_KPROBE(tcp_sendmsg, struct sock *sk)
{
#define MAX_NUM (512/8)
volatile u64 arr[MAX_NUM + 1] = {};
arr[MAX_NUM] = 0xff;
bpf_printk("%lld\n", arr[MAX_NUM]);
return 0;
}

上述程序在编译阶段会报错:Looks like the BPF stack limit of 512 bytes is exceeded. Please move large on stack variables into BPF per-cpu array map。

对于该错误,一般建议使用 map 来存储大数据。

注:因为我们是高级语言编程,所以这种容易检查出来的异常,编译器就直接报错了。如果直接使用 eBPF 指令,那么会由 verifier 拒绝程序的加载。

4.1.2、栈偏移仅支持常量

当访问栈时采用变量偏移,会导致无法推测寄存器的状态。所以 4.19 版本只支持常量偏移。下面是使用变量偏移的错误示例:

SEC("kprobe/tcp_sendmsg")
int BPF_KPROBE(tcp_sendmsg, struct sock *sk)
{
u64 volatile arr[16] = {};
arr[bpf_ktime_get_ns() & 0xf] = 0;
return 0;
}

当执行该程序时,会报如下错误:variable stack access var_off=(0x0; 0x78) off=-128 size=8-。对于该错误的一般解决方法是将 arr 数组保存到 map 里面。

注意:5.10内核已经支持变量类型的栈偏移。

4.2、helper 函数参数类型不匹配

verfier 会检查 eBPF 程序中所调用 helper 函数的参数类型,比如 bpf_map_lookup_elem helper 函数的参数类型约束定义如下:

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, /* const argument used as pointer to bpf_map */
.arg2_type = ARG_PTR_TO_MAP_KEY, /* pointer to stack used as map key */
};

所以对于 bpf_map_lookup_elem helper 函数来说,其参数 2 类型约束为ARG_PTR_TO_MAP_KEY,其表示指向栈的指针,即第二个参数的值必须存储在栈上。下面是一个错误的示例:

struct
{
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, struct sock *);
__type(value, struct sockmap_val);
__uint(max_entries, 1024);
} sockmap SEC(".maps");

struct sockmap_val
{
int nothing;
};

SEC("tracepoint/tcp/tcp_rcv_space_adjust")
int tp__tcp_rcv_space_adjust(struct trace_event_raw_tcp_event_sk *ctx)
{
struct sockmap_val *sv = bpf_map_lookup_elem(&sockmap, &ctx->skaddr);
if (sv)
bpf_printk("%d\n", sv->nothing);
return 0;
}

该程序会报错:R2 type=ctx expected=fp, pkt, pkt_meta, map_value。因为我们传的参数 ctx->skaddr 是 ctx 类型参数,非 fp 类型。对于该问题的一般解决方法是:定义一个栈变量,将 ctx->skaddr 的值存在栈上,即 u64 skaddr = ctx->skaddr 。

4.3、未做范围检查

范围检查主要是用来判断内存访问是否越界。

struct
{
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, int);
__type(value, int);
__uint(max_entries, 1024);
} indexmap SEC(".maps");

SEC("kprobe/tcp_sendmsg")
int BPF_KPROBE(tcp_sendmsg, struct sock *sk)
{
int map_key = 0;
int map_val = 0;
int array[10] = {};

int *map_val_ptr = bpf_map_look_up(&indexmap, &map_key);
if (map_val_ptr)
map_val = *map_val_ptr;

bpf_printk("array[%d] = %d\n", map_val, array[map_val]);
return 0;
}
char LICENSE[] SEC("license") = "GPL";

上述程序会报错:math between fp pointer and register with unbounded min value is not allowed。一般解决方法是:在内存访问时,进行范围检查。

5、经验总结

虽然 eBPF 程序可以采用 c 语言编写,但是相比于 c 语言的非安全性,eBPF 则通过严格的检查来保证 eBPF 程序安全,与此同时也引入了大量的约束条件。

另外,我们在写eBPF程序时,也会遇到其他的错误,比如我们不能去trace类似这样的函数,ip_rcv_finish_core.isra.16,它会作为perf trace事件的一个event name,而由于带了"."特殊符号,内核会检查不通过,导致运行失败。该问题在高版本内核已修复。

eBPF verifier常见错误浅析

还有一种错误是运行时libbpf未能加载有效的btf文件,如下图所示。造成这个问题的原因是/boot 路径下的btf默认只支持elf格式。解决这类问题的方法是升级libbpf,高版本libbpf中支持两种格式,也可以通过libbpf参数指定btf路径来解决。


eBPF verifier常见错误浅析

总之,我们通过具体的例子介绍了常见的 verifier 报错,那么编写eBPF程序有哪些需要注意的地方呢?下面是我们整理的一些tips(仅供参考,可能内核版本及libbpf版本不一样结果有所差异,如果需要可以自行再验证一下):

  • 两个指针不能做算术运算
  • 如果寄存器没有被写过,那么也不能读
  • call 返回时R1-R5被设置为不可读写,R0保存返回值
  • R6-R9 在call 执行中,值不会改变(保存到栈)
  • load/store 寄存器一定要是有效类型(PTR_TO_CTX,PTR_TO_MAP, PTR_TO_STACK)
  • 根据指针类型(PTR_TO_CTX,PTR_TO_MAP, PTR_TO_STACK),对访问的大小/对齐/边界进行校验
  • 只有先向对应堆栈写入数据,才能从堆栈读数据
  • 如果一个函数能够被eBPF访问,会严格检查传递的参数
  • 创建map时,没有使用的变量需要置0, value_size不能大于1<<(KMALLOC_SHIFT_MAX-1)
  • array map的元素总大小不能超过U32_MAX,也受进程RLIMIT_MEMLOCK的限制
  • && 要同一个类型,ptr && ptr , int && int
  • bpf_probe_read 的dst需要是栈空间
  • bpf_get_current_comm的dst需要是栈空间
  • bpf_map_update_elem参数都要是栈空间
  • 要对bpf_map_lookup_elem的返回做检测
  • map地址空间可以随意访问,但是其他内核空间地址,需要用bpf_probe_read

6、展望

当然,前面这些tips和错误解决方法都是我们实践经验的总结,只是需要大家在编码时注意,有时候也难免犯错,所谓常在河边走,哪有不湿鞋,最主要的还是靠我们去多写,多去用方可写出高效代码。

然而,即使我们再怎么注意,运行时出错提示如果能够更精确一些,明确告知我们代码在哪一行,那将是非常方便的。比如下图的错误,在低版本内核上没有明确提示,我们的bpf代码哪里出错(幸运的是,高版本内核配合高版本编译器,已经可以做到)?即使我们知道错误的大概原因,但也不能快速在大型代码工程里找到出错的行号,这将非常痛苦。

eBPF verifier常见错误浅析

至此,你是否有想过,这种错误提示,我们能否让它在低版本内核也支持起来?是的,不久你就会看到,在Coolbpf里我们采取一种间接的方式,去简化报错信息和代码的对应关系。敬请期待。