BPF 技术
BPF (Berkeley Packet Filter) 最初是为网络数据包过滤而设计的一种高效机制,通过在内核空间执行用户定义的过滤程序,从而实现对网络数据包的高效处理。
随着时间的发展,BPF 的应用场景逐渐扩展,演变为 eBPF(extended BPF),不仅可以用于网络数据包过滤,还可以用于性能监控、安全审计等多种内核级别的任务。eBPF 通过在内核中安全地运行用户定义的程序,实现了对内核行为的灵活扩展,而无需修改内核源代码。
在后文中,不再严格区分 BPF 和 eBPF.
BPF 技术的核心思想很简单:hook——在某个函数调用的位置预留一个钩子,执行到该函数时触发钩子,转而执行用户定义的代码。这一点与调试器非常相似,区别在于调试器在命中断点时会暂停整个程序执行,而 BPF 技术则是在命中钩子后,先执行注入的代码,然后继续原函数的执行。
以 x86 为例,当我们设置断点时,调试器会将端点处的指令首字节暂存,并替换为 int3 指令的机器码 0xCC,当进程执行到该指令时,int3 指令触发一个软中断,随后陷入内核态,调试器进程随后就可以介入进程的执行流。
call rax ; FF D0
int3 rax ; CC D0
eBPF 技术也采用了类似的技术路线,通过在程序的关键位置预留一段触发钩子的指令,使得 eBPF 有机会在此处介入进程的执行流。随后,eBPF 进程就可以从内核接管原进程。
在调试器的例子中,被调试的进程是用户态的,需要通过 int3 指令触发软中断,让内核介入进程的执行流,然后将控制权交给调试器。eBPF 技术依据探测的场景会有所区别。对于用户态程序的探测(uprobe),eBPF 进程启动后发起一个特定的系统调用,内核随后将探测点的指令修改为 int3。
对于内核态程序的探测(kprobe,一般是一些系统调用入口),被探测的程序会在钩子位置插入 5 个 nop,在探测进程未启动时,这些 nop 对程序正常执行没有任何影响。这个过程被称为插桩(instrumentation)。
some_syscall:
nop
nop
nop
nop
nop
push rbp
mov rbp, rsp
sub rsp, 0x20
eBPF 探测进程启动后,通过 bpf() 系统调用将 eBPF 程序加载到内核中,将插入的桩替换为 call 指令,从而实现对函数调用的拦截和处理。
之所以是 5 个 nop,是因为 x86 架构下 call 指令最长为 5 个字节,插入的桩可以替换成一条完整的 call 指令。
替换为 call 后,这条 call 指令就继承了函数调用 foo() 的所有参数。
some_syscall:
call ebpf_trampoline
push rbp
mov rbp, rsp
sub rsp, 0x20
; ...
ebpf_trampoline:
; eBPF 程序的入口
; ...
eBPF 本身是一段跨平台的、由 eBPF 虚拟机执行的字节码程序,能够在内核中安全地运行。eBPF 探测进程通过 bpf() 系统调用将 eBPF 程序加载到内核中,在命中插桩位置后,通过 eBPF 蹦床(trampoline)将控制权转交给内核空间的 eBPF 虚拟机执行插入的 eBPF 程序。