eBPF-Basic-Learning
eBPF
What is eBPF ?
eBPF 全称是 extended Berkeley Packet Filter ,起源于 BPF ( Berkeley Packet Filter )。顾名思义,它向linux内核提供了对数据包的过滤。
早期的网络监控器等都是作为用户级进程运行的。为了分析只在内核空间运行的数据,它们必须将这些数据从内核空间复制到用户空间的内存中去,并进行上下文切换。这与直接在内核空间分析这些数据相比,导致了巨大的性能开销。
BPF 就是解决这一问题的一种在内核空间执行高效安全的程序的机制。
BPF 在数据包过滤上引入了两大革新:
- 一个新的虚拟机 (VM) 设计,可以有效地工作在基于寄存器结构的 CPU 之上;
- 应用程序使用缓存只复制与过滤数据包相关的数据,不会复制数据包的所有信息,最大程度地减少BPF 处理的数据,提高处理效率;
发展到今天,BPF 升级为 eBPF 。它演进成为了一套通用执行引擎,提供可基于系统或程序事件高效安全执行特定代码的通用能力,通用能力的使用者不再局限于内核开发者。原来的 BPF 被称为 cBPF (classic BPF)已被舍弃。
下面是 eBPF 的大致原理图:
用户可以通过创建内核探针(kprobe)或用户探针(uprobe)在几乎任何地方附加eBPF程序。
在我刚开始阅读 eBPF 的相关资料时,就在想,这不就是一个数据过滤吗。但是现在想想吧,你可以在几乎内核的任何地方加入自己的代码。向内核加入用户输入,这本身就是一个大胆创新的想法,而加入自己的程序这是多么令人激动!
How does it work ?
正如原理图中展示的那样,用户需要首先使用 eBPF 指令集编写相应的 eBPF 程序,然后将程序字节码和程序类型送入内核,程序类型决定了可以访问的内核区域(各种Helper Calls)。
验证
为了确保安全,内核首先对传入的程序进行验证。
第一轮检查程序是否为一个有向无环图DAG,第二轮检查,它会拒绝下面的程序:
- 指令个数大于
BPF_MAXINSNS
(4096) - 有循环
- 有无法到达的指令(程序结构只能是一个函数不能是森林)
- 越界或畸形跳跃
每个寄存器状态都有一个类型,
NOT_INIT
:该寄存器还未写入数据SCALAR_VALUE
:标量值,不可作为指针- 指针类型
依据它们指向的数据结构类型,又可以分为:
-
PTR_TO_CTX
:指向bpf_context
的指针。 -
CONST_PTR_TO_MAP
:指向struct bpf_map
的指针。 是常量(const),因为不允许对这种类型指针进行算术操作。 -
PTR_TO_MAP_VALUE
:指向 bpf map 元素的指针。 -
PTR_TO_MAP_VALUE_OR_NULL
:指向 bpf map 元素的指针,可为 NULL。 访问 map 的操作会返回这种类型的指针。禁止算术操作。 -
PTR_TO_STACK
:帧指针(Frame pointer)。 -
PTR_TO_PACKET
:指向skb->data
的指针。 -
PTR_TO_PACKET_END
:指向skb->data + headlen
的指针。禁止算术操作。 -
PTR_TO_SOCKET
:指向struct bpf_sock_ops
的指针,内部有引用计数。 -
PTR_TO_SOCKET_OR_NULL
:指向struct bpf_sock_ops
的指针,或 NULL。socket lookup 操作会返回这种类型。有引用计数, 因此程序在执行结束时,必须通过 socket release 函数释放引用。禁止算术操作。
这些指针都称为 base 指针
JIT
通过验证后,它就会进入JIT编译阶段,利用Just-In-Time编译器,编译生成的是通用的字节码,它是完全可移植的,可以在x86和ARM等任意球CPU架构上加载这个字节码,这样我们能获得本地编译后的程序运行速度,而且是安全可靠的。
Maps
maps 是 eBPF 的数据存储数据库,在程序中由用户通过相应的函数创建,它支持以下类型:
- Hash tables, Arrays
- LRU (Least Recently Used)
- Ring Buffer
- Stack Trace
- LPM (Longest Prefix match)
- ……
一个定义的例子:
|
|
值得注意的是:
- BPF Map是可以被用户空间访问并操作的
- BPF Map是可以与BPF程序分离的,即当创建一个BPF Map的BPF程序运行结束后,该BPF Map还能存在,而不是随着程序一起消亡
Helper Calls
在 eBPF 的程序中不能直接调用内核函数。因为内核版本不断更新,很多函数会发生变化,这可能导致 eBPF 的失效。为了避免这样,内核提供了 helper calls 的 API,无需了解其实现,只需使用即可。另一方面,这也拓展了 eBPF 的功能。
bpf-helpers(7) - Linux manual page
指令集
eBPF 的指令结构如下:
|
|
在 eBPF 中有 11 个 64位寄存器 R0-R10
R0 | 返回值寄存器 |
---|---|
R1-R5 | 函数参数 |
R6-R9 | 被调用函数保留 |
R10 | 只读栈帧寄存器 |
其栈的大小固定为512字节。当一个 eBPF 程序启动时,R1 中的地址指向 context 上下文(当前情况下为数据包缓冲区)
opcode 结构
|
|
op字段的低3位,决定指令类型。
Code: include/uapi/linux/bpf.h
|
|
- BPF_LD, BPF_LDX: 两个类都用于加载操作。BPF_LD用于加载双字。后者是从 cBPF 继承而来的,主要是为了保持 cBPF 到 BPF 的转换效率,因为它们优化了 JIT 代码。
- BPF_ST, BPF_STX: 两个类都用于存储操作,用于将数据从寄存器到存储器中。
- BPF_ALU, BPF_ALU64: 分别是32位和64位下的ALU操作。
- BPF_JMP和BPF_JMP32:跳转指令。JMP32的跳转范围是32位大小(一个 word)
加载和存储指令
此时:
|
|
size决定了操作数据的大小
|
|
mode
|
|
跳转与运算指令
此时:
|
|
|
|
可以使用以下宏定义快速的编写指令,Code: samples/bpf/bpf_insn.h:
|
|
Security
虽然内核对用户输入做了很多的防护,但是依然没有阻止 eBPF 作为新的内核攻击面。
OOB
用户与内核唯一的屏障是 verify ,如果绕过那么就可以实现注入了。 eBPF 会对读取对应类型的内核缓冲区 context 和 map,这里涉及到,程序读取的值不能马上确定,而程序又要对数据进行其他的运算,如何保证得到的数据等不超界?
eBPF 寄存器结构:
|
|
umin_value
和umax_value
:当解释器将寄存器的值解释为无符号整数时的最小值和最大值smin_value
和smax_value
:当解释器将寄存器的值解释为有符号整数时的最小值和最大值var_off
: 用来描述无法确定的值,既然有待定的值,一个位的状态就变成了三种,‘0’、‘1’和未知。如果一个数的某位是确定的,那么其在value中的值就是它的真值,对应mask中的位为0,如果某位无法确定,那么mask中对应的位为1。
例如: var_off→value = 0b010, value->mask = 0b100
,那么这个值就可能为0b010或0b110。
上述这五个数据可以相互更新,例如如果 umax_value
小于 2^63
,则 smin_value
会被设置为 0(因为不会有负数出现),如果 var_off
指示寄存器只有最低 3 位可能为 1,
则 umax_value
为 7。
Reference
What is eBPF? An Introduction and Deep Dive into the eBPF Technology