eBPF 校验器¶
eBPF 程序的安全性分两步确定。
第一步进行 DAG 检查以禁止循环和其他 CFG 验证。特别是,它会检测包含不可达指令的程序。(尽管经典 BPF 检查器允许它们)
第二步从第一条指令开始,并遍历所有可能的路径。它模拟每条指令的执行并观察寄存器和堆栈的状态变化。
在程序开始时,寄存器 R1 包含一个指向上下文的指针,其类型为 PTR_TO_CTX。如果校验器看到一条执行 R2=R1 的指令,那么 R2 现在也具有 PTR_TO_CTX 类型,并且可以用于表达式的右侧。如果 R1=PTR_TO_CTX 且指令为 R2=R1+R1,则 R2=SCALAR_VALUE,因为两个有效指针的相加会产生无效指针。(在“安全”模式下,校验器将拒绝任何类型的指针算术,以确保内核地址不会泄露给非特权用户)
如果寄存器从未被写入,则不可读
bpf_mov R0 = R2
bpf_exit
将被拒绝,因为在程序开始时 R2 是不可读的。
在内核函数调用之后,R1-R5 被重置为不可读,R0 具有函数的返回类型。
由于 R6-R9 是被调用者保存的,它们的状态在调用后得以保留。
bpf_mov R6 = 1
bpf_call foo
bpf_mov R0 = R6
bpf_exit
是一个正确的程序。如果这里是 R1 而不是 R6,它就会被拒绝。
加载/存储指令只允许使用有效类型的寄存器,即 PTR_TO_CTX、PTR_TO_MAP、PTR_TO_STACK。它们会进行边界和对齐检查。例如
bpf_mov R1 = 1
bpf_mov R2 = 2
bpf_xadd *(u32 *)(R1 + 3) += R2
bpf_exit
将被拒绝,因为在执行 bpf_xadd 指令时 R1 没有有效的指针类型。
开始时,R1 的类型是 PTR_TO_CTX(指向通用 struct bpf_context
的指针)。回调函数用于自定义校验器,以限制 eBPF 程序对 ctx 结构中具有指定大小和对齐的某些字段的访问。
例如,以下指令
bpf_ld R0 = *(u32 *)(R6 + 8)
打算从地址 R6 + 8 加载一个字并将其存储到 R0。如果 R6=PTR_TO_CTX,校验器将通过 is_valid_access() 回调知道偏移量 8(大小 4 字节)可以被读取,否则校验器将拒绝该程序。如果 R6=PTR_TO_STACK,则访问应该对齐并在堆栈边界内,即 [-MAX_BPF_STACK, 0)。在此示例中,偏移量为 8,因此它将失败验证,因为它超出边界。
校验器将只允许 eBPF 程序在写入堆栈后从堆栈读取数据。
经典 BPF 校验器对 M[0-15] 内存槽进行类似检查。例如
bpf_ld R0 = *(u32 *)(R10 - 4)
bpf_exit
是无效程序。尽管 R10 是正确的只读寄存器,并且类型为 PTR_TO_STACK,R10 - 4 在堆栈边界内,但该位置没有进行存储操作。
指针寄存器溢出/填充也会被跟踪,因为四个(R6-R9)被调用者保存的寄存器可能不足以满足某些程序的需求。
允许的函数调用通过 bpf_verifier_ops->get_func_proto() 进行自定义。eBPF 校验器将检查寄存器是否符合参数约束。调用后,寄存器 R0 将被设置为函数的返回类型。
函数调用是扩展 eBPF 程序功能的主要机制。套接字过滤器可能允许程序调用一组函数,而跟踪过滤器可能允许完全不同的一组。
如果函数可供 eBPF 程序访问,则需要从安全角度进行周密考虑。校验器将确保函数使用有效参数进行调用。
seccomp 与套接字过滤器对经典 BPF 有不同的安全限制。Seccomp 通过两阶段校验器解决此问题:经典 BPF 校验器之后是 seccomp 校验器。在 eBPF 的情况下,一个可配置的校验器被所有用例共享。
有关 eBPF 校验器的详细信息,请参见 kernel/bpf/verifier.c
寄存器值跟踪¶
为了确定 eBPF 程序的安全性,校验器必须跟踪每个寄存器以及每个堆栈槽中可能值的范围。这是通过定义在 include/linux/bpf_verifier.h 中的 struct bpf_reg_state
完成的,它统一了标量和指针值的跟踪。每个寄存器状态都有一个类型,要么是 NOT_INIT(寄存器尚未写入),SCALAR_VALUE(无法用作指针的某个值),要么是指针类型。指针的类型描述它们的基址,如下所示:
- PTR_TO_CTX
指向 bpf_context 的指针。
- CONST_PTR_TO_MAP
指向 struct bpf_map 的指针。“Const”是因为禁止对这些指针进行算术运算。
- PTR_TO_MAP_VALUE
指向映射元素中存储的值的指针。
- PTR_TO_MAP_VALUE_OR_NULL
要么是指向映射值的指针,要么是 NULL;映射访问(参见 BPF 映射)返回此类型,当检查 != NULL 时,此类型变为 PTR_TO_MAP_VALUE。禁止对这些指针进行算术运算。
- PTR_TO_STACK
帧指针。
- PTR_TO_PACKET
skb->data。
- PTR_TO_PACKET_END
skb->data + headlen;禁止算术运算。
- PTR_TO_SOCKET
指向 struct bpf_sock_ops 的指针,隐式引用计数。
- PTR_TO_SOCKET_OR_NULL
要么是指向套接字的指针,要么是 NULL;套接字查找返回此类型,当检查 != NULL 时,此类型变为 PTR_TO_SOCKET。PTR_TO_SOCKET 是引用计数的,因此程序必须在程序结束前通过套接字释放函数释放引用。禁止对这些指针进行算术运算。
然而,指针可能相对于其基址有偏移量(作为指针算术的结果),这分为两部分进行跟踪:“固定偏移量”和“可变偏移量”。前者用于将一个精确已知的值(例如立即数操作数)添加到指针时,而后者用于不精确已知的值。可变偏移量也用于 SCALAR_VALUEs,以跟踪寄存器中可能值的范围。
校验器对可变偏移量的了解包括
无符号的最小值和最大值
有符号的最小值和最大值
关于各个位值的知识,以“tnum”的形式:一个 u64 的“mask”和一个 u64 的“value”。mask 中的 1 表示未知其值的位;value 中的 1 表示已知为 1 的位。已知为 0 的位在 mask 和 value 中都为 0;任何位都不应该在两者中都为 1。例如,如果从内存中读取一个字节到寄存器中,寄存器的最高 56 位已知为零,而最低 8 位未知——这表示为 tnum (0x0; 0xff)。如果我们然后将其与 0x40 进行 OR 运算,我们得到 (0x40; 0xbf),然后如果我们加 1,我们得到 (0x0; 0x1ff),因为存在潜在的进位。
除了算术运算,寄存器状态也可以通过条件分支更新。例如,如果一个 SCALAR_VALUE 被比较 > 8,在“true”分支中它将具有 umin_value(无符号最小值)9,而在“false”分支中它将具有 umax_value 8。有符号比较(使用 BPF_JSGT 或 BPF_JSGE)将改为更新有符号最小值/最大值。来自有符号和无符号边界的信息可以组合;例如,如果一个值首先测试 < 8,然后测试 s> 4,校验器将得出结论,该值也 > 4 且 s< 8,因为边界阻止跨越符号边界。
具有可变偏移部分的 PTR_TO_PACKET 具有一个“id”,所有共享相同可变偏移的指针都具有该 id。这对于数据包范围检查很重要:在将变量添加到数据包指针寄存器 A 后,如果您将其复制到另一个寄存器 B,然后向 A 添加一个常量 4,则两个寄存器将共享相同的“id”,但 A 将具有 +4 的固定偏移量。然后,如果对 A 进行边界检查并发现其小于 PTR_TO_PACKET_END,则寄存器 B 现在已知具有至少 4 字节的安全范围。有关 PTR_TO_PACKET 范围的更多信息,请参阅下面的“直接数据包访问”。
“id”字段也用于 PTR_TO_MAP_VALUE_OR_NULL,所有从映射查找返回的指针副本都具有此 id。这意味着当一个副本被检查并发现非 NULL 时,所有副本都可以变为 PTR_TO_MAP_VALUEs。除了范围检查,跟踪信息还用于强制执行指针访问的对齐。例如,在大多数系统上,数据包指针在 4 字节对齐后是 2 字节。如果程序向其添加 14 字节以跳过以太网头,然后读取 IHL 并添加 (IHL * 4),则结果指针将具有已知为 4n+2(对于某些 n)的可变偏移量,因此添加 2 字节 (NET_IP_ALIGN) 会给出 4 字节对齐,因此通过该指针的字大小访问是安全的。“id”字段也用于 PTR_TO_SOCKET 和 PTR_TO_SOCKET_OR_NULL,所有从套接字查找返回的指针副本都具有此 id。这与 PTR_TO_MAP_VALUE_OR_NULL->PTR_TO_MAP_VALUE 的处理方式类似,但它也处理指针的引用跟踪。PTR_TO_SOCKET 隐式表示对相应 struct sock
的引用。为确保引用不泄漏,必须对引用进行 NULL 检查,并在非 NULL 情况下,将有效引用传递给套接字释放函数。
直接数据包访问¶
在 cls_bpf 和 act_bpf 程序中,校验器允许通过 skb->data 和 skb->data_end 指针直接访问数据包数据。例如:
1: r4 = *(u32 *)(r1 +80) /* load skb->data_end */
2: r3 = *(u32 *)(r1 +76) /* load skb->data */
3: r5 = r3
4: r5 += 14
5: if r5 > r4 goto pc+16
R1=ctx R3=pkt(id=0,off=0,r=14) R4=pkt_end R5=pkt(id=0,off=14,r=14) R10=fp
6: r0 = *(u16 *)(r3 +12) /* access 12 and 13 bytes of the packet */
从数据包加载这 2 字节是安全的,因为程序作者在指令 #5 处进行了检查 if (skb->data + 14 > skb->data_end) goto err
,这意味着在通过的情况下,寄存器 R3(指向 skb->data)至少有 14 个可直接访问的字节。校验器将其标记为 R3=pkt(id=0,off=0,r=14)。id=0 意味着没有向寄存器添加额外的变量。off=0 意味着没有添加额外的常量。r=14 是安全访问的范围,这意味着字节 [R3, R3 + 14) 是安全的。请注意,R5 被标记为 R5=pkt(id=0,off=14,r=14)。它也指向数据包数据,但向寄存器添加了常量 14,因此它现在指向 skb->data + 14
,可访问范围是 [R5, R5 + 14 - 14),即零字节。
更复杂的数据包访问可能如下所示:
R0=inv1 R1=ctx R3=pkt(id=0,off=0,r=14) R4=pkt_end R5=pkt(id=0,off=14,r=14) R10=fp
6: r0 = *(u8 *)(r3 +7) /* load 7th byte from the packet */
7: r4 = *(u8 *)(r3 +12)
8: r4 *= 14
9: r3 = *(u32 *)(r1 +76) /* load skb->data */
10: r3 += r4
11: r2 = r1
12: r2 <<= 48
13: r2 >>= 48
14: r3 += r2
15: r2 = r3
16: r2 += 8
17: r1 = *(u32 *)(r1 +80) /* load skb->data_end */
18: if r2 > r1 goto pc+2
R0=inv(id=0,umax_value=255,var_off=(0x0; 0xff)) R1=pkt_end R2=pkt(id=2,off=8,r=8) R3=pkt(id=2,off=0,r=8) R4=inv(id=0,umax_value=3570,var_off=(0x0; 0xfffe)) R5=pkt(id=0,off=14,r=14) R10=fp
19: r1 = *(u8 *)(r3 +4)
寄存器 R3 的状态为 R3=pkt(id=2,off=0,r=8)。id=2 意味着看到了两条 r3 += rX
指令,因此 r3 指向数据包内的某个偏移量,并且由于程序作者在指令 #18 处执行了 if (r3 + 8 > r1) goto err
,因此安全范围是 [R3, R3 + 8)。校验器只允许对数据包寄存器执行“加”/“减”操作。任何其他操作都将把寄存器状态设置为“SCALAR_VALUE”,并且它将无法用于直接数据包访问。
操作 r3 += rX
可能会溢出并变得小于原始 skb->data,因此校验器必须防止这种情况。所以当它看到 r3 += rX
指令并且 rX 大于 16 位值时,任何后续对 r3 与 skb->data_end 的边界检查都不会给我们“范围”信息,因此尝试通过指针读取将导致“无效访问数据包”错误。
例如,在指令 r4 = *(u8 *)(r3 +12)
(上文指令 #7)之后,r4 的状态是 R4=inv(id=0,umax_value=255,var_off=(0x0; 0xff)),这意味着寄存器的高 56 位保证为零,而低 8 位则未知。在指令 r4 *= 14
之后,状态变为 R4=inv(id=0,umax_value=3570,var_off=(0x0; 0xfffe)),因为将一个 8 位值乘以常量 14 会使高 52 位保持为零,同时最低有效位也将为零,因为 14 是偶数。类似地,r2 >>= 48
将使 R2=inv(id=0,umax_value=65535,var_off=(0x0; 0xffff)),因为移位不是符号扩展。此逻辑在 adjust_reg_min_max_vals() 函数中实现,该函数调用 adjust_ptr_min_max_vals() 用于将指针添加到标量(反之亦然),并调用 adjust_scalar_min_max_vals() 用于对两个标量进行操作。
最终结果是 bpf 程序作者可以使用普通的 C 代码直接访问数据包,如下所示:
void *data = (void *)(long)skb->data;
void *data_end = (void *)(long)skb->data_end;
struct eth_hdr *eth = data;
struct iphdr *iph = data + sizeof(*eth);
struct udphdr *udp = data + sizeof(*eth) + sizeof(*iph);
if (data + sizeof(*eth) + sizeof(*iph) + sizeof(*udp) > data_end)
return 0;
if (eth->h_proto != htons(ETH_P_IP))
return 0;
if (iph->protocol != IPPROTO_UDP || iph->ihl != 5)
return 0;
if (udp->dest == 53 || udp->source == 9)
...;
这使得此类程序比 LD_ABS 指令更容易编写,并且速度显著提高。
剪枝¶
校验器实际上不会遍历程序的所有可能路径。对于每个要分析的新分支,校验器会查看它在当前指令处之前所处的所有状态。如果其中任何一个状态包含当前状态作为子集,则该分支被“剪枝”——也就是说,之前状态被接受的事实意味着当前状态也会被接受。例如,如果在之前的状态中,r1 持有一个数据包指针,而在当前状态中,r1 持有一个数据包指针,其范围更长或更长,并且对齐方式至少一样严格,那么 r1 是安全的。类似地,如果 r2 之前是 NOT_INIT,那么从那时起它就不能被任何路径使用,因此 r2 中的任何值(包括另一个 NOT_INIT)都是安全的。该实现在函数 regsafe() 中。剪枝不仅考虑寄存器,还考虑堆栈(以及它可能持有的任何溢出寄存器)。它们都必须是安全的,才能进行分支剪枝。这在 states_equal() 中实现。
有关状态剪枝实现的一些技术细节可在下文找到。
寄存器活跃度跟踪¶
为了使状态剪枝有效,会跟踪每个寄存器和堆栈槽的活跃度状态。基本思想是跟踪在程序后续执行中实际使用了哪些寄存器和堆栈槽,直到程序退出。从未使用的寄存器和堆栈槽可以从缓存状态中移除,从而使更多状态等同于缓存状态。这可以通过以下程序说明:
0: call bpf_get_prandom_u32()
1: r1 = 0
2: if r0 == 0 goto +1
3: r0 = 1
--- checkpoint ---
4: r0 = r1
5: exit
假设在指令 #4 处创建了一个状态缓存条目(此类条目在下文中也称为“检查点”)。校验器可以通过两种可能的寄存器状态之一到达该指令:
r0 = 1, r1 = 0
r0 = 0, r1 = 0
然而,只有寄存器 r1
的值对于成功完成验证是重要的。活跃度跟踪算法的目标是发现这个事实并找出这两种状态实际上是等价的。
数据结构¶
活跃度使用以下数据结构进行跟踪:
enum bpf_reg_liveness {
REG_LIVE_NONE = 0,
REG_LIVE_READ32 = 0x1,
REG_LIVE_READ64 = 0x2,
REG_LIVE_READ = REG_LIVE_READ32 | REG_LIVE_READ64,
REG_LIVE_WRITTEN = 0x4,
REG_LIVE_DONE = 0x8,
};
struct bpf_reg_state {
...
struct bpf_reg_state *parent;
...
enum bpf_reg_liveness live;
...
};
struct bpf_stack_state {
struct bpf_reg_state spilled_ptr;
...
};
struct bpf_func_state {
struct bpf_reg_state regs[MAX_BPF_REG];
...
struct bpf_stack_state *stack;
}
struct bpf_verifier_state {
struct bpf_func_state *frame[MAX_CALL_FRAMES];
struct bpf_verifier_state *parent;
...
}
REG_LIVE_NONE
是新校验器状态创建时分配给->live
字段的初始值;REG_LIVE_WRITTEN
表示寄存器(或堆栈槽)的值由在此校验器状态的父状态和校验器状态本身之间验证的某些指令定义;REG_LIVE_READ{32,64}
表示寄存器(或堆栈槽)的值由该校验器状态的某个子状态读取;REG_LIVE_DONE
是clean_verifier_state()
使用的标记,用于避免多次处理同一校验器状态以及进行一些健全性检查;->live
字段的值是通过使用按位或组合enum bpf_reg_liveness
值形成的。
寄存器父子链¶
为了在父状态和子状态之间传播信息,建立了 寄存器父子链。每个寄存器或堆栈槽通过 ->parent
指针链接到其父状态中对应的寄存器或堆栈槽。此链接在 is_state_visited()
中创建状态时建立,并可能由从 __check_func_call()
调用的 set_callee_state()
修改。
寄存器/堆栈槽之间的对应规则如下:
对于当前堆栈帧,新状态的寄存器和堆栈槽链接到父状态中具有相同索引的寄存器和堆栈槽。
对于外部堆栈帧,只有被调用者保存的寄存器 (r6-r9) 和堆栈槽链接到父状态中具有相同索引的寄存器和堆栈槽。
当处理函数调用时,会分配一个新的
struct bpf_func_state
实例,它封装了一组新的寄存器和堆栈槽。对于这个新帧,r6-r9 和堆栈槽的父链接被设置为 nil,r1-r5 的父链接被设置为匹配调用者的 r1-r5 父链接。
这可以通过以下图表说明(箭头表示 ->parent
指针):
... ; Frame #0, some instructions
--- checkpoint #0 ---
1 : r6 = 42 ; Frame #0
--- checkpoint #1 ---
2 : call foo() ; Frame #0
... ; Frame #1, instructions from foo()
--- checkpoint #2 ---
... ; Frame #1, instructions from foo()
--- checkpoint #3 ---
exit ; Frame #1, return from foo()
3 : r1 = r6 ; Frame #0 <- current state
+-------------------------------+-------------------------------+
| Frame #0 | Frame #1 |
Checkpoint +-------------------------------+-------------------------------+
#0 | r0 | r1-r5 | r6-r9 | fp-8 ... |
+-------------------------------+
^ ^ ^ ^
| | | |
Checkpoint +-------------------------------+
#1 | r0 | r1-r5 | r6-r9 | fp-8 ... |
+-------------------------------+
^ ^ ^
|_______|_______|_______________
| | |
nil nil | | | nil nil
| | | | | | |
Checkpoint +-------------------------------+-------------------------------+
#2 | r0 | r1-r5 | r6-r9 | fp-8 ... | r0 | r1-r5 | r6-r9 | fp-8 ... |
+-------------------------------+-------------------------------+
^ ^ ^ ^ ^
nil nil | | | | |
| | | | | | |
Checkpoint +-------------------------------+-------------------------------+
#3 | r0 | r1-r5 | r6-r9 | fp-8 ... | r0 | r1-r5 | r6-r9 | fp-8 ... |
+-------------------------------+-------------------------------+
^ ^
nil nil | |
| | | |
Current +-------------------------------+
state | r0 | r1-r5 | r6-r9 | fp-8 ... |
+-------------------------------+
\
r6 read mark is propagated via these links
all the way up to checkpoint #1.
The checkpoint #1 contains a write mark for r6
because of instruction (1), thus read propagation
does not reach checkpoint #0 (see section below).
活跃度标记跟踪¶
对于每条已处理的指令,校验器会跟踪读取和写入的寄存器和堆栈槽。该算法的核心思想是,读取标记沿着状态父链向后传播,直到它们遇到写入标记,该标记“屏蔽”了早期状态的读取。有关读取的信息由函数 mark_reg_read()
传播,其总结如下:
mark_reg_read(struct bpf_reg_state *state, ...):
parent = state->parent
while parent:
if state->live & REG_LIVE_WRITTEN:
break
if parent->live & REG_LIVE_READ64:
break
parent->live |= REG_LIVE_READ64
state = parent
parent = state->parent
注意:
读取标记应用于 父 状态,而写入标记应用于 当前 状态。寄存器或堆栈槽上的写入标记意味着它已在从父状态到当前状态的直线代码中的某个指令更新。
关于 REG_LIVE_READ32 的细节已省略。
函数
propagate_liveness()
(参见 缓存命中时读取标记的传播 一节)可能会覆盖第一个父链接。有关更多详细信息,请参阅propagate_liveness()
和mark_reg_read()
源代码中的注释。
由于堆栈写入可能具有不同的大小,REG_LIVE_WRITTEN
标记会保守地应用:堆栈槽仅在写入大小与寄存器大小对应时才标记为已写入,例如参见函数 save_register_state()
。
考虑以下示例:
0: (*u64)(r10 - 8) = 0 ; define 8 bytes of fp-8
--- checkpoint #0 ---
1: (*u32)(r10 - 8) = 1 ; redefine lower 4 bytes
2: r1 = (*u32)(r10 - 8) ; read lower 4 bytes defined at (1)
3: r2 = (*u32)(r10 - 4) ; read upper 4 bytes defined at (0)
如上所述,(1) 处的写入不计为 REG_LIVE_WRITTEN
。如果不是这样,上述算法将无法将读取标记从 (3) 传播到检查点 #0。
一旦达到 BPF_EXIT
指令,将调用 update_branch_counts()
来更新父校验器状态链中每个校验器状态的 ->branches
计数器。当 ->branches
计数器达到零时,校验器状态将成为一组缓存校验器状态中的有效条目。
校验器状态缓存的每个条目都由函数 clean_live_states()
进行后处理。此函数会将所有没有 REG_LIVE_READ{32,64}
标记的寄存器和堆栈槽标记为 NOT_INIT
或 STACK_INVALID
。以这种方式标记的寄存器/堆栈槽在从 states_equal()
调用的 stacksafe()
函数中被忽略,当一个状态缓存条目被考虑与当前状态等价时。
现在可以解释本节开头的示例是如何工作的:
0: call bpf_get_prandom_u32()
1: r1 = 0
2: if r0 == 0 goto +1
3: r0 = 1
--- checkpoint[0] ---
4: r0 = r1
5: exit
在指令 #2 处,达到分支点,状态
{ r0 == 0, r1 == 0, pc == 4 }
被推入状态处理队列(pc 代表程序计数器)。在指令 #4
创建
checkpoint[0]
状态缓存条目:{ r0 == 1, r1 == 0, pc == 4 }
;checkpoint[0].r0
被标记为已写入;checkpoint[0].r1
被标记为已读取;
在指令 #5 处,达到退出点,
checkpoint[0]
现在可以由clean_live_states()
处理。处理后,checkpoint[0].r1
带有读取标记,所有其他寄存器和堆栈槽被标记为NOT_INIT
或STACK_INVALID
。状态
{ r0 == 0, r1 == 0, pc == 4 }
从状态队列中弹出,并与缓存状态{ r1 == 0, pc == 4 }
进行比较,这些状态被认为是等价的。
缓存命中时读取标记的传播¶
另一点是当在状态缓存中找到先前验证过的状态时,读取标记的处理。缓存命中时,校验器必须以与当前状态已验证到程序退出相同的方式行为。这意味着缓存状态的寄存器和堆栈槽上存在的所有读取标记必须沿着当前状态的父链传播。下面的示例说明了为什么这很重要。函数 propagate_liveness()
处理这种情况。
考虑以下状态父链(S 是起始状态,A-E 是派生状态,-> 箭头表示哪个状态由哪个状态派生)
r1 read
<------------- A[r1] == 0
C[r1] == 0
S ---> A ---> B ---> exit E[r1] == 1
|
` ---> C ---> D
|
` ---> E ^
|___ suppose all these
^ states are at insn #Y
|
suppose all these
states are at insn #X
首先验证状态链
S -> A -> B -> exit
。在验证
B -> exit
时,寄存器r1
被读取,此读取标记向上传播到状态A
。当状态链
C -> D
被验证时,状态D
被证明与状态B
等价。必须将
r1
的读取标记传播到状态C
,否则状态C
可能会被错误地标记为与状态E
等价,尽管寄存器r1
的值在C
和E
之间存在差异。
理解 eBPF 校验器消息¶
以下是一些无效 eBPF 程序和校验器错误消息的示例(如日志中所示):
包含不可达指令的程序
static struct bpf_insn prog[] = {
BPF_EXIT_INSN(),
BPF_EXIT_INSN(),
};
错误
unreachable insn 1
读取未初始化寄存器的程序
BPF_MOV64_REG(BPF_REG_0, BPF_REG_2),
BPF_EXIT_INSN(),
错误
0: (bf) r0 = r2
R2 !read_ok
退出前未初始化 R0 的程序
BPF_MOV64_REG(BPF_REG_2, BPF_REG_1),
BPF_EXIT_INSN(),
错误
0: (bf) r2 = r1
1: (95) exit
R0 !read_ok
访问越界堆栈的程序
BPF_ST_MEM(BPF_DW, BPF_REG_10, 8, 0),
BPF_EXIT_INSN(),
错误
0: (7a) *(u64 *)(r10 +8) = 0
invalid stack off=8 size=8
在将堆栈地址传递给函数之前未初始化堆栈的程序
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -8),
BPF_LD_MAP_FD(BPF_REG_1, 0),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
BPF_EXIT_INSN(),
错误
0: (bf) r2 = r10
1: (07) r2 += -8
2: (b7) r1 = 0x0
3: (85) call 1
invalid indirect read from stack off -8+0 size 8
调用 map_lookup_elem() 函数时使用无效 map_fd=0 的程序
BPF_ST_MEM(BPF_DW, BPF_REG_10, -8, 0),
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -8),
BPF_LD_MAP_FD(BPF_REG_1, 0),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
BPF_EXIT_INSN(),
错误
0: (7a) *(u64 *)(r10 -8) = 0
1: (bf) r2 = r10
2: (07) r2 += -8
3: (b7) r1 = 0x0
4: (85) call 1
fd 0 is not pointing to valid bpf_map
在访问映射元素之前未检查 map_lookup_elem() 返回值的程序
BPF_ST_MEM(BPF_DW, BPF_REG_10, -8, 0),
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -8),
BPF_LD_MAP_FD(BPF_REG_1, 0),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
BPF_ST_MEM(BPF_DW, BPF_REG_0, 0, 0),
BPF_EXIT_INSN(),
错误
0: (7a) *(u64 *)(r10 -8) = 0
1: (bf) r2 = r10
2: (07) r2 += -8
3: (b7) r1 = 0x0
4: (85) call 1
5: (7a) *(u64 *)(r0 +0) = 0
R0 invalid mem access 'map_value_or_null'
程序正确检查 map_lookup_elem() 返回值是否为 NULL,但在“if”分支的一侧以不正确的对齐方式访问内存
BPF_ST_MEM(BPF_DW, BPF_REG_10, -8, 0),
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -8),
BPF_LD_MAP_FD(BPF_REG_1, 0),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
BPF_JMP_IMM(BPF_JEQ, BPF_REG_0, 0, 1),
BPF_ST_MEM(BPF_DW, BPF_REG_0, 4, 0),
BPF_EXIT_INSN(),
错误
0: (7a) *(u64 *)(r10 -8) = 0
1: (bf) r2 = r10
2: (07) r2 += -8
3: (b7) r1 = 1
4: (85) call 1
5: (15) if r0 == 0x0 goto pc+1
R0=map_ptr R10=fp
6: (7a) *(u64 *)(r0 +4) = 0
misaligned access off 4 size 8
程序正确检查 map_lookup_elem() 返回值是否为 NULL,并在 'if' 分支的一侧以正确的对齐方式访问内存,但在 'if' 分支的另一侧未能做到这一点
BPF_ST_MEM(BPF_DW, BPF_REG_10, -8, 0),
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -8),
BPF_LD_MAP_FD(BPF_REG_1, 0),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
BPF_JMP_IMM(BPF_JEQ, BPF_REG_0, 0, 2),
BPF_ST_MEM(BPF_DW, BPF_REG_0, 0, 0),
BPF_EXIT_INSN(),
BPF_ST_MEM(BPF_DW, BPF_REG_0, 0, 1),
BPF_EXIT_INSN(),
错误
0: (7a) *(u64 *)(r10 -8) = 0
1: (bf) r2 = r10
2: (07) r2 += -8
3: (b7) r1 = 1
4: (85) call 1
5: (15) if r0 == 0x0 goto pc+2
R0=map_ptr R10=fp
6: (7a) *(u64 *)(r0 +0) = 0
7: (95) exit
from 5 to 8: R0=imm0 R10=fp
8: (7a) *(u64 *)(r0 +0) = 1
R0 invalid mem access 'imm'
执行套接字查找然后不检查就将指针设置为 NULL 的程序
BPF_MOV64_IMM(BPF_REG_2, 0),
BPF_STX_MEM(BPF_W, BPF_REG_10, BPF_REG_2, -8),
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -8),
BPF_MOV64_IMM(BPF_REG_3, 4),
BPF_MOV64_IMM(BPF_REG_4, 0),
BPF_MOV64_IMM(BPF_REG_5, 0),
BPF_EMIT_CALL(BPF_FUNC_sk_lookup_tcp),
BPF_MOV64_IMM(BPF_REG_0, 0),
BPF_EXIT_INSN(),
错误
0: (b7) r2 = 0
1: (63) *(u32 *)(r10 -8) = r2
2: (bf) r2 = r10
3: (07) r2 += -8
4: (b7) r3 = 4
5: (b7) r4 = 0
6: (b7) r5 = 0
7: (85) call bpf_sk_lookup_tcp#65
8: (b7) r0 = 0
9: (95) exit
Unreleased reference id=1, alloc_insn=7
执行套接字查找但未对返回值进行 NULL 检查的程序
BPF_MOV64_IMM(BPF_REG_2, 0),
BPF_STX_MEM(BPF_W, BPF_REG_10, BPF_REG_2, -8),
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -8),
BPF_MOV64_IMM(BPF_REG_3, 4),
BPF_MOV64_IMM(BPF_REG_4, 0),
BPF_MOV64_IMM(BPF_REG_5, 0),
BPF_EMIT_CALL(BPF_FUNC_sk_lookup_tcp),
BPF_EXIT_INSN(),
错误
0: (b7) r2 = 0
1: (63) *(u32 *)(r10 -8) = r2
2: (bf) r2 = r10
3: (07) r2 += -8
4: (b7) r3 = 4
5: (b7) r4 = 0
6: (b7) r5 = 0
7: (85) call bpf_sk_lookup_tcp#65
8: (95) exit
Unreleased reference id=1, alloc_insn=7