使用eBPF追踪进程文件打开操作实战
eBPF 简介
核心思路:追踪 open 系统调用
eBPF 程序示例 (简化版)
进阶:处理 openat 系统调用
安全性和性能注意事项
总结
想知道某个进程偷偷摸摸打开了哪些文件?或者需要排查某个服务的文件访问行为?eBPF (extended Berkeley Packet Filter) 给你提供了一个强大的武器,可以在内核态进行安全高效的观测和分析,而无需修改内核代码或加载内核模块。
eBPF 简介
eBPF 最初是为网络数据包过滤设计的,但现在已经发展成为一个通用的内核态虚拟机,允许用户在内核中运行自定义程序,从而实现各种各样的功能,例如性能分析、安全监控、网络优化等等。eBPF 程序运行在受限的环境中,需要经过严格的验证,确保程序的安全性,例如防止程序崩溃、死循环等。
核心思路:追踪 open
系统调用
我们的目标是追踪特定进程打开文件的操作,因此需要找到合适的切入点。在 Linux 系统中,open
和 openat
系统调用负责打开文件。我们可以利用 eBPF 的 kprobe 或 tracepoint 功能,在这两个系统调用上挂载我们的 eBPF 程序,从而拦截文件打开操作。
- kprobe: 允许你动态地在内核函数的入口和出口处插入探测点。这非常灵活,但依赖于内核函数的具体实现,可能会因为内核版本升级而失效。
- tracepoint: 是内核中预先定义好的静态探测点。相比 kprobe,tracepoint 更加稳定,不容易受到内核版本变化的影响。
考虑到稳定性,我们这里选择使用 tracepoint
。open
相关的 tracepoint 一般是 syscalls:sys_enter_open
和 syscalls:sys_exit_open
,分别在 open
系统调用进入和退出时触发。
eBPF 程序示例 (简化版)
下面是一个简化的 eBPF 程序示例,用于追踪指定 PID 的进程打开文件的操作。为了便于理解,这里使用 bcc
(BPF Compiler Collection) 框架来简化 eBPF 程序的编写和部署。
from bcc import BPF import os # 定义 eBPF 程序 program = ''' #include <linux/sched.h> // 定义输出数据结构 struct data_t { u32 pid; char filename[256]; }; // 定义 BPF 映射 (BPF map),用于将数据从内核态传递到用户态 BPF_PERF_OUTPUT(events); // tracepoint 处理函数 tracepoint(syscalls, sys_enter_open) { // 获取当前进程的 PID u32 pid = bpf_get_current_pid_tgid(); // 过滤指定的 PID if (pid != TARGET_PID) { return 0; // 忽略其他进程 } // 获取文件名 struct data_t data = {}; data.pid = pid; bpf_probe_read_user(&data.filename, sizeof(data.filename), (void *)args->filename); // 将数据发送到用户态 events.perf_submit(args, &data, sizeof(data)); return 0; } ''' # 替换目标 PID target_pid = int(os.getenv("TARGET_PID")) # 从环境变量中获取目标PID,更灵活 program = program.replace('TARGET_PID', str(target_pid)) # 加载 eBPF 程序 bpf = BPF(text=program) # 打印输出 def print_event(cpu, data, size): event = bpf["events"].event(data) print(f"PID: {event.pid}, Filename: {event.filename.decode('utf-8', 'replace')}") # 注册回调函数 bpf["events"].open_perf_buffer(print_event) # 循环读取事件 while True: try: bpf.perf_buffer_poll() except KeyboardInterrupt: exit()
代码解释:
#include <linux/sched.h>
: 引入内核调度相关的头文件,用于获取进程 PID。struct data_t
: 定义了一个结构体,用于存储 PID 和文件名。文件名使用字符数组存储。BPF_PERF_OUTPUT(events)
: 定义了一个 BPF 映射,类型为perf_output
。perf_output
是一种特殊的映射,用于将数据从内核态异步地发送到用户态,避免阻塞内核程序的执行。tracepoint(syscalls, sys_enter_open)
: 定义了一个 tracepoint 处理函数,挂载到syscalls:sys_enter_open
这个 tracepoint 上。syscalls
是 tracepoint 的类别,sys_enter_open
是 tracepoint 的名称。bpf_get_current_pid_tgid()
: eBPF 内置函数,用于获取当前进程的 PID 和线程组 ID。返回值是一个 64 位的整数,高 32 位是 PID,低 32 位是线程组 ID。我们这里只关心 PID,所以只取高 32 位。if (pid != TARGET_PID)
: 过滤非目标进程的事件。TARGET_PID
需要替换成实际的目标进程 PID。bpf_probe_read_user(&data.filename, sizeof(data.filename), (void *)args->filename)
: eBPF 内置函数,用于从用户态内存读取数据。由于文件名位于用户态内存,我们需要使用这个函数来读取。args->filename
是sys_enter_open
tracepoint 传递的参数,指向用户态的文件名字符串。events.perf_submit(args, &data, sizeof(data))
: 将数据提交到perf_output
映射,异步地发送到用户态。bpf = BPF(text=program)
: 使用bcc
框架加载 eBPF 程序。bpf["events"].open_perf_buffer(print_event)
: 注册一个回调函数print_event
,用于处理从内核态发送过来的事件。bpf.perf_buffer_poll()
: 循环读取perf_output
映射中的事件,并调用回调函数进行处理。
使用方法:
- 安装
bcc
:sudo apt-get install bpfcc-tools linux-headers-$(uname -r)
- 保存代码为
trace_open.py
- 设置环境变量
TARGET_PID
:export TARGET_PID=<目标进程PID>
- 运行脚本:
sudo python3 trace_open.py
进阶:处理 openat
系统调用
openat
系统调用是 open
的一个变体,它允许你相对于一个文件描述符打开文件。为了完整地追踪文件打开操作,你还需要处理 openat
系统调用。你可以通过挂载 syscalls:sys_enter_openat
这个 tracepoint 来实现。openat
的参数略有不同,你需要根据 openat
的参数结构来读取文件名。
安全性和性能注意事项
- eBPF 程序的安全性至关重要。eBPF 程序运行在内核态,如果程序存在漏洞,可能会导致系统崩溃。因此,eBPF 程序需要经过严格的验证,确保程序的安全性。
- eBPF 程序的性能也需要考虑。eBPF 程序会消耗 CPU 资源,如果程序过于复杂,可能会影响系统性能。因此,eBPF 程序应该尽可能地简单高效。
- 限制 eBPF 程序的执行时间。eBPF 程序的执行时间应该尽可能地短,避免长时间占用 CPU 资源。可以使用
bpf_ktime_get_ns()
函数来获取当前时间,并根据时间来控制程序的执行。 - 使用 BPF 映射来存储数据。BPF 映射是一种高效的键值存储,可以在内核态和用户态之间共享数据。可以使用 BPF 映射来存储一些状态信息,例如文件打开次数等。
总结
eBPF 提供了强大的内核观测能力,可以用于追踪进程的文件打开操作。通过 kprobe 或 tracepoint,我们可以轻松地拦截 open
和 openat
系统调用,并记录打开的文件信息。但需要注意安全性和性能方面的注意事项,确保 eBPF 程序不会对系统造成负面影响。希望这篇文章能帮助你了解如何使用 eBPF 来追踪进程的文件打开操作。