ebpf-verifier
ebpf verifier
简介
验证流程
概述:
- DAG检查
- 构建指令的DAG(有向无环图),禁止循环结构
- 经检测无法到达的指令,确保CFG(控制流图)的有效性
- 路径模拟
- 从第一条指令开始,遍历所有可能的执行路径
- 模拟每条指令的执行,观察寄存器和堆栈的状态变化
寄存器与类型追踪
初始状态: R1指向上下文的指针,类型为PTR_TO_CTX
类型传播与限制:
R2=R1
:R2继承PTR_TO_CTX
类型R2=R1+R1
:R2被视为SCALAR_VALUE
,因为两个指针相加产生无效指针- 在”安全“模式下,验证器拒绝任何类型的指针算术,以防止内核地址泄露给非特权用户
寄存器可读性:
-
未被写入的寄存器不饿可都
-
示例:
bpf_mov R0 = R2 bpf_exit
R2在程序开始时是不可读的
函数调用后的寄存器状态:
- 调用内核函数后,R1-R5被重置为不可读
- R0:设置为函数的返回类型
- R6-R9:callee(被调用者)保存的寄存器,状态在调用之间保持不变
内存访问规则
有效指针类型: 仅允许使用以下类型的寄存器机型加载/存储指令
PTR_TO_CTX
PTR_TO_MAP
PTR_TO_STACK
边界和对齐检查:
-
验证器会进行边界和对齐检查,确保访问的内存地址合法
-
示例:
bpf_mov R1 = 1 bpf_mov R2 = 2 bpf_xadd *(u32 *)(R1 + 3) += R2
R1没有有效的指针类型
上下文访问控制:
-
使用回调函数
is_valid_access()
限制ebpf程序对上下文结构中字段的访问 -
示例:
bpf_id R0 = *(u32 *)(R6 + 8)
如果R6为
PTR_TO_CTX
,验证器会检查偏移量8是否可读
堆栈访问限制:
-
堆栈访问必须在边界[-MAX_BPF_STACK, 0]内,并且对齐
-
只能读取已写入的树
-
例如:
bpf_id R0 = *(U32 *)(R10 - 4) bpf_exit
尽管地址合法,但该位置没有存储任何内容
函数调用与扩展机制
函数调用机制:
- 通过
bpf_verifier_ops->get_func_proto()
定制允许的函数调用 - 验证器检查函数调用的参数是否符合约束
- 调用后,R0设置为函数的返回类型
安全性考虑:
- 如果一个函数对ebpf程序开放访问,需要从安全角度进行考虑
- 验证器保证该函数使用有效的参数进行调用
不同用例的函数集:
- 套接字过滤器和跟踪过滤器可能允许不同的函数集合
- 在ebpf的情况下, 一个可配置的验证器被所有用例共享
寄存器值跟踪
结构体
struct bpf_reg_state
作用:
- 用于追踪每个寄存器和堆栈槽的值范围
- 统一处理标量值与指针值的状态
- 每个寄存器状态具有以下类型之一:
- NOT_INIT:尚未写入
- SCALAR_VALUE:非指针的标量值
- 指针类型
指针类型
PTR_TO_CTX
:指向bpf_context
CONST_PTR_TO_MAP
:指向struct bpf_map
,禁止进行指针算术运算PTR_TO_MAP_VALUE
:指向映射元素中的值PTR_TO_MAP_VALUE_OR_NULL
:指向映射值或NULL;在检查!=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
:指向socket或NULL;在检查!=NULL
后转为PTR_TO_SOCKET
,禁止进行指针算术运算
指针偏移追踪
指针偏移分为两部分:
- 固定偏移量:已知的立即数偏移
- 可变 偏移量:未知或运行时确定的偏移
可变偏移量用于追踪寄存器中可能值的范围:
- 无符号最小值(
umin_value
)和最大值(umax_value
) - 有符号最小值(
smin_value
)和最大值(smax_value
) tnum
表示法:使用mask
和value
表示已知和未知的位
条件分支对寄存器状态的影响
- 条件分支(如
if
语句)会根据比较结果更新寄存器的值范围- 示例:
if (reg > 8)
,在true分支中,reg
的最小值为9;在false分支中,最大值为8
- 示例:
- 验证器可以结合有符号和无符号的边界信息,得出更精确的值范围
指针ID的作用
- 具有可变偏移的指针会分配一个唯一的id,用于追踪其来源和关联
- 在数据包处理和映射查找中,多个指针可能共享同一个id,以便验证器进行一致性检查
- 对于
PTR_TO_SOCKET
和PTR_TO_SOCKET_OR_NULL
,id
还用于引用计数的管理,确保在程序结束前释放引用
直接数据包访问
在cls_bpf
和act_bpf
程序类型中,ebpf验证器允许通过skb->data
和skb->data_end
指针直接访问数据包数据
工作机制
- 验证器会跟踪每个寄存器的状态,包括指向的数据类型、偏移量和可访问的范围
- 对于指向数据包的指针,验证器使用以下标记:
- id:标识该指针的唯一标识符,用于跟踪指针的来源和变更
- off:指针的固定偏移量
- r:指针的可访问范围,即从当前偏移量开始,可以安全访问的字节数
- 验证器确保所有对数据包的访问都在
skb->data
和skb->data_end
之间,防止越界访问
注意事项
- 对数据包指针的操作仅限于加法和减法,其他操作(如位移、按位与等)将使寄存器状态变为
SCALAR_VALUE
,从而禁止直接数据包访问 - 在进行指针加法时,必须确保偏移量不会导致指针越过
skb->data_end
,验证器会根据偏移量的最大可能值进行检查,防止潜在的越界访问 - 在进行复杂的偏移计算时,建议使用常量或已知范围的变量,以便验证器能够准确地推断出指针的可访问范围
剪枝机制(Pruning)
目的: 减少验证器需分析的路径数量,提高验证效率
原理: 当验证器在某指令处遇到新的分支状态时,会检查之前是否存在包含当前状态的子集状态。若存在,则当前分支可被剪枝,因为之前的状态已被接收,意味着当前状态也将被接收
实现函数:
- regsafe():判断寄存器状态是否安全
- states_equal():比较两个状态是否等效,考虑寄存器和堆栈
示例:
- 若之前的状态中,
r1
是一个数据包指针,而当前状态中,r1
是一个范围更长或对齐更严格的数据包指针,则r1
被视为安全 - 若
r2
之前为NOT_INIT
,则在该点的路径中都未使用r2
,因此当前状态中r2
的任何值都是安全的
寄存器活跃性追踪
Register Liveness Tacking
目的: 识别在程序执行过程中实际使用的寄存器和堆栈槽,忽略未使用的部分,从而使更多状态等效于缓存状态,增强pruning效果
关键数据结构:
-
enum bpf_reg_liveness
:定义寄存器活跃性状态REG_LIVE_NONE
:是在创建新的验证器状态时分配给->live
字段的初始值REG_LIVE_READ{32,64}
:表示寄存器(堆栈槽)的值被该验证器状态的某个子状态读取REG_LIVE_WRITTEN
:表示寄存器(或堆栈槽)的值由此验证器状态的父状态和验证器状态本身之间验证的某些指令定义REG_LIVE_DONE
:是clean_verifier_state()
使用的标记,以避免多次处理相同的验证器状态并进行一些健全性检查->live
字段值:由enum bpf_reg_liveness
组合而成使用按位或的值
-
struct bpf_reg_state
:表示寄存器状态,包含指向父状态的指针和活跃性标记 -
struct bpf_stack_state
:表示堆栈槽状态,包含溢出寄存器信息 -
struct bpf_func_state
:表示函数状态,包含寄存器和堆栈槽数据 -
struct bpf_verifer_state
:表示验证器状态,包含多个函数帧和指向父状态的指针
活跃性标记传播:
mark_reg_read()
:沿着父状态链向后传播读取标记,直到遇到写入标记或已存在读取标记。读标记应用于父状态,而写标记应用于当前状态。寄存器或堆栈槽上的写标记意味着它被从父状态到当前状态的直线代码中的某条指令更新。propagate_liveness()
:在缓存命中时,将读取标记从缓存状态传播到当前状态的父状态链,确保验证器行为一致
示例程序:
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的值对验证结果有影响,活跃性追踪算法会识别到这一点,并将两个状态视为等效,从而进行剪枝
精确标量值传播:
在epbf程序的验证过程中,有时一些寄存器或堆栈顶的值是精确的,意味着这些值不再改变,或者它们是常量。为了优化验证过程并提高验证的精度,验证器将这些精确值传播到后续的验证过程中
寄存器亲子链
Register Parentage Chain
目的: 在父状态和子状态之间传播活跃性信息,确保读取和写入标记的正确传递。此链接在is_state_visited()
中创建状态时建立,并且可能通过__check_func_call()
通过set_callee_state()
修改
规则:
- 当前堆栈帧中的寄存器和堆栈槽链接到父状态中具有相同索引的寄存器和堆栈槽
- 外部堆栈帧中,只有被调用者保存的寄存器(
r6
-r9
)和堆栈槽链接到父状态中具有相同索引的寄存器和堆栈槽 - 处理函数调用时,为新的函数帧分配一个新的
struct bpf_func_state
实例,对于这个新的栈帧,r6-r9 和堆栈槽的父链接被设置为 nil,r1-r5 的父链接被设置为与调用者 r1-r5 的父链接匹配。
状态清理与缓存命中处理
状态清理:
clean_live_states()
:处理验证器状态缓存的每个条目,将所有没有REG_LIVE_READ{32, 64}
标记的寄存器和堆栈槽标记为NOT_INIT
或STACK_INVALID
缓存命中处理:
- 当在状态缓存中找到先前验证的状态时,验证器必须表现得好像当前状态已验证到程序退出一样
- 这意味着缓存状态的寄存器和堆栈槽上存在的所有读取标记都必须通过当前状态的父代链传播
propagate_liveness()
处理这种情况,确保验证器行为一致,避免错误地将不同状态视为等效
错误类型笔记
不可达指令
Unreachable Instructions
示例:
static struct bpf_insn prog[] = {
BPF_EXIT_INSN(),
BPF_EXIT_INSN(),
};
错误信息: unreachable insn X
原因: 程序中存在无法执行到的指令,例如在BPF_EXIT_INSN()
之后仍有指令
说明: 验证器要求程序的控制流图为有向无环图,不可达的指令会被视为无效
使用未初始化的寄存器
示例:
BPF_MOV64_REG(BPF_REG_0, BPF_REG_2),
BPF_EXIT_INSN(),
错误信息: R2 !read_ok
或R0 !read_ok
原因: 在未对寄存器进行初始化的情况下使用其值
说明: 所有寄存器在使用前必须被明确赋值,否则验证器将拒绝程序
未初始化的返回寄存器R0
示例:
BPF_MOV64_REG(BPF_REG_2, BPF_REG_1),
BPF_EXIT_INSN(),
错误信息: R0 !read_ok
原因: 在程序退出前未对R0进行复制
说明: R0是返回值寄存器,必须在程序结束前进行初始化
堆栈访问错误
示例:
BPF_ST_MEM(BPF_DW, BPF_REG_10, 8, 0),
BPF_EXIT_INSN(),
错误信息:
- invalid stack off=8 size=8
- invalid indirect read from stack off -8+0 size 8
原因:
- 访问超出了堆栈边界的内存
- 在读取堆栈数据前未进行写入初始化
说明: 验证器要求堆栈访问必须在合法范围内,且读取前必须有写入操作
使用无效的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(),
错误信息: fd 0 is not pointing to valid bpf_map
原因: 在调用map_lookup_elem()函数时使用无效的map_fd=0的程序
未检查map_lookup_elem()的返回值
示例:
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(),
错误信息: R0 invalid mem access ‘map_value_or_null’
原因: 在调用map_lookup_elem()
后,未检查其返回值是否为NULL,直接使用返回的指针
说明: 必须在使用返回的指针前进行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(),
错误信息: misaligned access off 4 size 8
原因: 对内存进行非对齐的访问,例如在偏移量为4的位置读取8字节数据
说明: ebpf要求内存访问必须按照类型的对齐要求进行
条件分支路径中的未检查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_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(),
错误信息: 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(),
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(),
错误信息: Unreleased reference id=1, alloc_insn=7
原因: 调用如 bpf_sk_lookup_tcp()
等函数后,未释放返回的引用。
说明: 对于返回引用的函数,必须在使用后调用相应的释放函数,如 bpf_sk_release()