eBPF 内核实现
BPF 的字面上意思 Berkeley Packet Filter 意味着它是从包过滤而来。如果在开始前对 BPF 缺乏感性的认识建议先看一下参考文档
本质上它是一种内核代码注入的技术:
- 内核中实现了一个 cBPF/eBPF 虚拟机;
- 用户态可以用 C 来写运行的代码,再通过一个 Clang&LLVM 的编译器将 C 代码编译成 BPF 目标码;
- 用户态通过系统调用 bpf()将 BPF 目标码注入到内核当中;
- 内核通过 JIT(Just-In-Time)将 BPF 目编码转换成本地指令码;如果当前架构不支持 JIT 转换内核则会使用一个解析器(interpreter)来模拟运行,这种运行效率较低;
- 内核在 packet filter 和 tracing 等应用中提供了一系列的钩子来运行 BPF 代码。目前支持以下类型的 BPF 代码:
|
|
BPF 的好处在哪里? 是因为它提供了一种在不修改内核代码的情况下,可以灵活修改内核处理策略的方法。 这在包过滤和系统 tracing 这种需要频繁修改规则的场合非常有用。因为如果只在用户态修改策略的话那么所有数据需要复制一份给用户态开销较大;如果在内核态修改策略的话需要修改内核代码重新编译内核,而且容易引人安全问题。BPF 这种内核代码注入技术的生存空间就是它可以在这两者间取得一个平衡。
Systamp 就是解决了这个问题得以发展的,它使用了 ko 的方式来实现内核代码注入(有点笨拙,但是也解决了实际问题)。
Systemtap 工作原理:是通过将脚本语句翻译成 C 语句,编译成内核模块。模块加载之后,将所有探测的事件以 Kprobe 钩子的方式挂到内核上,当任何处理器上的某个事件发生时,相应钩子上句柄就会被执行。最后,当 systemtap 会话结束之后,钩子从内核上取下,移除模块。整个过程用一个命令 stap 就可以完成。
既然是提供向内核注入代码的技术,那么安全问题肯定是重中之重。平时防范他人通过漏洞向内核中注入代码,这下子专门开了一个口子不是大开方便之门。所以内核指定了很多的规则来限制 BPF 代码,确保它的错误不会影响到内核:
- 一个 BPF 程序的代码数量不能超过 BPF_MAXINSNS (4K),它的总运行步数不能超过 32K (4.9 内核中这个值改成了 96k);
- BPF 代码中禁止循环,这也是为了保证出错时不会出现死循环来 hang 死内核。一个 BPF 程序总的可能的分支数也被限制到 1K;
- 为了限制它的作用域,BPF 代码不能访问全局变量,只能访问局部变量。一个 BPF 程序只有 512 字节的堆栈。在开始时会传入一个 ctx 指针,BPF 程序的数据访问就被限制在 ctx 变量和堆栈局部变量中;
- 如果 BPF 需要访问全局变量,它只能访问 BPF map 对象。BPF map 对象是同时能被用户态、BPF 程序、内核态共同访问的,BPF 对 map 的访问通过 helper function 来实现;
- 旧版本 BPF 代码中不支持 BPF 对 BPF 函数的调用,所以所有的 BPF 函数必须声明成 always_inline。在 Linux 内核 4.16 和 LLVM 6.0 以后,才支持 BPF to BPF Calls;
- BPF 虽然不能函数调用,但是它可以使用 Tail Call 机制从一个 BPF 程序直接跳转到另一个 BPF 程序。它需要通过 BPF_MAP_TYPE_PROG_ARRAY 类型的 map 来知道另一个 BPF 程序的指针。这种跳转的次数也是有限制的,32 次;
- BPF 程序可以调用一些内核函数来辅助做一些事情(helper function);
- 有些架构(64 bit x86_64, arm64, ppc64, s390x, mips64, sparc64 and 32 bit arm)已经支持 BPF 的 JIT,它可以高效的几乎一比一的把 BPF 代码转换成本机代码(因为 eBPF 的指令集已经做了优化,非常类似最新的 arm/x86 架构,ABI 也类似)。如果当前架构不支持 JTI 只能使用内核的解析器(interpreter)来模拟运行;
- 内核还可以通过一些额外的手段来加固 BPF 的安全性(Hardening)。主要包括:把 BPF 代码映像和 JIT 代码映像的 page 都锁成只读,JIT 编译时把常量致盲(constant blinding),以及对 bpf()系统调用的权限限制;
对 BPF 这些安全规则的检查主要是在 BPF 代码加载时,通过 BPF verifier 来实现的。大概分为两步:
- 第一步,通过 DAG(Directed Acyclic Graph 有向无环图)的 DFS(Depth-first Search)深度优先算法来遍历 BPF 程序的代码路径,确保没有环路发生;
- 第二步,逐条分析 BPF 每条指令的运行,对 register 和对 stack 的影响,最坏情况下是否有越界行为(对变量的访问是否越界,运行的指令数是否越界)。这里也有一个快速分析的优化方法:修剪(Pruning)。如果当前指令的当前分支的状态,和当前指令另一个已分析分支的状态相等或者是它的一个子集,那么当前指令的当前分支就不需要分析了,因为它肯定是符合规则的。
整个 BPF 的开发过程大概如下图所示:
系统调用
核心代码在 bpf()系统调用中,我们从入口开始分析。
|
|
bpf 加载
BPF_PROG_LOAD 命令负责加载一段 BPF 程序到内核当中:
- 拷贝程序到内核;
- 校验它的安全性;
- 如果可能对它进行 JIT 编译;
- 然后分配一个文件句柄 fd 给它。
完成这一切后,后续再把这段 BPF 程序挂载到需要运行的钩子上面。
bpf 内存空间分配
|
|
这其中对 BPF 来说有个重要的数据结构就是 struct bpf_prog:
|
|
其中重要的成员如下:
- len:程序包含 bpf 指令的数量;
- type:当前 bpf 程序的类型(kprobe/tracepoint/perf_event/sk_filter/sched_cls/sched_act/xdp/cg_skb);
- aux:主要用来辅助 verifier 校验和转换的数据;
- orig_prog:
- bpf_func:运行时 BPF 程序的入口。如果 JIT 转换成功,这里指向的就是 BPF 程序 JIT 转换后的映像;否则这里指向内核解析器(interpreter)的通用入口__bpf_prog_run();
- insnsi[]:从用户态拷贝过来的,BPF 程序原始指令的存放空间;
bpf verifier
关于 verifier 的步骤和规则,在“3.1、Berkeley Packet Filter (BPF) (Kernel Document)”一文的“eBPF verifier”一节有详细描述。
另外,在 kernel/bpf/verifier.c 文件的开头对 eBPF verifier 也有一段详细的注释:
bpf_check()是一个静态代码分析器,它按指令遍历eBPF程序指令并更新寄存器/堆栈状态。分析条件分支的所有路径,直到'bpf_exit'指令。
1、第一步是深度优先搜索,检查程序是否为DAG(Directed Acyclic Graph 有向无环图)。它将会拒绝以下程序:
- 大于BPF_MAXINSNS条指令(BPF_MAXINSNS=4096)
- 如果出现循环(通过back-edge检测)
- 不可达的指令存在(不应该是森林,程序等于一个函数)
- 越界或畸形的跳跃
2、第二步是从第一步所有可能路径的展开。
- 因为它分析了程序所有的路径,这个分析的最大长度限制为32k个指令,即使指令总数小于4k也会受到影响,因为有太多的分支改变了堆栈/寄存器。
- 分支的分析数量被限制为1k。
在进入每条指令时,每个寄存器都有一个类型,该指令根据指令语义改变寄存器的类型:
- rule 1、如果指令是BPF_MOV64_REG(BPF_REG_1, BPF_REG_5),则将R5的类型复制到R1。
所有寄存器都是64位的。
* R0 -返回寄存器
* R1-R5参数传递寄存器
* R6-R9被调用方保存寄存器
* R10 -帧指针只读
- rule 2、在BPF程序开始时,寄存器R1包含一个指向bpf_context的指针,类型为PTR_TO_CTX。
- rule 3、verifier跟踪指针上的算术运算:
`
BPF_MOV64_REG(BPF_REG_1, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_1, -20),
`
第一条指令将R10(它具有FRAME_PTR)类型复制到R1中,第二条算术指令是匹配的模式,用于识别它想要构造一个指向堆栈中某个元素的指针。
因此,在第二条指令之后,寄存器R1的类型为PTR_TO_STACK(-20常数需要进一步的堆栈边界检查)。表示这个reg是一个指针由堆栈加上常数。
- rule 4、大多数时候寄存器都有UNKNOWN_VALUE类型,这意味着寄存器有一些值,但它不是一个有效的指针。(就像指针+指针变成了UNKNOWN_VALUE类型)
- rule 5、当verifier看到load指令或store指令时,基本寄存器的类型可以是:PTR_TO_MAP_VALUE、PTR_TO_CTX、FRAME_PTR。这是由check_mem_access()函数识别的三种指针类型。
- rule 6、PTR_TO_MAP_VALUE表示这个寄存器指向‘map元素的值’,并且可以访问[ptr, ptr + map value_size)的范围。
- rule 7、寄存器用于向函数调用传递参数,将根据函数参数约束进行检查。
ARG_PTR_TO_MAP_KEY就是这样的参数约束之一。
这意味着传递给这个函数的寄存器类型必须是PTR_TO_STACK,它将作为‘map element key的指针’在函数内部使用。
例如bpf_map_lookup_elem()的参数约束:
`
.ret_type = RET_PTR_TO_MAP_VALUE_OR_NULL,
.arg1_type = ARG_CONST_MAP_PTR,
.arg2_type = ARG_PTR_TO_MAP_KEY,
`
ret_type表示该函数返回“指向map element value的指针或null”。
函数期望第一个参数是指向‘struct bpf_map’的const指针,第二个参数应该是指向stack的指针,这个指针在helper函数中用作map element key的指针。
在内核侧的helper函数如下:
`
u64 bpf_map_lookup_elem(u64 r1, u64 r2, u64 r3, u64 r4, u64 r5)
{
struct bpf_map *map = (struct bpf_map *) (unsigned long) r1;
void *key = (void *) (unsigned long) r2;
void *value;
here kernel can access 'key' and 'map' pointers safely, knowing that
[key, key + map->key_size) bytes are valid and were initialized on
the stack of eBPF program.
}
`
相应的eBPF程序如下:
`
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10), // after this insn R2 type is FRAME_PTR
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -4), // after this insn R2 type is PTR_TO_STACK
BPF_LD_MAP_FD(BPF_REG_1, map_fd), // after this insn R1 type is CONST_PTR_TO_MAP
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
`
这里verifier查看map_lookup_elem()的原型,看到:
- .arg1_type == ARG_CONST_MAP_PTR and R1->type == CONST_PTR_TO_MAP, 这个是ok的。现在verifier知道map key的尺寸了:R1->map_ptr->key_size。
- 然后.arg2_type == ARG_PTR_TO_MAP_KEY and R2->type == PTR_TO_STACK也是ok的。
现在verifier检测 [R2, R2 + map's key_size]是否在堆栈限制内,并且在调用之前被初始化。
- 如果可以,那么verifier允许这个BPF_CALL指令,并查看.ret_type RET_PTR_TO_MAP_VALUE_OR_NULL,因此它设置R0->类型= PTR_TO_MAP_VALUE_OR_NULL,这意味着bpf_map_lookup_elem()函数返回map value指针或NULL。
当类型PTR_TO_MAP_VALUE_OR_NULL通过'if (reg != 0) goto +off' 指令判断时,在真分支中持有指针的寄存器将状态更改为PTR_TO_MAP_VALUE,在假分支中相同的寄存器将状态更改为CONST_IMM。看check_cond_jmp_op()的实现。
函数调用以后R0设置为返回函数类型后,将寄存器R1-R5设置为NOT_INIT,以指示它们不再可读。
原文如下:
|
|
BPF verifier 总体代码流程如下:
|
|
|
|
1、把 BPF 程序中操作 map 的指令,从 map_fd 替换成实际的 map 指针。 由此可见用户态的 loader 程序,肯定是先根据__section(“maps”)中定义的 map 调用 bpf()创建 map,再加载其他的程序 section。
符合条件:(insn[0].code == (BPF_LD | BPF_IMM | BPF_DW)) && (insn[0]->src_reg == BPF_PSEUDO_MAP_FD) 的指令为 map 指针加载指针。 把原始的立即数作为 fd 找到对应的 map 指针。 把 64bit 的 map 指针拆分成两个 32bit 的立即数,存储到 insn[0].imm、insn[1].imm 中。
|
|
2、Step 1、通过 DAG(Directed Acyclic Graph 有向无环图)的 DFS(Depth-first Search)深度优先算法来遍历 BPF 程序的代码路径,确保没有环路发生; DAG 的 DFS 算法可以参考“Graph”一文。其中最重要的概念如下图:
一个图形"Graph"经过 DAG 的 DFS 算法遍历后,对每一个根节点都会形成一颗树“DFS Tree”,多个根节点得到的多棵树形成一个森林"DFS Forest"。根据搜索的结构整个“Graph”的边“Edge”可以分成四类:
- Tree Edges:在 DFS 树上的边;
- Back Edges:从子节点连向祖先节点的边(形成环);
- Forward Edges:直接连向孙节点的边(跨子节点的连接);
- Cross Edges:叶子之间的连接,或者树之间的连接;
对 BPF verifier 来说,检查 BPF 程序的运行路径图中是否有“Back Edges”的存在,确保程序中没有环路。
具体的代码如下:
|
|
3、step2、详细扫描 BPF 代码的运行过程,跟踪分析寄存器和堆栈,检查是否有不符合规则的情况出现。
这段代码的具体算法就是把 step1 的路径重新走一遍,并且跟踪寄存器和堆栈的变化,判断最坏情况下是否有违反规则的情况出现。 在碰到指令对应 explored_states[]被设置成 STATE_LIST_MARK,需要给当前指令独立分配一个 bpf_verifier_state_list 链表,来存储这个指令在多个分支上的不同状况。 这里也有一个快速分析的优化方法:修剪(Pruning)。如果当前指令的当前分支的状态 cur_state,和当前指令另一个已分析分支的状态(当前指令 explored_states[]链表中的一个 bpf_verifier_state_list 成员)相等或者是它的一个子集,那么当前指令的当前分支就不需要分析了,因为它肯定是符合规则的。
|
|
4、修复 BPF 指令中对内核 helper function 函数的调用,把函数编号替换成实际的函数指针。 符合条件:(insn->code == (BPF_JMP | BPF_CALL)) 的指令,即是调用 helper function 的指令。 通用 helper function 的处理:根据 insn->imm 指定的编号找打对应的函数指针,然后再把函数指针和__bpf_call_base 之间的 offset,赋值到 insn->imm 中。
|
|
bpf JIT/kernel interpreter
在 verifier 验证通过以后,内核通过 JIT(Just-In-Time)将 BPF 目编码转换成本地指令码;如果当前架构不支持 JIT 转换内核则会使用一个解析器(interpreter)来模拟运行,这种运行效率较低;
有些架构(64 bit x86_64, arm64, ppc64, s390x, mips64, sparc64 and 32 bit arm)已经支持 BPF 的 JIT,它可以高效的几乎一比一的把 BPF 代码转换成本机代码(因为 eBPF 的指令集已经做了优化,非常类似最新的 arm/x86 架构,ABI 也类似)。如果当前架构不支持 JTI 只能使用内核的解析器(interpreter)来模拟运行;
|
|
1、JIT 以 arm64 的 JIT 转换为例:
|
|
JIT 的核心转换分为 3 部分:prologue + body + epilogue。
-
prologue:新增的指令,负责 BPF 运行堆栈的构建和运行现场的保护;
-
body:BPF 主体部分;
-
epilogue:负责 BPF 运行完现场的恢复和清理;
1.1、prologue
A64_:开头的是本机的相关寄存器BPF_:开头的是 BPF 虚拟机的寄存器
整个过程还是比较巧妙的: 首先将 A64_FP/A64_LR 保存进堆栈 A64_SP,然后把当前 A64_SP 保存进 A64_FP; 继续保存 callee saved registers 进堆栈 A64_SP:r6, r7, r8, r9, fp, tcc,然后把当前 A64_SP 保存进 BPF_FP; 把 A64_SP 减去 STACK_SIZE,给 BPF_FP 留出 512 字节的堆栈空间; 这样 BPF 程序使用的是 BPF_FP 开始的 512 字节堆栈空间,普通 kernel 函数使用的是 A64_SP 继续向下的堆栈空间,互不干扰;
|
|
1.2、body 把 BPF 指令翻译成本地 arm64 指令:
|
|
1.3、epilogue 做和 prologue 相反的工作,恢复和清理堆栈:
|
|
2、interpreter 对于不支持 JIT 的情况,内核只能使用一个解析器来解释 prog->insnsi[]中 BPF 的指令含义,模拟 BPF 指令的运行: 使用“u64 stack[MAX_BPF_STACK / sizeof(u64)]”局部变量来模拟 BPF 堆栈空间; 使用“u64 regs[MAX_BPF_REG]”局部变量来模拟 BPF 寄存器;
|
|
3、BPF_PROG_RUN()
不论是转换成 JIT 的映像,或者是使用 interpreter 解释器。最后 BPF 程序运行的时候都是使用 BPF_PROG_RUN()这个宏来调用的:
|
|
fd 分配
对于加载到内核空间的 BPF 程序,最后会给它分配一个文件句柄 fd,将 prog 存储到对应的 file->private_data 上。方便后续的引用。
|
|
bpf map 操作
BPF map 的应用场景有几种:
- BPF 程序和用户态的交互:BPF 程序运行完,得到的结果存储到 map 中,供用户态访问;
- BPF 程序内部交互:如果 BPF 程序内部需要用全局变量来交互,但是由于安全原因 BPF 程序不允许访问全局变量,可以使用 map 来充当全局变量;
- BPF Tail call:Tail call 是一个 BPF 程序跳转到另一 BPF 程序,BPF 程序首先通过 BPF_MAP_TYPE_PROG_ARRAY 类型的 map 来知道另一个 BPF 程序的指针,然后调用 tail_call()的 helper function 来执行 Tail call。
- BPF 程序和内核态的交互:和 BPF 程序以外的内核程序交互,也可以使用 map 作为中介;
目前,支持的 map 种类:
|
|
不论哪种 map,对 map 的使用都是用"键-值“对(key-value)的形式来使用的。
map 的创建
如果用户态的 BPF c 程序有定义 map,map 最后会被编译进section(“maps”)。 用户态的 loader 在加载 BPF 程序的时候,首先会根据section(“maps”)中的成员来调用 bpf()系统调用来创建 map 对象。
|
|
1、BPF_MAP_TYPE_ARRAY 我们以 BPF_MAP_TYPE_ARRAY 类型的 map 为例,来看看 map 的分配过程:
从用户态传过来的 attr 成员意义如下: attr->map_type:map 的类型; attr->key_size:键 key 成员的大小; attr->value_size:值 value 成员的大小; attr->max_entries:需要存储多少个条目(“键-值“对)
|
|
2、BPF_MAP_TYPE_HASH 我们以 BPF_MAP_TYPE_HASH 类型的 map 为例,来看看 map 的分配过程:
|
|
map 的查找
查找就是通过 key 来找到对应的 value。
|
|
1、BPF_MAP_TYPE_ARRAY BPF_MAP_TYPE_ARRAY 类型的 map 最终调用到 array_map_lookup_elem():
|
|
2、BPF_MAP_TYPE_HASH BPF_MAP_TYPE_HASH 类型的 map 最终调用到 htab_map_lookup_elem():
|
|
BPF_FUNC_map_lookup_elem
除了用户态空间需要通过 bpf()系统调用来查找 key 对应的 value 值。BPF 程序中也需要根据 key 查找到 value 的地址,然后在 BPF 程序中使用。BPF 程序时通过调用 BPF_FUNC_map_lookup_elem helper function 来实现的。
我们以 perf_event 为例,看看 BPF_FUNC_map_lookup_elem helper function 的实现:
|
|
和 bpf()系统调用一样,最后调用的都是 map->ops->map_lookup_elem()函数,只不过 BPF 程序需要返回的是 value 的指针,而 bpf()系统调用需要返回的是 value 的值。 关于 map 的 helper function,还有 BPF_FUNC_map_update_elem、BPF_FUNC_map_delete_elem 可以使用,原理一样。
obj pin
系统把 bpf_prog 和 bpf_map 都和文件句柄绑定起来。有一系列的好处:比如可以在用户态使用一系列的通用文件操作;也有一系列的坏处:因为 fd 生存在进程空间的,其他进程不能访问,而且一旦本进程退出,这些对象都会处于失联状态无法访问。
所以系统也支持把 bpf 对象进行全局化的声明,具体的做法是把这些对象绑定到一个专用的文件系统当中:
|
|
具体分为 pin 操作和 get 操作。
bpf_obj_pin()
|
|
bpf_obj_get()
|
|
Tracing 类型的 BPF 程序
经过上一节的内容,bpf 程序和 map 已经加载到内核当中了。什么时候 bpf 程序才能发挥它的作用呢? 这就需要 bpf 的应用系统把其挂载到适当的钩子上,当钩子所在点的路径被执行,钩子被触发,BPF 程序得以执行。
目前应用 bpf 的子系统分为两大类:
- tracing:kprobe、tracepoint、perf_event
- filter:sk_filter、sched_cls、sched_act、xdp、cg_skb
我们仔细分析一下 tracing 类子系统应用 bpf 的过程,tracing 类型的 bpf 操作都是通过 perf 来完成的。
bpf 程序的绑定
在使用 perf_event_open()系统调用创建 perf_event 并且返回一个文件句柄后,可以使用 ioctl 的 PERF_EVENT_IOC_SET_BPF 命令把加载好的 bpf 程序和当前 perf_event 绑定起来。
|
|
如上,perf_event 绑定 bpf_prog 的规则如下:
对于 PERF_TYPE_HARDWARE、PERF_TYPE_SOFTWARE 类型的 perf_event,需要绑定 BPF_PROG_TYPE_PERF_EVENT 类型的 BPF prog。event->prog = prog; 对于 TRACE_EVENT_FL_TRACEPOINT 实现的 PERF_TYPE_TRACEPOINT 类型的 perf_event,需要绑定 BPF_PROG_TYPE_TRACEPOINT 类型的 BPF prog。event->tp_event->prog = prog; 对于 TRACE_EVENT_FL_UKPROBE 实现的 PERF_TYPE_TRACEPOINT 类型的 perf_event,需要绑定 BPF_PROG_TYPE_KPROBE 类型的 BPF prog。event->tp_event->prog = prog;
bpf 程序的执行
因为几种 perf_event 的执行路径不一样,我们分开描述。
1、PERF_TYPE_HARDWARE、PERF_TYPE_SOFTWARE 类型的 perf_event。
|
|
2、TRACE_EVENT_FL_TRACEPOINT 实现的 PERF_TYPE_TRACEPOINT 类型的 perf_event。
|
|
3、TRACE_EVENT_FL_UKPROBE 实现的 PERF_TYPE_TRACEPOINT 类型的 perf_event。 kprobe 类型的实现:
|
|
kretprobe 类型的实现:
|
|
Filter 类型的 BPF 程序
参考资料
-
No backlinks found.