使用eBPF追踪进程文件打开操作实战
想知道某个进程偷偷摸摸打开了哪些文件?或者需要排查某个服务的文件访问行为?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_opentracepoint 传递的参数,指向用户态的文件名字符串。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 来追踪进程的文件打开操作。