WEBKT

eBPF实战:追踪`open()`系统调用,揪出应用的文件访问秘密

44 0 0 0

为什么选择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()

这段代码主要做了以下几件事情:

  1. 定义eBPF程序:使用C语言定义了一个eBPF程序,该程序通过kprobe探针,在do_sys_openat2函数(open()系统调用的内核实现)执行时被触发。程序会读取当前进程的PID、时间戳、进程名、文件名和权限等信息,并将这些信息通过perf ring buffer发送到用户空间。
  2. 创建BPF实例:使用bcc库创建一个BPF实例,并将上面定义的eBPF程序加载到内核中。
  3. 定义回调函数:定义一个回调函数print_event,用于处理eBPF程序输出的事件。该函数会将事件信息打印到终端。
  4. 绑定回调函数:将回调函数绑定到eBPF程序的输出,这样当eBPF程序有数据输出时,回调函数就会被调用。
  5. 循环读取输出:循环读取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.commevent.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技术,可以帮助我们更好地调试、优化和保护我们的系统。

内核侦探 eBPF系统调用性能分析

评论点评

打赏赞助
sponsor

感谢您的支持让我们更好的前行

分享

QRcode

https://www.webkt.com/article/9688