eBPF实战:追踪`open()`系统调用,揪出应用的文件访问秘密
为什么选择eBPF?
准备工作
编写eBPF程序
代码详解
eBPF程序
用户空间程序
运行程序
进阶应用
注意事项
总结
作为一名程序员,我们经常需要深入了解应用程序的行为。特别是在调试、性能分析和安全审计等场景下,能够追踪特定函数的执行路径和参数信息,无疑是一项强大的技能。eBPF(Extended Berkeley Packet Filter)正是这样一种技术,它允许我们在内核中安全地运行自定义代码,而无需修改内核源代码或加载内核模块。
本文将以追踪open()
系统调用为例,介绍如何利用eBPF来监控应用程序的文件访问行为。通过记录open()
系统调用打开的文件名和权限,我们可以深入了解应用程序的文件读写模式,发现潜在的安全漏洞或性能瓶颈。
为什么选择eBPF?
在深入探讨具体实现之前,我们先来了解一下为什么选择eBPF来实现这个任务。
- 安全性:eBPF程序在内核中运行之前,会经过严格的验证器检查,确保程序的安全性和正确性,避免造成系统崩溃或安全漏洞。
- 高性能:eBPF程序可以被JIT(Just-In-Time)编译成本地机器码,从而获得接近原生代码的执行效率。这使得eBPF成为一种高性能的内核态代码执行引擎。
- 灵活性:eBPF程序可以动态加载和卸载,无需重启系统或修改内核源代码。这使得eBPF成为一种非常灵活的内核扩展机制。
- 广泛支持:现代Linux内核已经广泛支持eBPF,并且提供了丰富的API和工具,方便开发者编写和调试eBPF程序。
准备工作
在开始编写eBPF程序之前,我们需要准备以下工具和环境:
- Linux内核:建议使用较新的Linux内核版本(4.14及以上),以获得更好的eBPF支持。
- bcc:bcc(BPF Compiler Collection)是一个Python库,提供了方便的API和工具,用于编写、编译和加载eBPF程序。
- libbpf:libbpf是一个C库,提供了更底层的API,用于编写和管理eBPF程序。
- clang/LLVM:clang和LLVM是编译eBPF程序所必需的编译器和工具链。
安装bcc:
sudo apt-get update sudo apt-get install -y bpfcc-tools linux-headers-$(uname -r) sudo apt-get install -y python3-pip sudo pip3 install --upgrade pip sudo pip3 install bcc
编写eBPF程序
接下来,我们将使用bcc来编写一个简单的eBPF程序,用于追踪open()
系统调用。
from bcc import BPF # 定义eBPF程序 program = ''' #include <uapi/linux/ptrace.h> #include <linux/sched.h> struct data_t { u32 pid; u64 ts; char comm[TASK_COMM_LEN]; char filename[256]; int flags; }; BPF_PERF_OUTPUT(events); int kprobe__do_sys_openat2(struct pt_regs *ctx, int dirfd, const char *pathname, int flags, int mode) { struct data_t data = {}; data.pid = bpf_get_current_pid_tgid(); data.ts = bpf_ktime_get_ns(); bpf_get_current_comm(&data.comm, sizeof(data.comm)); bpf_probe_read_user(&data.filename, sizeof(data.filename), (void *)pathname); data.flags = flags; events.perf_submit(ctx, &data, sizeof(data)); return 0; } ''' # 创建BPF实例 bpf = BPF(text=program) # 定义回调函数,用于处理eBPF程序输出的事件 def print_event(cpu, data, size): event = bpf['events'].event(data) print(f'PID: {event.pid}, Timestamp: {event.ts}, Command: {event.comm.decode()}, Filename: {event.filename.decode()}, Flags: {event.flags}') # 绑定回调函数到eBPF程序的输出 bpf['events'].open_perf_buffer(print_event) # 循环读取eBPF程序的输出 while True: try: bpf.perf_buffer_poll() except KeyboardInterrupt: exit()
这段代码主要做了以下几件事情:
- 定义eBPF程序:使用C语言定义了一个eBPF程序,该程序通过kprobe探针,在
do_sys_openat2
函数(open()
系统调用的内核实现)执行时被触发。程序会读取当前进程的PID、时间戳、进程名、文件名和权限等信息,并将这些信息通过perf ring buffer发送到用户空间。 - 创建BPF实例:使用bcc库创建一个BPF实例,并将上面定义的eBPF程序加载到内核中。
- 定义回调函数:定义一个回调函数
print_event
,用于处理eBPF程序输出的事件。该函数会将事件信息打印到终端。 - 绑定回调函数:将回调函数绑定到eBPF程序的输出,这样当eBPF程序有数据输出时,回调函数就会被调用。
- 循环读取输出:循环读取eBPF程序的输出,并将读取到的事件信息传递给回调函数处理。
代码详解
下面我们来详细分析一下这段代码的关键部分。
eBPF程序
#include <uapi/linux/ptrace.h> #include <linux/sched.h> struct data_t { u32 pid; u64 ts; char comm[TASK_COMM_LEN]; char filename[256]; int flags; }; BPF_PERF_OUTPUT(events); int kprobe__do_sys_openat2(struct pt_regs *ctx, int dirfd, const char *pathname, int flags, int mode) { struct data_t data = {}; data.pid = bpf_get_current_pid_tgid(); data.ts = bpf_ktime_get_ns(); bpf_get_current_comm(&data.comm, sizeof(data.comm)); bpf_probe_read_user(&data.filename, sizeof(data.filename), (void *)pathname); data.flags = flags; events.perf_submit(ctx, &data, sizeof(data)); return 0; }
#include <uapi/linux/ptrace.h>
:包含了ptrace相关的头文件,用于定义struct pt_regs
结构,该结构包含了函数调用时的寄存器信息。#include <linux/sched.h>
:包含了进程调度相关的头文件,用于定义TASK_COMM_LEN
常量,该常量表示进程名的最大长度。struct data_t
:定义了一个结构体,用于存储要收集的数据,包括PID、时间戳、进程名、文件名和权限等信息。BPF_PERF_OUTPUT(events)
:定义了一个perf ring buffer,用于将数据从内核空间传递到用户空间。events
是ring buffer的名称。int kprobe__do_sys_openat2(struct pt_regs *ctx, int dirfd, const char *pathname, int flags, int mode)
:定义了一个kprobe探针函数,该函数会在do_sys_openat2
函数执行时被触发。函数参数包括:struct pt_regs *ctx
:包含了函数调用时的寄存器信息。int dirfd
:目录文件描述符。const char *pathname
:要打开的文件名。int flags
:打开文件的权限。int mode
:创建文件时的权限。
data.pid = bpf_get_current_pid_tgid()
:获取当前进程的PID。data.ts = bpf_ktime_get_ns()
:获取当前时间戳。bpf_get_current_comm(&data.comm, sizeof(data.comm))
:获取当前进程名。bpf_probe_read_user(&data.filename, sizeof(data.filename), (void *)pathname)
:从用户空间读取文件名。由于文件名存储在用户空间,因此需要使用bpf_probe_read_user
函数来读取。注意,这里需要将pathname
转换为void *
类型。data.flags = flags
:获取打开文件的权限。events.perf_submit(ctx, &data, sizeof(data))
:将收集到的数据通过perf ring buffer发送到用户空间。ctx
参数是必须的,即使我们没有使用它。return 0
:kprobe探针函数必须返回0。
用户空间程序
from bcc import BPF # 定义eBPF程序 program = ''' ... ''' # 创建BPF实例 bpf = BPF(text=program) # 定义回调函数,用于处理eBPF程序输出的事件 def print_event(cpu, data, size): event = bpf['events'].event(data) print(f'PID: {event.pid}, Timestamp: {event.ts}, Command: {event.comm.decode()}, Filename: {event.filename.decode()}, Flags: {event.flags}') # 绑定回调函数到eBPF程序的输出 bpf['events'].open_perf_buffer(print_event) # 循环读取eBPF程序的输出 while True: try: bpf.perf_buffer_poll() except KeyboardInterrupt: exit()
from bcc import BPF
:导入bcc库。bpf = BPF(text=program)
:创建一个BPF实例,并将上面定义的eBPF程序加载到内核中。text
参数指定了eBPF程序的源代码。def print_event(cpu, data, size)
:定义一个回调函数,用于处理eBPF程序输出的事件。该函数会将事件信息打印到终端。函数参数包括:cpu
:事件发生的CPU核心ID。data
:指向事件数据的指针。size
:事件数据的大小。
event = bpf['events'].event(data)
:从事件数据中解析出data_t
结构体。bpf['events']
表示perf ring buffer,event(data)
方法用于从data
指针中解析出事件数据。print(f'PID: {event.pid}, Timestamp: {event.ts}, Command: {event.comm.decode()}, Filename: {event.filename.decode()}, Flags: {event.flags}')
:将事件信息打印到终端。注意,event.comm
和event.filename
是字节数组,需要使用decode()
方法将其转换为字符串。bpf['events'].open_perf_buffer(print_event)
:将回调函数绑定到eBPF程序的输出。open_perf_buffer(print_event)
方法会创建一个perf buffer,并将回调函数注册到该buffer上。当eBPF程序有数据输出到perf buffer时,回调函数就会被调用。while True
:循环读取eBPF程序的输出。bpf.perf_buffer_poll()
:从perf buffer中读取数据,并调用相应的回调函数。except KeyboardInterrupt
:捕获键盘中断信号,当用户按下Ctrl+C时,程序退出。
运行程序
将上面的代码保存为open_tracer.py
,然后在终端中运行:
sudo python3 open_tracer.py
运行程序后,它会开始追踪open()
系统调用。当你运行其他程序时,如果它们调用了open()
系统调用,open_tracer.py
就会打印出相关的信息,包括PID、时间戳、进程名、文件名和权限等。
例如,如果你运行ls /tmp
命令,open_tracer.py
可能会打印出类似下面的信息:
PID: 1234, Timestamp: 1678886400123456789, Command: ls, Filename: /tmp, Flags: 0 PID: 1234, Timestamp: 1678886400234567890, Command: ls, Filename: /etc/ld.so.cache, Flags: 32768 PID: 1234, Timestamp: 1678886400345678901, Command: ls, Filename: /lib/x86_64-linux-gnu/libc.so.6, Flags: 32768 ...
进阶应用
通过修改eBPF程序,我们可以实现更复杂的功能,例如:
- 过滤特定进程:只追踪特定进程的
open()
系统调用。 - 统计文件访问次数:统计每个文件被访问的次数。
- 记录文件访问延迟:记录每次
open()
系统调用的延迟。 - 监控特定目录:只监控特定目录下的文件访问。
- 分析文件访问模式:分析应用程序的文件读写模式,发现潜在的性能瓶颈。
例如,要过滤特定进程,可以在eBPF程序中添加一个判断条件:
int kprobe__do_sys_openat2(struct pt_regs *ctx, int dirfd, const char *pathname, int flags, int mode) { u32 pid = bpf_get_current_pid_tgid(); if (pid != TARGET_PID) { return 0; } ... }
其中,TARGET_PID
是要追踪的进程的PID。你需要在用户空间程序中定义一个变量,并将该变量的值传递给eBPF程序:
target_pid = 1234 bpf = BPF(text=program, cflags=['-DTARGET_PID=%d' % target_pid])
注意事项
- 性能影响:eBPF程序在内核中运行,会对系统性能产生一定的影响。因此,在生产环境中部署eBPF程序时,需要仔细评估其性能影响。
- 安全风险:虽然eBPF程序经过严格的验证器检查,但仍然存在一定的安全风险。例如,如果eBPF程序存在漏洞,可能会被恶意利用,导致系统崩溃或安全漏洞。因此,在编写eBPF程序时,需要格外小心,避免出现安全问题。
- 内核兼容性:不同的Linux内核版本对eBPF的支持程度可能不同。因此,在编写eBPF程序时,需要考虑内核兼容性问题。
- 调试难度:eBPF程序在内核中运行,调试难度较高。可以使用bcc提供的调试工具,例如
bpftrace
,来调试eBPF程序。
总结
本文介绍了如何利用eBPF来追踪open()
系统调用,从而监控应用程序的文件访问行为。通过学习本文,你应该能够掌握eBPF的基本原理和使用方法,并能够利用eBPF来解决实际问题。希望本文能够帮助你更好地理解和使用eBPF技术。
eBPF为我们提供了一个强大的工具,让我们能够深入了解内核和应用程序的行为。掌握eBPF技术,可以帮助我们更好地调试、优化和保护我们的系统。