Linux 的可观测性 — 应急响应中的 eBPF 实践
eBPF tracepoint
场景
应对各种恶意外联场景,但是没有足够的可观测性,在难以复现,配置不方便改动的情况下以 eBPF hook 的形式完成相关应急。
相关的 Linux 系统调用函数:
socket(), bind(), accept()
sendto(), recvfrom()
可以进一步研究的内核相关的知识:
- Socket Buffer 数据结构的创建与释放(需要了解 Linux 连接建立在内核的全过程)
- Linux 相关的各类内核追踪机制,包括但不限于 kprobe、uprobe、tracepoint、dprobe、fprobe、ftrace、USDT (user statically defined tracepoint) 等。
推荐阅读:文末参考文献中标注了 IMPORTANT 字样的文献。
要求
要使用尽可能便利的新功能,推荐内核 5.4 + 。但实际上,Kernel 3.15 便引入了 eBPF 支持,JIT 在 3.16 开始引入,到 4.18 基本完成了对主流处理器架构的支持。相关的特性还在不断的更新增强。Ubuntu 系列到 21.04 以上(含 21.04)才启用了相关支持(虽然 18.04 的 HWE 内核已经非常新,5.4 版本),Debian 系列在 Debian 11 之后才有 CO-RE 支持…
关于 eBPF 的学习与实践
初步了解内部架构与运行过程
官方网站: https://ebpf.io
关联:
- Linux Kernel BPF Documentation
- Linux Kernel Tracing Mechanism
系统架构:
如下图所示,通常我们借助 LLVM 把编写的 eBPF 程序转换为 BPF 字节码,然后再通过 bpf 系统调用提交给内核执行。内核在接受 BPF 字节码之前,会首先通过验证器对字节码进行校验,只有校验通过的 BPF 字节码才会提交到即时编译器执行。
BPF 程序可以利用 BPF 映射(map)进行存储,而用户程序通常也需要通过 BPF 映射同运行在内核中的 BPF 程序进行交互。
eBPF 在内核中运行时的 JIT 架构如下所示:
- 第一个模块是 eBPF 辅助函数。它提供了一系列用于 eBPF 程序与内核其他模块进行交互的函数。
- 第二个模块是 eBPF 验证器。它用于确保 eBPF 程序的安全。验证器会将待执行的指令创建为一个有向无环图(DAG),确保程序中不包含不可达指令;接着再模拟指令的执行过程,确保不会执行无效指令。
- 第三个模块是由 11 个 64 位寄存器、一个程序计数器和一个 512 字节的栈组成的存储模块。这个模块用于控制 eBPF 程序的执行。其中,R0 寄存器用于存储函数调用和 eBPF 程序的返回值,这意味着函数调用最多只能有一个返回值;R1-R5 寄存器用于函数调用的参数,因此函数调用的参数最多不能超过 5 个;而 R10 则是一个只读寄存器,用于从栈中读取数据。
- 第四个模块是即时编译器,它将 eBPF 字节码编译成本地机器指令,以便更高效地在内核中执行。
- 第五个模块是 BPF 映射(map),它用于提供大块的存储。这些存储可被用户空间程序用来进行访问,进而控制 eBPF 程序的运行状态。
可以通过 Linux 软件包 bpftool 中的 bpftool (在 Arch 上是 bpf),使用命令:bpftool prog list
和 bpftool prog dump xlated id <ID>
读取和查看 eBPF 程序。
eBPF 程序的编写 1: BPF 系统调用
一个完整的 eBPF 程序通常包含用户态和内核态两部分。其中,用户态负责 eBPF 程序的加载、事件绑定以及 eBPF 程序运行结果的汇总输出;内核态运行在 eBPF 虚拟机中,负责定制和控制系统的运行状态。
其中用户态与内核交互的部分使用系统调用 int bpf()
,可以通过 man bpf
查看调用格式,函数签名如下:
cmd
为 BPF 命令, attr
为对应命令的属性参数指针,size
为属性的大小,命令的支持情况在对应内核头文件的 include/uapi/linux/bpf.h
中可以查看。
在内核态下,对应的 eBPF 函数不能随意调用内核函数,因此内核定义了一系列辅助函数,可以通过:bpftool feature probe
查看对应类型支持的辅助函数,也可通过 man bpf-helpers
查看。需要特别注意 bpf_probe_head
系列的辅助函数,用以读取内存中的字符串和数据,内核对此类函数会进行严格的安全检查,并禁止缺页中断的发生。需要注意的是 eBPF 内部的内存空间只有寄存器和栈。
eBPF 程序的编写 2:BPF Map
BPF Map 提供了 K-V 存储,用于获取 eBPF 程序的运行状态,程序最多可以访问 64 个不同的 BPF 映射。
BPF 的 Map 值可以通过用户程序的系统调用创建,例如:int bpf_create_map()
, map 的类型可以通过 bpftool feature probe
查看。BCC 提供了工具函数 BPF_HASH()
创建哈希表映射,相关的 Map 会在用户态程序关闭 fd 时自动删除,除非使用 BPF_OBJ_PIN
命令挂载对应 Map 到 /sys/fs/bpf
中,bpftool map
可用于查看和调试映射。
eBPF 程序的编写 3:BPF 类型格式 (BTF)
CO-RE: Compile Once, Run Everywhere.
为了解决依赖的内核 Header 中内核数据结构定义在不同版本的差异带来的潜在的程序开发对特定内核的强依赖的问题,内核 5.2 开始引入了 BTF,只需要开启 CONFIG_DEBUG_INFO_BTF
就会将对应数据结构定义内嵌到 vmlinux 中,可以通过 bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
导出到 Header 文件中。
而内核升级后带来的头文件差异导致无法运行的问题,eBPF 引入了 CO-RE 项目,借助 BTF 提供的调试信息,对 BPF 代码中的访问偏移进行了重写并配合在 libbpf 中预定义不同版本内核数据结构的修改,解决了兼容问题。
相关的依赖库与支持情况大致如下:
eBPF 程序的编写 4:BPF 程序的分类
- 跟踪类:从内核和程序的运行状态中提取跟踪信息,来了解当前系统正在发生什么。
- 网络类:即对网络数据包进行过滤和处理,以便了解和控制网络数据包的收发过程。
网络类 eBPF 程序又可以分为 XDP(eXpress Data Path,高速数据路径)程序、TC(Traffic Control,流量控制)程序、套接字程序以及 cgroup 程序,下面我们来分别看看。
XDP 程序的类型定义为 BPF_PROG_TYPE_XDP
,它在网络驱动程序刚刚收到数据包时触发执行。由于无需通过繁杂的内核网络协议栈,XDP 程序可用来实现高性能的网络处理方案,常用于 DDoS 防御、防火墙、4 层负载均衡等场景。
TC 程序的类型定义为 BPF_PROG_TYPE_SCHED_CLS
和 BPF_PROG_TYPE_SCHED_ACT
,分别作为 Linux 流量控制 的分类器和执行器。Linux 流量控制通过网卡队列、排队规则、分类器、过滤器以及执行器等,实现了对网络流量的整形调度和带宽控制。
套接字程序用于过滤、观测或重定向套接字网络包,具体的种类也比较丰富。根据类型的不同,套接字 eBPF 程序可以挂载到套接字(socket)、控制组(cgroup )以及网络命名空间(netns)等各个位置。你可以根据具体的应用场景,选择一个或组合多个类型的 eBPF 程序,去控制套接字的网络包收发过程。
cgroup 程序用于对 cgroup 内所有进程的网络过滤、套接字选项以及转发等进行动态控制,它最典型的应用场景是对容器中运行的多个进程进行网络控制。
- 第三类是除跟踪和网络之外的其他类型,包括安全控制、BPF 扩展等等。
eBPF 程序的编写 5:Event-driven tracing 与 Tracepoint
内核函数:/proc/kallsyms
不过需要提醒你的是,这些符号表不仅包含了内核函数,还包含了非栈数据变量。而且,并不是所有的内核函数都是可跟踪的,只有显式导出的内核函数才可以被 eBPF 进行动态跟踪。因而,通常我们并不直接从内核符号表查询可跟踪点。
通常,以 Root 权限访问 /sys/kernel/debug
是常用的查询内核函数和Tracepoint的方法,如果此文件不存在,可以通过 sudo mount -t debugfs debugfs /sys/kernel/debug
挂载它,eBPF 的执行同样依赖 debugfs。
可用事件列表:https://www.kernel.org/doc/html/latest/trace/events.html
性能事件可以通过:sudo perf list <TYPE>
来查看。
每一个 BPF 的处理函数应当首参数使用 void *ctx
接受一个 eBPF JIT 引擎的上下文。然后编写好对应的 JIT 程序后编译为 BPF Bytecode ,接下来使用相对应的 Loader 加载到内核对应的部分上,由内核验证程序合法性与可用性后执行,例如:挂载到内核探针 kprobe 中对应的 openat2()
函数调用使用的 Tracepoint 为:syscalls:sys_enter_openat2
,对应的 tracepoint 可以自己定义,内核也有内置一些预定义的 Tracepoints。
这里我们试着启用 sock:inet_sock_set_state
并查看输出格式,会发现其实实际还是 TCP Tracing (老版本中,此 Tracepoint 被命名为:sock:tcp_set_state
),读取对应的 Tracepoint 下的 format 文件可以查看参数的调用格式:
https://www.giac.org/paper/gcda/32/securing-soft-underbelly-supercomputer-bpf-probes/154681
如果需要编写一个事件映射 Map 来传输数据,可以自定义一个 struct,以性能事件映射为例,并通过 BPF_PERF_OUTPUT(struct_name)
来定义性能事件映射,在 eBPF 程序中调用相关的辅助函数,填充这个数据结构之后通过 events.perf_submit()
函数调用提交到定义的映射中。然后与之对应的,相关的事件映射存储在 Ring Buffer 中,使用用户态辅助函数 (以 BCC 为例,BCC 是 eBPF 的一个抽象和封装框架)open_perf_buffer(callback_func)
获得数据并打印到用户态程序的 stdout。
eBPF 程序的编写 6:使用 bpftrace 脚本并尝试编写跟踪脚本
通过: sudo bpftrace -l
可以查看所有的 kprobe 和 tracepoint,支持通配符查询。-v
参数可以查看调用参数和返回值。当 kprobe 和 tracepoint 均同时可用的情况下,应当优先选择使用 tracepoint 以保证 CO-RE。
关于 bpftrace 的语法,可以进一步参考:https://github.com/iovisor/bpftrace/blob/master/docs/reference_guide.md#1-builtins
需要注意的是 eBPF 的虚拟环境中只有 512 字节的堆栈,因此超过对应长度的内容只能放到 eBPF map 中。读取字符串的函数返回的长度包含末尾的 \0
,如需拼接,需要排除在外。
eBPF 程序的编写 7 :libbpf 实践
在开始之前:
- 修改
/etc/security/limits.conf
或/etc/systemd/system/system.conf
,将RLIMIT_MEMLOCK
提升为 unlimited,以确保内存空间可以放下我们的 map,并通过ulimit -l
检查,注意:ulimit 为 bash function,并非程序,所以不可直接在 shell 之外执行。 - 从 vmlinux 生成内核数据头文件供 libbpf 调用:
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
libbpf 用户态程序也需要 eBPF 程序加载、挂载到跟踪点,以及通过 BPF 映射获取和打印执行结果等几个步骤。
在编译时,通过 SEC() 宏定义的数据结构和函数会放到特定的 ELF 段中,这样后续在加载 BPF 字节码时,就可以从这些段中获取所需的元数据。
一个示例:
// 包含头文件
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
// 定义进程基本信息数据结构
struct event {
char comm[TASK_COMM_LEN];
pid_t pid;
int retval;
int args_count;
unsigned int args_size;
char args[FULL_MAX_ARGS_ARR];
};
// 定义哈希映射
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 10240);
__type(key, pid_t);
__type(value, struct event);
} execs SEC(".maps");
// 定义性能事件映射
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(u32));
__uint(value_size, sizeof(u32));
} events SEC(".maps");
// sys_enter_execve跟踪点
SEC("tracepoint/syscalls/sys_enter_execve")
int tracepoint__syscalls__sys_enter_execve(struct trace_event_raw_sys_enter *ctx)
{
// 待实现处理逻辑
}
// sys_exit_execve跟踪点
SEC("tracepoint/syscalls/sys_exit_execve")
int tracepoint__syscalls__sys_exit_execve(struct trace_event_raw_sys_exit *ctx)
{
// 待实现处理逻辑
}
// 定义许可证(前述的BCC默认使用GPL)
char LICENSE[] SEC("license") = "Dual BSD/GPL";
这里需要注意的是,程序运行参数是以 \0
分割的,所以打印时需要使用替换为空格。更新 Map 和处理逻辑如下,使用完 Map 后及时清理:
SEC("tracepoint/syscalls/sys_enter_execve")
int tracepoint__syscalls__sys_enter_execve(struct trace_event_raw_sys_enter
*ctx)
{
struct event *event;
const char **args = (const char **)(ctx->args[1]);
const char *argp;
// 查询PID
u64 id = bpf_get_current_pid_tgid();
pid_t pid = (pid_t) id;
// 保存一个空的event到哈希映射中
if (bpf_map_update_elem(&execs, &pid, &empty_event, BPF_NOEXIST)) {
return 0;
}
event = bpf_map_lookup_elem(&execs, &pid);
if (!event) {
return 0;
}
// 初始化event变量
event->pid = pid;
event->args_count = 0;
event->args_size = 0;
// 查询第一个参数
unsigned int ret = bpf_probe_read_user_str(event->args, ARGSIZE,
(const char *)ctx->args[0]);
if (ret <= ARGSIZE) {
event->args_size += ret;
}
// 查询其他参数
event->args_count++;
#pragma unrollfor (int i = 1; i < TOTAL_MAX_ARGS; i++) {
bpf_probe_read_user(&argp, sizeof(argp), &args[i]);
if (!argp)
return 0;
if (event->args_size > LAST_ARG)
return 0;
ret =
bpf_probe_read_user_str(&event->args[event->args_size],
ARGSIZE, argp);
if (ret > ARGSIZE)
return 0;
event->args_count++;
event->args_size += ret;
}
// 再尝试一次,确认是否还有未读取的参数
bpf_probe_read_user(&argp, sizeof(argp), &args[TOTAL_MAX_ARGS]);
if (!argp)
return 0;
// 如果还有未读取参数,则增加参数数量(用于输出"...")
event->args_count++;
return 0;
}
SEC("tracepoint/syscalls/sys_exit_execve")
int tracepoint__syscalls__sys_exit_execve(struct trace_event_raw_sys_exit *ctx)
{
u64 id;
pid_t pid;
int ret;
struct event *event;
// 从哈希映射中查询进程基本信息
id = bpf_get_current_pid_tgid();
pid = (pid_t) id;
event = bpf_map_lookup_elem(&execs, &pid);
if (!event)
return 0;
// 更新返回值和进程名称
ret = ctx->ret;
event->retval = ret;
bpf_get_current_comm(&event->comm, sizeof(event->comm));
// 提交性能事件
size_t len = EVENT_SIZE(event);
if (len <= sizeof(*event))
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, event,
len);
// 清理哈希映射
bpf_map_delete_elem(&execs, &pid);
return 0;
}
编写完内核态 eBPF 程序后,使用 clang 编译成字节码并生成用户态程序的基本框架头文件:
clang -g -O2 -target bpf -D__TARGET_ARCH_x86_64 -I/usr/include/x86_64-linux-gnu -I. -c execsnoop.bpf.c -o execsnoop.bpf.o
bpftool gen skeleton execsnoop.bpf.o > execsnoop.skel.h
用户态程序的基本框架如下:
// 引入脚手架头文件
#include "execsnoop.skel.h"
// C语言主函数
int main(int argc, char **argv)
{
// 定义BPF程序和性能事件缓冲区
struct execsnoop_bpf *skel;
struct perf_buffer_opts pb_opts;
struct perf_buffer *pb = NULL;
int err;
// 1. 设置调试输出函数
libbpf_set_print(libbpf_print_fn);
// 2. 增大 RLIMIT_MEMLOCK(默认值通常太小,不足以存入BPF映射的内容)
bump_memlock_rlimit();
// 3. 初始化BPF程序
skel = execsnoop_bpf__open();
// 4. 加载BPF字节码
err = execsnoop_bpf__load(skel);
// 5. 挂载BPF字节码到跟踪点
err = execsnoop_bpf__attach(skel);
// 6. 配置性能事件回调函数
pb_opts.sample_cb = handle_event;
pb = perf_buffer__new(bpf_map__fd(skel->maps.events), 64, &pb_opts);
// 7. 从缓冲区中循环读取数据
while ((err = perf_buffer__poll(pb, 100)) >= 0) ;
}
完整的代码如下:https://github.com/feiskyer/ebpf-apps/blob/main/bpf-apps/execsnoop.c
最终编译命令为:
clang -g -O2 -Wall -I . -c execsnoop.c -o execsnoop.o
clang -Wall -O2 -g execsnoop.o -static -lbpf -lelf -lz -o execsnoop
实际上它们的实现逻辑都是类似的,无非就是找出跟踪点,然后在 eBPF 部分获取想要的数据并保存到 BPF 映射中,最后在用户空间程序中读取 BPF 映射的内容并输出出来。
对于用户态信息的插桩监控,需要使用:bpftrace -l '<TYPE>:<PATH>:*
查看,其中 TYPE 可以是 uprobe/usdt/uretprobe 等,二进制文件在编译时需要保留 DWARF 调试符号信息。 需要注意的是 用户态的监控中 uprobe 是基于文件的,意味着任何使用此文件的程序都会被插桩调试。
eBPF 程序的编写 8 :程序的跟踪
- 编译型程序:关注程序调用规范,1.17 前的 Go 程序使用 Plan 9 调用规范,之后使用 ABI 调用规范。
- 解释型程序:关注解释器的函数调用,可以使用 usdt 和 uprobe 两种。
- JIT 型程序:JIT 的状态只存在于内存中,需要理解该类语言的底层原理后同解释型程序一样的追踪,推荐 USDT 追踪,Java 可以通过
--enable-dtrace
处理。
eBPF 用于系统性能优化
需要详细了解上层应用程序进入内核态后的调用逻辑,根据业务情况通过使用对应的 eBPF 程序类型编写对应程序完成加载、优化、过滤。
Tracee 在应急响应中的应用
Tracee 地址:https://github.com/aquasecurity/tracee
执行使用参数 --trace event="socket,connect,sendto,sendmsg
过滤即可。
自行开发的小工具
追踪 connect() / socket() / sendto() / recvfrom()
系统调用,用于应急响应中的恶意外联场景。进一步的,后续可以考虑自定义程序追溯 open() / clone() / openat() / bind() / accept()
等系统调用或用户态API调用。
实验性质的工具:https://github.com/kmahyyg/ebpf-track-conn-exp ,利用了 eBPF 读取内核中预定义的 syscall tracepoint 数据。进一步的,可以考虑通过 kprobe / kretprobe / uprobe hook 到对应的函数,用于更加精确的抓取需要的运行数据。
推荐使用 Parrot Linux / Arch Linux / Kali 2020.2+ 版本。相关编译依赖包括:zlib, libbpf, libelf, clang, llvm, pkg-config, git, make, linux-headers. 由于 gcc 对 BPF JIT 字节码支持存在一定问题,不使用 gcc!
由于使用了 CO-RE 技术,过老的 Linux 无法运行。
代码结构:
headers/common.h
定义公共的perf_event
结构。*.ebpf.h
包含相关的 maps 和需要引入的 libbpf 头文件。*.ebpf.c
为内核态 eBPF 程序的定义,主要写明如何读取进入和退出相关 syscall 调用时的参数和返回值并输出到内核的perf_event
ring buffer(每一个 ring buffer 中的对象为对应的perf_event
的 file descriptor ,使用 reader 读出后解析 data 部分即可),运行过程中的数据则保存在 eBPF map 中用作临时中转使用。src-go
目录下的 go 程序负责载入编译好的 eBPF 内核态程序,读取 ring buffer 中的perf_event
数据,并解析后输出。
编写过程中主要的坑点:
- 内核态采集信息后传递到 map ,封装后传递到 ring buffer 再被用户态应用读取的过程中存在时间差,不可采用采集易失性的唯一标识符(例如:PID)匹配的方式获取某些运行数据。
- eBPF 程序受 memlock 设定限制,可能出现 memlock 过低导致无法载入的情况,需要自行调升。
- 内存堆栈 512 字节限制。
- JIT引擎编译后指令不超过 1M 条限制。
- eBPF verifier 禁用 uninitialized variable 和不安全的内核态、用户态之间的内存指针访问的限制。
- 链式指针调用读取内存中的内容属于不安全的内存指针访问,需要使用对应的 BPF Helper 函数替代。
- 对应的 C 中标准库函数和 Linux 内核其他 API 无法使用,需要使用带有
BPF_
或__
前缀的方法、宏、类型替代。 - 各类内核数据结构类型的转换。
- bpf2go 将内核态 C 源码编译后嵌入到 go 文件中时只支持单个 .c 源码文件,需要自行编写脚本合并多个程序。
编译方法:Git repo 根目录下 make all
。
对于支持 CO-RE 的发行版,即上述的推荐运行环境,可以直接下载 GitHub Release 中的 Binary 后以 Root 权限运行。
现有的一些优秀项目:
- 上文提到的 Tracee
- https://github.com/ehids
- https://github.com/iovisor/bpftrace
- https://github.com/cilium/tetragon
参考文献
- https://arthurchiao.art/blog/bpf-advanced-notes-1-zh/
- https://github.com/iovisor/bcc/tree/master/docs (IMPORTANT)
- https://www.kernel.org/doc/html/latest/bpf/index.html
- https://www.kernel.org/doc/Documentation/trace/
- https://www.kernel.org/doc/html/latest/trace/tracepoints.html (IMPORTANT)
- https://linuxfoundation.org/wp-content/uploads/ezannoni-tracing-tutorial-LF-2021.pdf
- https://blog.cloudflare.com/tubular-fixing-the-socket-api-with-ebpf/
- https://nakryiko.com/posts/bpf-core-reference-guide/ CO-RE Guide, Complie Once-Run Everywhere. (IMPORTANT)
- https://github.com/aquasecurity/tracee
- https://sysdig.com/blog/the-art-of-writing-ebpf-programs-a-primer/
- https://blog.yadutaf.fr/2016/03/30/turn-any-syscall-into-event-introducing-ebpf-kernel-probes/
- https://blog.yadutaf.fr/2017/07/28/tracing-a-packet-journey-using-linux-tracepoints-perf-ebpf/
- https://facebookmicrosites.github.io/bpf/
- https://docs.cilium.io/en/latest/bpf/
- https://tmpout.sh/2/4.html (IMPORTANT)
- https://github.com/feiskyer/ebpf-apps/blob/main/bpf-apps/execsnoop.bpf.c
- https://vvl.me/2021/02/eBPF-3-eBPF-map/
- https://github.com/cilium/ebpf/blob/master/examples/tracepoint_in_c/tracepoint.c
- https://docs.cilium.io/en/stable/bpf/ (VERY IMPORTANT)
- https://github.com/trichimtrich/dns-tcp-ebpf
- https://github.com/p-/socket-connect-bpf
- https://qmonnet.github.io/whirl-offload/2016/09/01/dive-into-bpf/
- https://www.ferrisellis.com/content/ebpf_syscall_and_maps/
- https://stdio.be/blog/2016-09-12-Sending-data-from-eBPF-program-to-userspace/
- https://jvns.ca/blog/2017/07/05/linux-tracing-systems/
- https://research.nccgroup.com/2019/03/25/ebpf-adventures-fiddling-with-the-linux-kernel-and-unix-domain-sockets/
- https://stackoverflow.com/questions/69413427/bpf-verifier-rejetcs-the-use-of-an-inode-ptr-as-a-key
- https://blog.aquasec.com/ebf-portable-code (IMPORTANT)
- https://stackoverflow.com/questions/60383861/failure-to-compare-strings-with-ebpf
About Kernel Tracepoints: