Skip to main content

eBPF-不只介绍,迅速上手-1

· 23 min read
IceyBlackTea

eBPF is a revolutionary technology that can run sandboxed programs in the Linux kernel without changing kernel source code or loading kernel modules.

eBPF logo
主体内容 2021-05-29 / 2021-05-30

从 BPF 到 eBPF

eBPF 的前身是 BPF (Berkeley Packet Filter),这项技术诞生于1992年,作用是提升网络包过滤工具的性能。

BPF 的思想是:与其将数据包拷贝到用户空间再执行过滤,不如将过滤程序注入内核执行。

BPF 在内核中附加在套接字上,拷贝数据包并进行匹配,将满足条件的数据包放入接受队列,随后发送给用户空间,从而减少了无用数据包的拷贝操作。

BPF 最初在 BSD 上使用,随后被移植到了 Linux 等其他类 unix 系统;之后,基于 BPF,诞生了 libpcap(以及在 Windows 平台下的 winpcap),开始被广泛应用于网络数据包捕获。

即使你不了解 BPF 和 libpcap,大名鼎鼎的抓包工具 tcpdump 和 wireshark 还是很有可能用过的,它们就使用了 libpcap/winpacap。

2013年,Alexei Starovoitov 向Linux社区提交了重新实现BPF的内核补丁,经过他与 Daniel Borkmann 共同完善,与2014年正式并入 Linux 内核。此后,新版本的BPF称为 eBPF (extended Berkeley Packet Filter)。

eBPF 在 BPF 的基础上添加很多新的特性,如:从两个32位寄存器转为支持十个64位寄存器、添加了映射与JIT机制从而大幅提高了执行效率、添加了新的附加点使 eBPF 可以在网络之外的领域发挥强大功能...

至此传统的 BPF 可称为 cBPF (classic Berkeley Packet Filter),在新版内核上,其工作时会被自动转换为 eBPF 程序。

改进后的BPF的大大扩展了使用场景,在原本网络数据包过滤的基础上,完善了对网络、系统功能、用户程序的跟踪与采样的支持,使其在网络、可观测性和安全三个应用领域广受欢迎。


eBPF-冷门但强大

eBPF 是一项革命性的技术,可以在 Linux 内核中运行沙盒程序,而无需更改内核源代码或加载内核模块。

简单来说,eBPF 允许用户编写代码并临时挂载在内核允许的 HOOK(附加点)上,在内核通过 HOOK 时,自动触发执行 eBPF 程序。

通过 eBPF 程序,你可以获取 HOOK 处使用的数据结构、系统运行时的信息等等,甚至修改数据、改变原本的系统功能。

基于事件驱动

eBPF 的强大离不开其可选择的丰富的附加点,内核中预定义的附加点包括了:系统调用、函数进入与退出、内核跟踪点、网络事件等等;如果预定义的附加点仍不足以满足需求,用户还可以通过内核探针与用户探针在内核程序与用户程序中附加 eBPF 程序。

eBPF 也支持 USDT (Userland Statically Defined Tracing)。

综上,eBPF支持对内核与用户空间进行静态与动态的跟踪,可以在几乎任何需要的位置临时执行eBPF程序。

caution

eBPF 需要内核提供运行环境与部分跟踪点,因此使用 eBPF 前请确认内核版本与其支持功能。

使用高级语言编写

通常 eBPF 程序使用 C 语言编写,使用 LLVM 编译成 eBPF 字节码,之后使用 Loader 加载程序注入内核。

随着 eBPF 生态的发展,产生了如 BCC 的工具集或库,其提供了一整套在 eBPF 之上的封装抽象,使技术人员可以不再浪费过多精力于程序的编译与加载等步骤。

因此,现在可以使用 C/C++、GO、Rust、Python 等多种语言来编写 eBPF 程序。

加载与验证

编译完成后,通过内核中 eBPF 系统调用将程序装载,不过在此之前,还需要经过两个步骤。

验证器

由于 eBPF 程序注入内核执行,因此必须保证其安全性。

eBPF 程序只有通过验证器检测,确保安全后才会被允许注入内核。

JIT 编译

Just-in-Time 编译步骤将通用字节码转换为机器指令集从而提升运行效率。

这使得 eBPF 程序可以像本地编译的内核代码或作为内核模块加载的代码一样高效地运行。

info

eBPF 的 JIT 编译需要内核开启相应选项。

Maps 映射

eBPF 通过 maps 来安全地实现内核与用户空间之间的通信;不同的用户空间的加载程序与内核中的 eBPF 程序可以访问同一个 maps。

eBPF maps 的类型很多,包括但不限于:哈希表或数组、LRU (Least Recently Used)、Ring Buffer、Stack Trace、LPM (Longest Prefix match)等等。

即使创建 maps 的 eBPF 程序已经终止,maps仍可以继续存在并与用户空间或其他 eBPF 程序通信,以此可以对数据进行持久化。

maps 是存储在内核中的高效键值对,根据需要保存的数据结构选择合适的 maps 类型即可。

辅助函数

eBPF 程序不能随意调用内核函数,而需要使用 eBPF 库中定义的辅助函数API,这使得 eBPF 程序的源代码不会依赖于特定的内核版本。

通过辅助函数,我们可以完成前面我们需要的各种操作,如:生成随机数、获取时间戳、访问 eBPF maps、访问进程或 cgroup 上下文、控制数据包传输等。

尾调用

单个 eBPF 程序的大小是有上限的,但通过尾调用和函数调用,可以拓展 eBPF 程序的功能,通过另一个 eBPF 程序执行并调换上下文。

其类似于 execve() 系统调用对常规进程的操作方式。

eBPF 安全性

eBPF 的安全性是通过几个层级来确保的:

所需权限

通常加载 eBPF 程序需要特权模式(root),或根据需要的功能选择需要的 capability CAP_BPF。

这意味着不受信任的程序无法加载 eBPF 程序。

如果启用了非特权 eBPF,非特权进程可以加载某些 eBPF 程序,但这些程序对内核的访问和使用的功能集受限。

验证器

加载 eBPF 程序必须经过验证器,在几个方面检查,如:

  • eBPF 程序是可终止的,如不会阻塞或陷入死循环;
  • 程序不得使用任何未初始化的变量或越界访问内存;
  • 程序必须符合系统的大小要求。无法加载任意大的 eBPF 程序;
  • 程序必须具有有限的复杂性。验证者将评估所有可能的执行路径,并且必须能够在配置的复杂性上限的范围内完成分析。

加固

在成功完成验证后,eBPF 程序会根据程序是从特权进程还是非特权进程加载来运行一个加固过程。这一步包括:

  • 程序执行保护: 保存 eBPF 程序的内核内存受到保护并被设为只读。无论是内核错误、恶意操作或其他任何理由,当尝试修改 eBPF 程序,内核将崩溃,而不会允许它继续执行损坏或被操作的程序。

  • Spectre 缓解: CPU 可能会错误预测分支从而留下可被边通道提取的的副作用,而 eBPF 会尽量避免该类问题,如:eBPF程序屏蔽内存访问,以便将瞬态指令下的访问重定向到控制区域,验证器还遵循仅在推测性执行下可访问的程序路径,如果尾部调用无法转换为直接调用,JIT编译器将发出retpoline。

  • 常量屏蔽: 代码中的所有常量都被屏蔽,以防止 JIT 喷射攻击。这可以防止在存在另一个内核错误的情况下,攻击者将可执行代码作为常量注入,并可能跳入 eBPF 程序的内存部分以执行代码。

caution

这部分涉及到系统安全性问题,且技术名词不太容易翻译和解释。感兴趣请搜索 spectreconstant blinding

抽象的运行时上下文

eBPF 程序不能直接访问任意内核内存,必须通过 eBPF 辅助函数访问位于程序上下文之外的数据和数据结构。

这保证了一致的数据访问并使任何此类访问受到 eBPF 程序的特权的约束,例如在保证安全的情况下,允许运行的 eBPF 程序修改某些数据结构的数据。

eBPF 程序不能随意修改内核中的数据结构。


"Hello World!"

这之前的介绍内容,通过 Google 等搜索引擎,你都可以找到大致的信息。

eBPF 最大的问题就是难上手,一方面是 eBPF 还属于比较新的技术,正在随 Linux 内核版本更新快速迭代中;另一方面 eBPF 应用比较底层,普通的技术人员是不会过多接触的。

大部分时候,你能搜索到的都是泛泛的介绍,而没有实质的代码样例,更别提其他有深度的讲解了;再加上 eBPF 对于内核版本与编译环境有要求,同一份代码是不一定能够在任意一台 Linux 机器上成功编译运行的;因此仅通过网络资料想要学好 eBPF 还是有一定的困难的。

使用百度搜索的话,除了介绍几乎没了,来源几乎全是国外文本的翻译,国内的复制粘贴博客质量可见一斑。

言归正规,学习一门新的语言,首先要掌握:打印 "hello world"!学习 eBPF 也是如此。

下面就简单介绍下纯 C 语言编写样例 eBPF 程序。

如果只想简便尝试 eBPF 程序,请直接跳转到 BCC 部分。

运行eBPF程序的源代码可以简要分为两部分:

  • eBPF 程序:注入内核工作的部分,针对设置的跟踪点执行操作;
  • 加载程序:位于用户空间执行,使用 eBPF 库的辅助函数将加载 eBPF 程序到指定的内核跟踪点位置。

配置环境

在正式编写 eBPF 程序前,请务必检查并配置好相关环境。

笔者使用的是 Ubuntu 18.04 LTS 发行版,内核版本是 5.4.0-73-generic。

请尽可能选择新版本的内核,最好是 5.* 以上。

由于需要编译内核程序,因此需要的工具也比较多,包括但不限于:

安装需要的工具
sudo apt update
sudo apt install build-essential git make libelf-dev clang strace tar bpfcc-tools linux-headers-$(uname -r) gcc-multilib

由于 eBPF 库与辅助函数定义于 Linux 内核源码中,因此还需要下载 Linux 内核源码并编译 libbpf 库。

下载 Ubuntu 18.04 内核源码至需要路径
git clone --depth 1 git://kernel.ubuntu.com/ubuntu/ubuntu-bionic.git
进入内核源码路径编译安装 libbpf
cd /kernel-src/tools/lib/bpf
sudo make && sudo make install prefix=/usr/local
sudo mv /usr/local/lib64/libbpf.* /lib/x86_64-linux-gnu/ # for Ubuntu 18.04

根据编写的 eBPF 程序,可能还有其他环境需求。

eBPF 程序

一个标准的BPF程序通常使用C语言编写,其类似于一个标准 C 程序,但并不包含main函数。

在 eBPF 程序中,除核心的操作函数外,一般包含:引用头文件、定义函数与宏、声明附加点与协议等信息等部分。

以下示例源代码。

bpf_program.c
#include <linux/bpf.h>

// 定义宏用于声明信息
#define SEC(NAME) __attribute__((section(NAME), used))

// 静态定义辅助函数
static int (*bpf_trace_printk)(const char *fmt, int fmt_size,
...) = (void *)BPF_FUNC_trace_printk;
// 核心函数
SEC("tracepoint/syscalls/sys_enter_execve") // 声明 eBPF 程序附加点
int bpf_prog(void *ctx) {
// 输出 debug 信息
char msg[] = "Hello, BPF World!";
bpf_trace_printk(msg, sizeof(msg));

return 0;
}

char _license[] SEC("license") = "GPL"; // 声明协议

eBPF 程序的核心函数的格式是由 eBPF 程序类型决定,而程序类型又由选择的附加点决定。

在本样例中,eBPF 程序被附加在系统调用上,当执行进入 sys_enter_execve 时,eBPF 程序将被触发。

核心函数的函数名是任意的,但返回类型固定为 int,由于不需要其他功能,上下文参数也选择 void 指针即可。

触发 eBPF 程序后,将输出 debug 信息 "Hello, BPF World!"。

为了输出 debug 信息,静态定义了辅助函数。

eBPF 程序中必须声明使用 GPL 协议,否则程序将不被信任并拒绝被装载。

加载程序

相对来说,本例中的加载程序比较简单,利用辅助函数加载和读取信息即可。

loader.c
// 引入 bpf库文件
#include "bpf_load.h"
#include <stdio.h>

int main(int argc, char **argv) {
// 使用辅助函数加载 eBPF 程序
if (load_bpf_file("bpf_program.o") != 0) {
printf("The kernel didn't load the BPF program\n");
return -1;
}

read_trace_pipe(); // 使用辅助函数读取 debug 信息

return 0;
}

Makefile & 编译

编译是运行整个 eBPF 程序最麻烦的过程,一方面涉及内核源代码的头文件,另一方面离不开编译环境的配置。

仅仅是最简单的 eBPF 程序,也需要比较麻烦的 Makefile 文件控制编译过程。

以下 Makefile 文件仅供参考,使用请检查各个路径。

Makefile
CLANG = clang

EXECABLE = monitor-exec

BPFCODE = bpf_program

BPFTOOLS = /kernel-src/samples/bpf
BPFLOADER = $(BPFTOOLS)/bpf_load.c

CCINCLUDE += -I/kernel-src/tools/testing/selftests/bpf

LOADINCLUDE += -I/kernel-src/samples/bpf
LOADINCLUDE += -I/kernel-src/tools/lib
LOADINCLUDE += -I/kernel-src/tools/perf
LOADINCLUDE += -I/kernel-src/tools/include
LIBRARY_PATH = -L/usr/local/lib64
BPFSO = -lbpf

CFLAGS += $(shell grep -q "define HAVE_ATTR_TEST 1" /kernel-src/tools/perf/perf-sys.h \
&& echo "-DHAVE_ATTR_TEST=0")

.PHONY: clean $(CLANG) bpfload build

clean:
rm -f *.o *.so $(EXECABLE)

build: ${BPFCODE.c} ${BPFLOADER}
$(CLANG) -O2 -target bpf -c $(BPFCODE:=.c) $(CCINCLUDE) -o ${BPFCODE:=.o}

bpfload: build
clang $(CFLAGS) -o $(EXECABLE) -lelf $(LOADINCLUDE) $(LIBRARY_PATH) $(BPFSO) \
$(BPFLOADER) loader.c

$(EXECABLE): bpfload

.DEFAULT_GOAL := $(EXECABLE)

执行与输出

编译完成后,当前路径下会新增可执行文件 monitor-exec,使用管理员权限执行。

在另一个终端中,输入指令,如 ls,将会触发系统调用,从而执行 eBPF 程序。

此时可在原终端中发现输出信息,形如:

eBPF 输出
            bash-709     [010] .... 167044.897839: 0: Hello, BPF World!

至此,最简单的 C 语言实现 eBPF 程序完成。

其他

提示缺少 asm/types.h

检查是否存在 /usr/include/asm-generic 文件夹,将其重命名为 /usr/include/asm 可解决报错。

推测是内核等版本问题。


熟练前请用 BCC 吧

简而言之,使用纯 C 语言编写实在太麻烦了,最头疼的就是编译,不但要引入各种 eBPF 库文件,还得在进入内核源代码路径内编译。

请使用 BCC 吧,用它不但能避免在编译与环境上浪费大量时间,其自带了 eBPF 库,因此还能简化 eBPF 程序的代码。

安装 BCC

同上,依旧是 Ubuntu 18.04 LTS 发行版,内核版本是 5.4.0-73-generic。

首先配置编译 BCC 所需环境。

安装需要的工具
sudo apt-get -y install bison build-essential cmake flex git libedit-dev \
libllvm6.0 llvm-6.0-dev libclang-6.0-dev python zlib1g-dev libelf-dev libfl-dev

随后下载 BCC 源代码

info

请直接下载带有 submodule 模块的 BCC 源代码,能避免编译问题。

完成后,解压进行编译。默认 BCC 使用 python 2。

编译 BCC
mkdir bcc/build; cd bcc/build
cmake ..
make
sudo make install

# build python3 binding
cmake -DPYTHON_CMD=python3 ..
pushd src/python/
make
sudo make install
popd

正常情况下,到此就可以使用 BCC 来编写 eBPF 程序了!

超简单的 "Hello World!"

hello.py
#!/usr/bin/python
from bcc import BPF

BPF(text='int kprobe____x64_sys_clone(void *ctx) { bpf_trace_printk("Hello, World!\\n"); return 0; }').trace_print()

没错,使用 BCC 编写,就是这么简单。

实质上,BCC 编写的 eBPF 程序,将内核与用户空间的代码合并到了一个文件中(如果需要仍能分为两个)。

在前面 C 版本的 eBPF 程序的基础上,相信读者能够很快发现,他们的结构是类似的。

BCC 的 eBPF 程序可以通过函数名直接声明附加点(也可以在用户空间的代码中声明),并且可以略去其他繁琐的声明部分。

使用 BCC 将不再需要用户关注编译的过程,直接运行 python 程序即可。

本例中,其结果类似:

sudo python ./hello.py
            bash-620     [008] .... 174288.180808: 0: Hello, World!

与纯 C 语言对比,BCC 在开发过程上带来的优势非常巨大,因此推荐熟练掌握 eBPF 前均使用 BCC 编写 eBPF 程序。


简单总结

本文至此,简单介绍了 eBPF 技术,并给出了简单的示例。

很显然,eBPF 可以作为一类跟踪程序,用来检测某些系统事件或程序的发生和运行。

有关 eBPF 的更多介绍,后续还会继续更新。


Referrences

主要参考了:

eBPF - Introduction, Tutorials & Community Resources

bpftools/linux-observability-with-bpf: Code snippets from the O'Reilly book

iovisor/bcc: BCC - Tools for BPF-based Linux IO analysis, networking, monitoring, and more

有关 eBPF 的介绍网络上的资料还是比较少的,且基本全是英文。

对于入门,还是更推荐先阅读书籍,对 eBPF 技术整体有个简单了解:

Linux Observability with BPF - David Calavera, Lorenzo Fontana, O'Reilly, Nov 2019

BPF Performance Tools - Brendan Gregg, Addison-Wesley Professional Computing Series, Dec 2019

这两本书均有中文翻译实体书,可以放心购买阅读。


最后

终于写了我心心念念的eBPF 😘!

eBPF 还是比较难写的,泛泛的资料查了英文资料再考虑翻译,担心出错也是再三查找修改。

可惜的说,我既想尽可能详细,又怕过于啰嗦,最后翻译后效果还是一般。

源代码部分还是参考了别人的样例,因为我自己写也很难更简洁或更正确了。

我对自己要求不高,别人看了能懂,能比自己去查资料更方便就够了,当然看了能对 eBPF 感兴趣是最好的。

抵制国内复制粘贴低创垃圾博客,我辈义不容辞!

努力把关于 eBPF 学习到的和理解的分享出来~