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() 回调,验证器将知道可以读取大小为 4 字节的偏移量 8,否则验证器将拒绝该程序。 如果 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 程序的安全性,验证器必须跟踪每个寄存器以及每个堆栈槽中可能值的范围。 这是使用 struct bpf_reg_state 完成的,该结构在 include/linux/bpf_verifier.h 中定义,它统一了对标量值和指针值的跟踪。 每个寄存器状态都具有类型,该类型可以是 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_VALUE 中,以跟踪寄存器中可能值的范围。

验证器对可变偏移量的了解包括:

  • 作为无符号数的最小值和最大值

  • 作为有符号数的最小值和最大值

  • 以 'tnum' 形式了解各个位的值:u64 'mask' 和 u64 'value'。掩码中的 1 表示其值未知的位;值中的 1 表示已知为 1 的位。已知为 0 的位在掩码和值中都为 0;任何位都不应同时在两者中都为 1。例如,如果从内存中将一个字节读入寄存器,则该寄存器的高 56 位已知为零,而低 8 位是未知的 - 这表示为 tnum (0x0; 0xff)。如果我们然后将其与 0x40 进行或运算,则得到 (0x40; 0xbf),然后如果加 1,则由于可能发生进位,我们得到 (0x0; 0x1ff)。

除了算术运算外,寄存器状态还可以通过条件分支进行更新。 例如,如果将 SCALAR_VALUE 与 > 8 进行比较,则在“真”分支中,其 umin_value(无符号最小值)为 9,而在“假”分支中,其 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,它在从映射查找返回的指针的所有副本中通用。这意味着当一个副本被检查并发现为非 NULL 时,所有副本都可以变为 PTR_TO_MAP_VALUE。除了范围检查之外,跟踪的信息还用于强制指针访问的对齐。例如,在大多数系统中,数据包指针在 4 字节对齐后偏移 2 个字节。如果程序在此基础上增加 14 个字节以跳过以太网头部,然后读取 IHL 并加上 (IHL * 4),则生成的指针将具有一个可变的偏移量,已知该偏移量对于某个 n 为 4n+2,因此加上 2 个字节 (NET_IP_ALIGN) 将给出 4 字节对齐,因此通过该指针进行的字大小访问是安全的。 ‘id’ 字段也用于 PTR_TO_SOCKET 和 PTR_TO_SOCKET_OR_NULL,它在从套接字查找返回的指针的所有副本中通用。这具有与 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 字节是安全的,因为程序作者在 insn #5 处检查了 if (skb->data + 14 > skb->data_end) goto err,这意味着在 fall-through 的情况下,寄存器 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 指向数据包中的某个偏移量,并且由于程序作者在 insn #18 处执行了 if (r3 + 8 > r1) goto err,因此安全范围是 [R3, R3 + 8)。 验证器仅允许对数据包寄存器执行 “add”/“sub” 操作。任何其他操作都会将寄存器状态设置为 ‘SCALAR_VALUE’,并且它将不可用于直接数据包访问。

操作 r3 += rX 可能会溢出并变得小于原始的 skb->data,因此验证器必须防止这种情况。 因此,当它看到 r3 += rX 指令并且 rX 大于 16 位值时,任何后续对 r3 与 skb->data_end 的边界检查都不会为我们提供 ‘范围’ 信息,因此尝试通过该指针读取将给出 “无效的数据包访问” 错误。

例如,在指令 r4 = *(u8 *)(r3 +12) (上面 insn #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_DONEclean_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_INITSTACK_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 处,到达退出点,现在可以通过 clean_live_states() 处理 checkpoint[0]。经过此处理后,checkpoint[0].r1 具有读取标记,所有其他寄存器和堆栈槽都被标记为 NOT_INITSTACK_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 的值在 CE 之间不同。

了解 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,但以错误对齐方式访问内存的程序

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

在 ‘if’ 分支的一侧正确检查 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, 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