eBPF—使用uprobe探测用户程序函数调用

上一次,我们通过kprobe实现一个简单的内核系统调用捕获,了解了如何在内核函数的出入口动态注入自定义代码,并通过ring buffer传递到用户空间。今天,我开始带大家通过uprobe实现一个对用户空间自定义程序的特定函数出入口进行代码注入和探测的方法。

一、概述

1. 什么是uprobe?

uprobe是一种用户空间探针,uprobe探针允许在用户空间程序中动态插桩,插桩位置包括:函数入口、特定偏移处,以及函数返回处。当我们定义uprobe时,内核会在附加的指令上创建快速断点指令(x86机器上为int3指令),当程序执行到该指令时,内核将触发事件,程序陷入到内核态,并以回调函数的方式调用探针函数,执行完探针函数再返回到用户态继续执行后序的指令。

uprobe基于文件,当一个二进制文件中的一个函数被跟踪时,所有使用到这个文件的进程都会被插桩,包括那些尚未启动的进程,这样就可以在全系统范围内跟踪系统调用。

uprobe适用于在用户态去解析一些内核态探针无法解析的流量,例如http2流量(报文header被编码,内核无法解码)、https流量(加密流量,内核无法解密)等。

2. libbpf uprobe相关函数

2.1 bpf_program__attach_uprobe()

bpf_program__attach_uprobe()可以将BPF程序附加到通过二进制路径和偏移量找到的用户空间函数中。可以选择指定要附加的特定进程,还可以选择将程序附加到函数出口或入口。

函数定义:

struct bpf_link *bpf_program__attach_uprobe(
    const struct bpf_program *prog,
    bool retprobe,
    pid_t pid,
    const char *binary_path,
    size_t func_offset)

参数:

  • prog:要附加的 BPF 程序
  • retprobe:附加到函数出口
  • pid:附加 uprobe 的进程 ID(0 表示自身进程,-1 表示所有进程)
  • binary_path:包含函数符号的二进制文件的路径
  • func_offset :函数符号二进制内的偏移量

返回值:

  • 新创建的BPF链接引用;或出错时返回 NULL,错误代码存储在 errno 中
2.2 bpf_program__attach_uprobe_opts()

bpf_program__attach_uprobe_opts() 与 bpf_program__attach_uprobe() 类似,只是具有用于各种配置的选项结构。

函数定义:

struct bpf_link *bpf_program__attach_uprobe_opts(
    const struct bpf_program *prog, 
    pid_t pid,
    const char *binary_path, 
    size_t func_offset,
    const struct bpf_uprobe_opts *opts)

参数:

  • prog – 要附加的 BPF 程序
  • pid – 附加 uprobe 的进程 ID,0 表示自身(自己的进程),-1 表示所有进程
  • binary_path – 包含函数符号的二进制文件的路径
  • func_offset – 函数符号二进制内的偏移量
  • opts – 更改程序附件的选项(retprobe也被封装到了这里)

返回值:

  • 新创建的BPF链接引用;或出错时返回 NULL,错误代码存储在 errno 中
2.3 bpf_program__attach_usdt()

bpf_program__attach_usdt() 与 bpf_program__attach_uprobe_opts() 类似,只不过它覆盖了 USDT(用户空间静态定义跟踪点)附件,而不是附加到用户空间函数入口或出口。

函数定义:

struct bpf_link *bpf_program__attach_usdt(
    const struct bpf_program *prog,
    pid_t pid, 
    const char *binary_path,
    const char *usdt_provider,
    const char *usdt_name,
    const struct bpf_usdt_opts *opts)

参数:

  • prog – 要附加的 BPF 程序
  • pid – 附加 uprobe 的进程 ID,0 表示自身(自己的进程),-1 表示所有进程
  • binary_path – 包含提供的 USDT 探针的二进制文件的路径
  • usdt_provider – USDT 提供商名称
  • usdt_name – USDT 探针名称
  • opts – 更改程序附件的选项

返回值:

  • 新创建的BPF链接引用;或出错时返回 NULL,错误代码存储在 errno 中

 

二、实践

为了方便对用户空间特定程序的工作状态探测,我们将编写一个简单的用户空间程序,并使用uprobe对其内部的函数工作状态进行探测。

1. 准备用户空间自定义程序

1.1 自定义程序

编写一个简单的utest.cc程序,里边包含2个函数:utest_add、utest_sub,启动后无限循环调用这两个函数,作为被探针注入的用户空间自定义程序。

/**
 * 用户空间自定义程序
*/
#include <iostream>
#include <stdio.h>
#include <unistd.h>

int utest_add(int a, int b){
    return a + b;
}

int utest_sub(int a, int b){
    return a - b;
}

int main(int argc, char **argv){
    int err, i;

    for (i = 0;; i++) {
        utest_add(i, i + 1);
        utest_sub(i * i, i);
        
        std::cout << "i = " << i << std::endl;

        sleep(1);
        }
}
1.2 编译

配置一个简单的cmake exec编译文件:

cmake_minimum_required(VERSION 3.16)
project(utest)

# utest exec
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Werror -ggdb -rdynamic")  # 带符号
add_executable(utest utest.cc)
1.3 查看函数符号

编译:

mkdir build && cd build
cmake ..
make

确定编译好的函数符号:

objdump -T utest |grep utest_add
        00000000000011e9 g DF .text 0000000000000018  Base _Z9utest_addii
objdump -T utest |grep utest_sub
        0000000000001201 g DF .text 0000000000000016 Base _Z9utest_subii

启动测试程序(等待uprobe探针注入):

./utest 
    i = 0
    i = 1
    ...

2. uprobe ebpf程序实例

2.1 ebpf探测函数

我们编写一个uprobe.bpf.c程序,通过 SEC 宏来定义 uprobe 探针,并使用 BPF_KRETPROBE 宏来定义探针函数。

在 SEC 宏中,我们需要指定 uprobe 的类型,也可以在SEC宏中直接定义号要捕获的二进制文件的路径和要捕获的函数名称。

  • 如已经指定二进制路径和函数名称,可直接使用xxx_bpf__attach(skel)附加 xxx.bpf.c 程序到跟踪点;
  • 如未在SEC中指定二进制路径和函数名称,则需要使用bpf_program__attach_uprobe进行指定参数附加)。
/**
 * 通过使用 uprobe(用户空间探针)在用户空间程序函数的入口和退出处放置钩子,实现对该用户态函数调用的跟踪
*/
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

char LICENSE[] SEC("license") = "Dual BSD/GPL";

// 通过 SEC 宏来定义 uprobe 探针(未在SEC中指定二进制路径和函数名称,则需要使用bpf_program__attach_uprobe进行指定参数附加)
SEC("uprobe")
int BPF_KPROBE(utest_add, int a, int b)
{
    bpf_printk("utest_add ENTRY: a = %d, b = %d", a, b);
    return 0;
}

SEC("uretprobe")
int BPF_KRETPROBE(urettest_add, int ret)
{
    bpf_printk("utest_add EXIT: return = %d", ret);
    return 0;
}

// 在 SEC 宏中指定要捕获的 二进制文件的路径 和 要捕获的函数名称(已经指定二进制路径和函数名称,可直接使用xxx_bpf__attach(skel)附加 xxx.bpf.c 程序到跟踪点)
SEC("uprobe//home/work/project/libbpf-demo/examples/test/utest/build/utest:_Z9utest_subii")
int BPF_KPROBE(utest_sub, int a, int b)
{
    bpf_printk("utest_sub ENTRY: a = %d, b = %d", a, b);
    return 0;
}

SEC("uretprobe//home/work/project/libbpf-demo/examples/test/utest/build/utest:_Z9utest_subii")
int BPF_KRETPROBE(urettest_sub, int ret)
{
    bpf_printk("utest_sub EXIT: return = %d", ret);
    return 0;
}
2.2 用户空间程序

编写一个uprobe.c程序,使用bpf_program__attach_uprobe_opts()或uprobe_bpf__attach()加载探针程序。

/**
 * ebpf 用户空间程序(loader)
*/
#include <errno.h>
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <sys/resource.h>
#include <bpf/libbpf.h>
#include "uprobe.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 uprobe_bpf *skel;
    int err, i;
    LIBBPF_OPTS(bpf_uprobe_opts, uprobe_opts);

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

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

    /* 附加跟踪点处理 */
    uprobe_opts.func_name = "_Z9utest_addii";
    uprobe_opts.retprobe = false;
    /* uprobe 期望要附加的函数的相对偏移量。
     *   如果我们提供函数名称,libbpf 会自动为我们找到偏移量。
     *   如果未指定函数名称,libbpf 将尝试使用函数偏移量代替。
     */
    skel->links.utest_add = bpf_program__attach_uprobe_opts(
        skel->progs.utest_add,
        -1,    // uprobe 的进程 ID,0 表示自身(自己的进程),-1 表示所有进程
        "/home/work/project/libbpf-demo/examples/test/utest/build/utest",
        0,    // offset for function
        &uprobe_opts);
    if (!skel->links.utest_add) {
        err = -errno;
        fprintf(stderr, "Failed to attach uprobe: %d\n", err);
        goto cleanup;
    }

    /* 将 uretprobe 附加到使用相同二进制可执行文件的任何现有或未来进程 */
    uprobe_opts.func_name = "_Z9utest_addii";
    uprobe_opts.retprobe = true;
    skel->links.urettest_add = bpf_program__attach_uprobe_opts(
        skel->progs.urettest_add, 
        -1,    // uprobe 的进程 ID,0 表示自身(自己的进程),-1 表示所有进程
        "/home/work/project/libbpf-demo/examples/test/utest/build/utest",
        0,    // offset for function
        &uprobe_opts);
    if (!skel->links.urettest_add) {
        err = -errno;
        fprintf(stderr, "Failed to attach uprobe: %d\n", err);
        goto cleanup;
    }

    /* 让libbpf为 utest_sub/urettest_sub 执行自动附加
     *     注意:此方式需要在uprobe.bpf.c的SEC中提供路径和符号信息
     */
    err = uprobe_bpf__attach(skel);
    if (err) {
        fprintf(stderr, "Failed to auto-attach BPF skeleton: %d\n", err);
        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程序 */
    uprobe_bpf__destroy(skel);
    return -err;
}

这里要注意的是,使用bpf_program__attach_uprobe加载探针时,func_name必须为objdump中的函数符号,而不仅仅是函数名。

2.3 编译&调试
# 编译
mkdir build && cd build
cmake ..
make

# 启动
sudo ./uprobe 

# 监听内核log
sudo cat /sys/kernel/debug/tracing/trace_pipe
    utest-289573  [001] d...1 3661688.515533: bpf_trace_printk: utest_add ENTRY: a = 0, b = 1
    utest-289573  [001] d...1 3661688.515542: bpf_trace_printk: utest_add EXIT: return = 1
    utest-289573  [001] d...1 3661688.515543: bpf_trace_printk: utest_sub ENTRY: a = 0, b = 0
    utest-289573  [001] d...1 3661688.515546: bpf_trace_printk: utest_sub EXIT: return = 0
    
    utest-289573  [001] d...1 3661689.515725: bpf_trace_printk: utest_add ENTRY: a = 1, b = 2
    utest-289573  [001] d...1 3661689.515732: bpf_trace_printk: utest_add EXIT: return = 3
    utest-289573  [001] d...1 3661689.515734: bpf_trace_printk: utest_sub ENTRY: a = 1, b = 1
    utest-289573  [001] d...1 3661689.515735: bpf_trace_printk: utest_sub EXIT: return = 0
    
    utest-289573  [001] d...1 3661690.516228: bpf_trace_printk: utest_add ENTRY: a = 2, b = 3
    utest-289573  [001] d...1 3661690.516236: bpf_trace_printk: utest_add EXIT: return = 5
    utest-289573  [001] d...1 3661690.516238: bpf_trace_printk: utest_sub ENTRY: a = 4, b = 2
    utest-289573  [001] d...1 3661690.516240: bpf_trace_printk: utest_sub EXIT: return = 2
    
    utest-289573  [001] d...1 3661691.516409: bpf_trace_printk: utest_add ENTRY: a = 3, b = 4
    utest-289573  [001] d...1 3661691.516415: bpf_trace_printk: utest_add EXIT: return = 7
    utest-289573  [001] d...1 3661691.516417: bpf_trace_printk: utest_sub ENTRY: a = 9, b = 3
    utest-289573  [001] d...1 3661691.516419: bpf_trace_printk: utest_sub EXIT: return = 6

可以看到已经可以探测到自定义程序的函数调用参数和返回值。

 

三、总结

好了,今天学习了如何通过uprobe 对用户空间的程序中的特定函数出入口 进行探针代码注入和参数返回值探测。下一次,我们将尝试对程序或函数的cpu资源占用情况进行跟踪和抓取。如果你对其他的内容感兴趣,也可以告诉我~

 

yan 23.12.13

 

参考:

LIBBPF API

在 eBPF 中使用 uprobe 捕获 bash 的 readline 函数调用

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

发表评论

邮箱地址不会被公开。