eBPF—使用kprobe探测内核系统调用

在前两篇文章中,我们了解了eBPF的基本知识。本文开始进行一次具体的实践,带大家通过kprobe实现一个简单的内核系统调用捕获,来了解如何在内核函数的出入口动态注入自定义代码。

一、概述

1. 什么是kprobe?

开发人员在内核或者模块的调试过程中,往往会需要要知道其中的一些函数有无被调用、何时被调用、执行是否正确以及函数的入参和返回值是什么等等。比较简单的做法是在内核代码对应的函数中添加日志打印信息,但这种方式往往需要重新编译内核或模块,重新启动设备之类的,操作较为复杂甚至可能会破坏原有的代码执行过程。

而利用 kprobes 技术,用户可以定义自己的回调函数,然后在内核或者模块中几乎所有的函数中(有些函数是不可探测的,例如kprobes自身的相关实现函数,后文会有详细说明)动态地插入探测点,当内核执行流程执行到指定的探测函数时,会调用该回调函数,用户即可收集所需的信息了,同时内核最后还会回到原本的正常执行流程。如果用户已经收集足够的信息,不再需要继续探测,则同样可以动态地移除探测点。因此 kprobes 技术具有对内核执行流程影响小和操作方便的优点。

kprobes 技术包括的3种探测手段分别时 kprobe、jprobe 和 kretprobe。首先 kprobe 是最基本的探测方式,是实现后两种的基础,它可以在任意的位置放置探测点(就连函数内部的某条指令处也可以),它提供了探测点的调用前、调用后和内存访问出错3种回调方式,分别是 pre_handler、post_handler 和 fault_handler,其中 pre_handler 函数将在被探测指令被执行前回调,post_handler 会在被探测指令执行完毕后回调(注意不是被探测函数),fault_handler 会在内存访问出错时被调用;jprobe 基于 kprobe 实现,它用于获取被探测函数的入参值;最后 kretprobe 从名字中就可以看出其用途了,它同样基于 kprobe 实现,用于获取被探测函数的返回值

kprobes 的技术原理并不仅仅包含纯软件的实现方案,它也需要硬件架构提供支持。其中涉及硬件架构相关的是 CPU 的异常处理和单步调试技术,前者用于让程序的执行流程陷入到用户注册的回调函数中去,而后者则用于单步执行被探测点指令,因此并不是所有的架构均支持 kprobes。目前 kprobes 技术已经支持多种架构,包括 i386、x86_64、ppc64、ia64、sparc64、arm、ppc 和 mips(有些架构实现可能并不完全,具体可参考内核的 Documentation/kprobes.txt)。

kprobes 的特点与使用限制:

  1. kprobes 允许在同一个被探测位置注册多个 kprobe,但是目前 jprobe 却不可以;同时也不允许以其他的 jprobe 回调函数和 kprobe 的 post_handler 回调函数作为被探测点。
  2. 一般情况下,可以探测内核中的任何函数,包括中断处理函数。不过在 kernel/kprobes.c 和 arch/*/kernel/kprobes.c 程序中用于实现 kprobes 自身的函数是不允许被探测的,另外还有do_page_fault 和 notifier_call_chain;
  3. 如果以一个内联函数为探测点,则 kprobes 可能无法保证对该函数的所有实例都注册探测点。由于 gcc 可能会自动将某些函数优化为内联函数,因此可能无法达到用户预期的探测效果;
  4. 一个探测点的回调函数可能会修改被探测函数的运行上下文,例如通过修改内核的数据结构或者保存与struct pt_regs结构体中的触发探测器之前寄存器信息。因此 kprobes 可以被用来安装 bug 修复代码或者注入故障测试代码;
  5. kprobes 会避免在处理探测点函数时再次调用另一个探测点的回调函数,例如在printk()函数上注册了探测点,而在它的回调函数中可能会再次调用printk函数,此时将不再触发printk探测点的回调,仅仅是增加了kprobe结构体中nmissed字段的数值;
  6. 在 kprobes 的注册和注销过程中不会使用 mutex 锁和动态的申请内存;
  7. kprobes 回调函数的运行期间是关闭内核抢占的,同时也可能在关闭中断的情况下执行,具体要视CPU架构而定。因此不论在何种情况下,在回调函数中不要调用会放弃 CPU 的函数(如信号量、mutex 锁等);
  8. kretprobe 通过替换返回地址为预定义的 trampoline 的地址来实现,因此栈回溯和 gcc 内嵌函数__builtin_return_address()调用将返回 trampoline 的地址而不是真正的被探测函数的返回地址;
  9. 如果一个函数的调用次数和返回次数不相等,则在类似这样的函数上注册 kretprobe 将可能不会达到预期的效果,例如do_exit()函数会存在问题,而do_execve()函数和do_fork()函数不会;
  10. 当在进入和退出一个函数时,如果 CPU 运行在非当前任务所有的栈上,那么往该函数上注册 kretprobe 可能会导致不可预料的后果,因此,kprobes 不支持在 X86_64 的结构下为__switch_to()函数注册 kretprobe,将直接返回-EINVAL。

二、实践

1. 准备kernel内核源码

在对系统调用植入探针的过程中,我们需要了解对应函数的参数和返回值定义,因此我们需要提前下载好对应版本的内核源码,以便随时查阅:

# 确认目标设备的linux版本
uname -a
    5.15.0-83-generic

# 下载kernel源码
git clone git@github.com:torvalds/linux.git   -b v5.15

2. kprobe ebpf程序实例

为了方便调试,我们这里选取内核的do_unlinkat函数(删除文件事件)进行自定义代码植入。

2.1 内核do_unlinkat函数定义

开始之前,我们先来看下内核源码中这个函数的定义:

cd ~/project/linux
grep -w do_unlinkat -r ./
    ./fs/internal.h:int do_unlinkat(int dfd, struct filename *name);

可以看到,该函数接受2个参数:dfd(文件描述符)和name(文件名结构体指针),返回值为int,我们接下来编写ebpf程序时会用到这个定义。

2.2 ebpf探测函数

我们编写一个简单的kprobe.bpf.c程序,通过使使用kprobe(内核探针)在内核do_unlinkat函数的入口和退出位置添加钩子,实现对Linux 内核中的unlink 系统调用的入口参数和返回值的监测和捕获:

/**
 * 通过使用 kprobe(内核探针)在do_unlinkat函数的入口和退出处放置钩子,实现对该系统调用的跟踪
*/
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>

// 定义许可证,以允许程序在内核中运行
char LICENSE[] SEC("license") = "Dual BSD/GPL";

// 定义一个名为do_unlinkat的 kprobe,当进入do_unlinkat函数时,它会被触发
SEC("kprobe/do_unlinkat")
int BPF_KPROBE(do_unlinkat, int dfd, struct filename *name) // 捕获函数的参数:dfd(文件描述符)和name(文件名结构体指针)
{
    pid_t pid;
    const char *filename;

    // 获取当前进程的 PID(进程标识符)
    pid = bpf_get_current_pid_tgid() >> 32;
    // 读取文件名
    filename = BPF_CORE_READ(name, name);
    // 使用bpf_printk函数在内核日志中打印 PID 和文件名
    bpf_printk("KPROBE ENTRY pid = %d, filename = %s\n", pid, filename);
    return 0;
}

// 定义一个名为do_unlinkat_exit的 kretprobe,当从do_unlinkat函数退出时,它会被触发
SEC("kretprobe/do_unlinkat")
int BPF_KRETPROBE(do_unlinkat_exit, long ret) // 捕获函数的返回值(ret)
{
    pid_t pid;

    // 获取当前进程的 PID(进程标识符)
    pid = bpf_get_current_pid_tgid() >> 32;
    // 使用bpf_printk函数在内核日志中打印 PID 和返回值
    bpf_printk("KPROBE EXIT: pid = %d, ret = %ld\n", pid, ret);
    return 0;
}
2.3 加载程序

编写一个bpf用户空间程序,用于加载刚才编写的ebpf钩子到内核空间:

/**
 * ebpf 用户空间程序(loader、read ringbuffer)
*/
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <string.h>
#include <errno.h>
#include <sys/resource.h>
#include <bpf/libbpf.h>
#include "kprobe.skel.h"

static int libbpf_print_fn(enum libbpf_print_level level, const char *format, va_list args)
{
	return vfprintf(stderr, format, args);
}

static volatile sig_atomic_t stop;
static void sig_int(int signo)
{
	stop = 1;
}

int main(int argc, char **argv)
{
	struct kprobe_bpf *skel;
	int err;

	/* 设置libbpf错误和调试信息回调 */
	libbpf_set_print(libbpf_print_fn);

	/* 加载并验证 kprobe.bpf.c 应用程序 */
	skel = kprobe_bpf__open_and_load();
	if (!skel) {
		fprintf(stderr, "Failed to open BPF skeleton\n");
		return 1;
	}

	/* 附加 kprobe.bpf.c 程序到跟踪点 */
	err = kprobe_bpf__attach(skel);
	if (err) {
		fprintf(stderr, "Failed to attach BPF skeleton\n");
		goto cleanup;
	}

	/* Control-C 停止信号 */
	if (signal(SIGINT, sig_int) == SIG_ERR) {
		fprintf(stderr, "can't set signal handler: %s\n", strerror(errno));
		goto cleanup;
	}

	printf("Successfully started! Please run `sudo cat /sys/kernel/debug/tracing/trace_pipe` "
	       "to see output of the BPF programs.\n");

	while (!stop) {
		fprintf(stderr, ".");
		sleep(1);
	}

cleanup:
	/* 销毁挂载的ebpf程序 */
	kprobe_bpf__destroy(skel);
	return -err;
}
2.4 cmake编译配置

为kprobe.bpf.c、kprobe.c配置cmake编译:

cmake_minimum_required(VERSION 3.16)
project(ebpf_test)

# find BpfObject
list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/tools)

# build libbpf depend
include(ExternalProject)
ExternalProject_Add(libbpf
  PREFIX libbpf
  SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../libbpf/src
  CONFIGURE_COMMAND ""
  BUILD_COMMAND make
    BUILD_STATIC_ONLY=1
    OBJDIR=${CMAKE_CURRENT_BINARY_DIR}/libbpf/libbpf
    DESTDIR=${CMAKE_CURRENT_BINARY_DIR}/libbpf
    INCLUDEDIR=
    LIBDIR=
    UAPIDIR=
    install install_uapi_headers
  BUILD_IN_SOURCE TRUE
  INSTALL_COMMAND ""
  STEP_TARGETS build
)

ExternalProject_Add(bpftool
  PREFIX bpftool
  SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../bpftool/src
  CONFIGURE_COMMAND ""
  BUILD_COMMAND make bootstrap
    OUTPUT=${CMAKE_CURRENT_BINARY_DIR}/bpftool/
  BUILD_IN_SOURCE TRUE
  INSTALL_COMMAND ""
  STEP_TARGETS build
)

find_program(CARGO_EXISTS cargo)
if(CARGO_EXISTS)
  ExternalProject_Add(blazesym
    PREFIX blazesym
    SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../blazesym
    CONFIGURE_COMMAND ""
    BUILD_COMMAND cargo build --release
    BUILD_IN_SOURCE TRUE
    INSTALL_COMMAND ""
    STEP_TARGETS build
  )
endif()

# Set BpfObject input parameters
set(BPFOBJECT_BPFTOOL_EXE ${CMAKE_CURRENT_BINARY_DIR}/bpftool/bootstrap/bpftool)
set(BPFOBJECT_VMLINUX_H ${CMAKE_CURRENT_SOURCE_DIR}/../../vmlinux/${ARCH}/vmlinux.h)
set(LIBBPF_INCLUDE_DIRS ${CMAKE_CURRENT_BINARY_DIR}/libbpf)
set(LIBBPF_LIBRARIES ${CMAKE_CURRENT_BINARY_DIR}/libbpf/libbpf.a)
find_package(BpfObject REQUIRED)

# Create an executable for each application
file(GLOB apps *.bpf.c)
if(NOT CARGO_EXISTS)
  list(REMOVE_ITEM apps ${CMAKE_CURRENT_SOURCE_DIR}/profile.bpf.c)
endif()
foreach(app ${apps})
  get_filename_component(app_name ${app} NAME_WE)

  # Build object skeleton and depend skeleton on libbpf build
  bpf_object(${app_name} ${app_name}.bpf.c)
  add_dependencies(${app_name}_skel libbpf-build bpftool-build)

  add_executable(${app_name} ${app_name}.c)
  target_link_libraries(${app_name} ${app_name}_skel)
  if(${app_name} STREQUAL profile)
    target_include_directories(${app_name} PRIVATE
      ${CMAKE_CURRENT_SOURCE_DIR}/../../blazesym/include)
    target_link_libraries(${app_name}
      ${CMAKE_CURRENT_SOURCE_DIR}/../../blazesym/target/release/libblazesym.a -lpthread -lrt -ldl)
  endif()
endforeach()
2.5 编译&调试
# 编译
mkdir build && cd build
cmake ..
make

# 挂载自定义探针到内核
sudo ./kprobe

# 监控内核日志
sudo cat /sys/kernel/debug/tracing/trace_pipe

# 测试unlink删除事件
touch test1
rm test1
touch test2
rm test2

# 从监控的内核log中可以看到探针已经生效
sudo cat /sys/kernel/debug/tracing/trace_pipe
    rm-260674  [002] d...1 3556349.502552: bpf_trace_printk: KPROBE ENTRY pid = 260674, filename = test1
    rm-260674  [002] d...1 3556349.502574: bpf_trace_printk: KPROBE EXIT: pid = 260674, ret = 0
    rm-260676  [002] d...1 3556349.675106: bpf_trace_printk: KPROBE ENTRY pid = 260676, filename = test2
    rm-260676  [002] d...1 3556349.675144: bpf_trace_printk: KPROBE EXIT: pid = 260676, ret = 0

3. 使用 ring buffer 向用户态传递数据

在刚才的实例里,内核捕获到的数据打印到了内核log中,需要通过查看 /sys/kernel/debug/tracing/trace_pipe 文件来获取eBPF程序的输出。

在上一篇文章里我们学习了BPF Map,所以这里我们把它修改下,将捕获到的数据通过ring buffer从内核空间传递到用户空间。用户空间获取数据后,可以再进行后续的数据存储、处理和分析。

3.1 定义存储数据的结构体

这里我们定义一个kprobe.h头文件,方便内核空间和用户空间的程序使用同一个数据存储结构。

#ifndef __KPROBE_H
#define __KPROBE_H

#define MAX_FILENAME_LEN 256

struct event {
    int pid;
    char filename[MAX_FILENAME_LEN];
    bool exit_event;
    unsigned exit_code;
    unsigned long long ns;
};

#endif
3.2 内核ebpf存储数据到ring buffer

修改kprobe.bpf.c,引入数据结构头文件:

#include <string.h>
#include "kprobe.h"

定义一个名为rb的 ring buffer 类型的Map:

struct {
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 256 * 1024);    // 256 KB
} rb SEC(".maps");

在ebpf探针函数中保存数据到ring buffer:

SEC("kprobe/do_unlinkat")
int BPF_KPROBE(do_unlinkat, int dfd, struct filename *name)  // 该函数接受两个参数:dfd(文件描述符)和name(文件名结构体指针)
{
    ...
    struct event *e;

    ...
    // 预订一个ringbuf样本空间
    e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
    if (!e)
        return 0;
    // 设置数据
    e->pid = pid;
    bpf_probe_read_str(&e->filename, sizeof(e->filename), (void *)filename);
	e->exit_event = false;
    e->ns = bpf_ktime_get_ns();
    // 提交到ringbuf用户空间进行后处理
    bpf_ringbuf_submit(e, 0);

    return 0;
}
3.3 用户空间读取ring buffer

定义ring_buffer结构体、handle_event回调函数,使用ring_buffer__new(skel->maps.rb的fd, handle_event回调)初始化用户空间缓冲区对象,并使用ring_buffer__poll(rb) 获取内核ebpf程序传递的数据,收到的内核数据可以在handle_event回调函数中进行打印、存储、分析等后续处理。

/**
 * ebpf 用户空间程序(loader、read ringbuffer)
*/
...
#include <time.h>
#include "kprobe.h"

...

// ring buffer data process
static int handle_event(void *ctx, void *data, size_t data_sz)
{
	const struct event *e = data;
	struct tm *tm;
	char ts[32];
	time_t t;

	time(&t);
	tm = localtime(&t);
	strftime(ts, sizeof(ts), "%H:%M:%S", tm);

	if (e->exit_event) {
		printf("%-8s %-5s %-16s %-7d [%u]", ts, "EXIT", e->filename, e->pid, e->exit_code);
		if (e->ns)
			printf(" (%llums)", e->ns / 1000000);
		printf("\n");
	} else {
		printf("%-8s %-5s %-16s %-7d %s\n", ts, "EXEC", e->filename, e->pid, e->filename);
	}

	return 0;
}

int main(int argc, char **argv)
{
	...
	struct ring_buffer *rb = NULL;

	...
	printf("Successfully started! Please run `sudo cat /sys/kernel/debug/tracing/trace_pipe` "
	       "to see output of the BPF programs.\n");

	/* 设置环形缓冲区轮询 */
	rb = ring_buffer__new(bpf_map__fd(skel->maps.rb), handle_event, NULL, NULL);
	if (!rb) {
		err = -1;
		fprintf(stderr, "Failed to create ring buffer\n");
		goto cleanup;
	}

	/* 处理收到的内核数据 */
	printf("%-8s %-5s %-16s %-7s %s\n", "TIME", "EVENT", "FILENAME", "PID", "FILENAME/RET");
	while (!stop) {
		// 轮询内核数据
		err = ring_buffer__poll(rb, 100 /* timeout, ms */);
		if (err == -EINTR) {	/* Ctrl-C will cause -EINTR */
			err = 0;
			break;
		}
		if (err < 0) {
			printf("Error polling perf buffer: %d\n", err);
			break;
		}
	}
	// while (!stop) {
	// fprintf(stderr, ".");
	// sleep(1);
	// }
	...
}
3.4 编译&测试
# 编译
cmake ..
make

# 挂载自定义探针到内核
sudo ./kprobe

# 测试unlink删除事件
touch test.txt
rm test.txt


# 用户空间程序可以直接输出内核传递的数据
sudo ./kprobe
    TIME EVENT FILENAME PID FILENAME/RET
    23:31:03 EXEC test.txt 269717 test.txt
    23:31:03 EXIT 269717 [0] (3567267373ms)

可以看到内核采集到的数据已经顺利ring buffer传递到了用户空间并打印了出来,后续的存储、分析等逻辑自己单独写就行,就不在这里赘述了。

 

三、总结

今天我们学习了如何使用 eBPF 的 kprobe 和 kretprobe 捕获系统调用,并通过ring buffer将内核捕获到的数据传递到用户空间。下一次,我们将尝试如何捕获一个自定义程序中的函数或变量。如果你对其他的内容感兴趣,也可以告诉我~

 

yan 23.12.12

欢迎关注下方“非著名资深码农“公众号进行交流~

发表评论

邮箱地址不会被公开。