从kprobes到ftrace

kprobes简介

kprobes是Linux内核里跟踪内核函数最常见的方法之一。它的基本原理是在给定地址处首先进行原二进制代码的备份,然后加上一个int 3(x86架构)的单字节指令。这样,当内核执行到对应地址的代码时首先执行int 3软中断指令,内核捕获到这个软中断,发现这是一个kprobes的钩子,随后即通过预先准备好的跳床(trampoline)代码执行用户设定的kprobes预处理函数(pre_handler),继而单步执行备份的原二进制代码,然后执行用户设定的kprobes后处理函数(post_handler),最后跳转回相应的原代码处继续执行。

kprobes有两个变体,它们分别是jprobe与kretprobe。我们一般使用jprobe来通过内核函数的函数名在入口处跟踪内核函数,它底层是基于kprobes的,但是在注册kprobes时首先在内核符号表中查找到了对应内核函数的入口地址。在运行时产生了int 3后,内核首先会复制对应的调用栈与寄存器,以便执行jprobe处理函数,随后跳转到jprobe处理函数(break_handler)处,执行完处理函数之后再恢复栈,并调用原函数。而kretprobe则是在内核函数出口处被调用。它在函数入口处首先获得函数的返回地址,并提供了回调处理函数的接口可以用来获取当前的参数值(通过保存下来的寄存器组),继而修改返回地址为跳床地址。在函数返回时会跳转到跳床上执行对应的kretprobe返回处理函数,最后返回到正确的返回地址。

kprobes有多种实现,原始实现如上所述,即通过软中断指令、内核的中断处理函数以及跳床代码来实现。这种实现方式由于只需要修改一个字节,所以较为安全,但是中断处理与单步执行使得这种实现方式性能较低,而且需要考虑抢占、中断等诸多问题。此外,由于kprobes在实现时使用了perCPU数据,因此在执行时需要禁用抢占,以保证相应的perCPU数据不会被覆盖,从而使得在同一个处理器核上的kprobes旧实例访问到新实例的数据,导致内核崩溃。因此,内核要求用户提供的kprobes处理函数不能调用任何可能导致让出处理器核的函数,例如使用信号量、读写文件等,此要求使得kprobes开发受限较多,容易导致内核崩溃或者额外的性能损耗(因为只能使用自旋锁进行同步)。

随后,内核又提供了两种kprobes的实现方式,第一种是通过跳转指令替换软中断来实现,主要需要解决的问题是跳转的范围较小,而且跳转指令较长,可能会修改不止一个指令(主要是x86架构),会有额外的复杂性考虑。另外,跳转方式也不支持jprobe(需要模拟栈与函数执行)。第二种则是通过ftrace来支持对内核函数的kprobes,而kprobes最常用的也就是对内核函数的跟踪,直接对任意地址的跟踪其实是比较少见的。实际上,由于jprobe与ftrace功能的重叠以及jprobe本身诸多的限制,在内核4.15中已经禁用了jprobe,在内核4.19中已经完全删除了jprobe的功能。

相对于kprobes来说,ftrace不仅性能更好,而且不用考虑不能让出处理器核的问题,因此我们推荐使用ftrace而不是kprobes来对内核函数进行跟踪。而且,使用ftrace还有一个额外的好处,那就是很容易实现对内核函数的替换,虽说kprobes也可以实现内核函数的替换,但操作起来比ftrace就麻烦多了。实际上,内核热补丁livepatch也是基于ftrace开发的。

基于ftrace的内核函数跟踪

ftrace的实现依赖于gcc提供的mcount特性,此特性是在每个函数的入口处加入对自定义的mcount函数的调用,如果没有调用的话这部分代码会被填充为nop(或者等效指令,如mov %eax, %eax),从而保证相应的性能损耗极低。当用户针对某个函数设置了回调函数时,对应的nop指令就会被替换为函数调用指令。在此处需要注意的是内核函数起始处mcount占位代码指令的长度是与架构相关的,在内核中使用MCOUNT_INSN_SIZE表示这个指令的长度,在x86下MCOUNT_INSN_SIZE为5。

多说无益,放码出来。我们先来看看如何使用ftrace对do_filp_open内核函数进行跟踪,在打开每个文件的时候,内核都会调用到do_filp_open函数,因此此函数是一个会被频繁调用的内核函数。下面是源代码(省略了头文件与许可证声明等):

static unsigned long get_arg(struct pt_regs* regs, int n)
{
    switch (n) {
#if    defined(CONFIG_X86_64)

        case 1: return regs->di;
        case 2: return regs->si;
        case 3: return regs->dx;
        case 4: return regs->cx;
        case 5: return regs->r8;
        case 6: return regs->r9;

#elif defined(CONFIG_CPU_LOONGSON3)

        case 1:  // a0
        case 2:  // a1
        case 3:  // a2
        case 4:  // a3
            return *(unsigned long*)((char *)regs + (3+n)*8);

#endif // CONFIG_X86_64
        default:
            return 0;
    }
    return 0;
}

static void notrace my_ftrace_func(unsigned long ip, unsigned long parent_ip, struct ftrace_ops *ops, struct pt_regs *regs)
{
    struct filename* fn = (struct filename*)get_arg(regs, 2);
    pr_info("%pf called from %pf: %s\n", (void *)ip, (void *)parent_ip, fn->name);
}

static struct ftrace_ops fops = {
    .func = my_ftrace_func,
    .flags = FTRACE_OPS_FL_SAVE_REGS,
};

int __init init_module()
{
    char* fname = "do_filp_open";
    int ret = ftrace_set_filter(&fops, fname, strlen(fname), 0);
    if (ret) {
        pr_err("ftrace-set-filter error: %d\n", ret);
        return ret;
    }

    ret = register_ftrace_function(&fops);
    if (ret) {
        pr_err("reg-ftrace-func error: %d\n", ret);
        return ret;
    }

    pr_info("reg-ftrace-peek done\n");
    return 0;
}

void __exit cleanup_module()
{
    unregister_ftrace_function(&fops);
    pr_info("unreg-ftrace-peek done\n");
}

可以看到,在使用ftrace跟踪内核函数时,关键的数据结构是struct ftrace_ops。开发者需要在此结构体中设置自定义跟踪函数指针,以及对应的ftrace参数,例如在这里设置的就是FTRACE_OPS_FL_SAVE_REGS,表示需要在调用跟踪函数时将调用参数保存下来传递过去。在注册跟踪函数时需要首先调用ftrace_set_filter传入对应的跟踪函数名与上述的结构体指针,继而调用register_ftrace_function注册跟踪函数。

在跟踪函数中则可以根据传入的寄存器组得到参数值(与架构相关),然后还可以获得当前的代码地址(ip),调用者的地址(parent_ip)等。而do_filp_open内核函数的参数为int dfd, struct filename *pathname, const struct open_flags *op,第二个参数为struct filename*的类型,因此可以通过架构相关的get_arg函数得到第二个参数,并打印出文件名来。

在不需要跟踪时,可以调用unregister_ftrace_function注销注册函数即可。

在上述的跟踪函数my_ftrace_func中,可以调用其它内核,包括可能引起让出处理器的内核函数,例如使用信号量的内核函数,睡眠的内核函数等,而不用担心导致内核崩溃。

除了以上述方式对单个内核函数进行跟踪以外,ftrace还支持以通配符方式对多个内核函数同时进行跟踪,如下。

static void notrace my_ftrace_func(unsigned long ip, unsigned long parent_ip, struct ftrace_ops *ops, struct pt_regs *regs)
{
    char self[128] = {}, parent[128] = {};
    sprint_symbol_no_offset(parent, parent_ip);
    sprint_symbol_no_offset(self, ip);
    pr_info("%s called from %s\n", self, parent);
}

static struct ftrace_ops fops = {
    .func = my_ftrace_func,
    .flags = FTRACE_OPS_FL_SAVE_REGS,
};

int __init init_module()
{
    char* fname = "*_permission";
    int ret = ftrace_set_filter(&fops, fname, strlen(fname), 0);
    if (ret) {
        pr_err("ftrace-set-filter error: %d\n", ret);
        return ret;
    }

    ret = register_ftrace_function(&fops);
    if (ret) {
        pr_err("reg-ftrace-func error: %d\n", ret);
        return ret;
    }
    return 0;
}

void __exit cleanup_module()
{
    unregister_ftrace_function(&fops);
}

在上面的代码中,主要关注变化的代码即可。在ftrace_set_filter函数中跟踪函数的名称从do_filp_open变化成为*_permission,表示对所有以_permission结尾的内核函数进行跟踪。此外,在跟踪函数my_ftrace_func中,因为需要跟踪多个内核函数,每个内核函数的参数不一样,因此就不能调用get_arg函数获取函数参数了,此时可以将ipparent_ip定位到对应的函数名称,并将其打印出来,进行跟踪查看。

使用ftrace对内核函数的跟踪基本实现如上所述,下面我们来看看如何使用ftrace修改内核函数的行为。

基于ftrace修改内核函数

使用ftrace修改内核函数的过程是比较简单的:

  1. 在挂钩函数中跳转到自定义函数,如果只需要挂钩一个内核函数,自定义函数可以与挂钩函数合并,如果需要提供通用的挂钩处理,则可以通过挂钩函数的struct ftrace_ops*参数获得自定义函数的地址,然后设置IP寄存器的值进行直接跳转,以方便直接使用调用栈与寄存器组以传递函数参数。在这里使用跳转也可以避免与具体的函数耦合,可以使用一个挂钩函数处理多个自定义函数;
  2. 在自定义函数中,获得原内核函数的地址,并加上MCOUNT_INSN_SIZE,以获得真正的内核函数实现入口,不然会导致无限循环;
  3. 根据参数判断是否需要调用原内核函数或者直接返回失败(例如-EPERM);
  4. 调用原内核函数;
  5. 获得原内核函数的返回值,并进行进一步处理;
  6. 从自定义函数中返回,由于挂钩函数是通过跳转进入自定义函数的,因此也同时从挂钩函数返回;

若我们要修改内核中的vfs_create函数(其函数签名为struct inode*, struct dentry*,umode_t, bool),如果路径中包含123456则返回失败值EPERM,下面是相应的实现。

struct ftrace_hook {
    unsigned long orig_func;
    unsigned long my_func;
    struct ftrace_ops fops;
} fhook;

static int my_vfs_create(struct inode *dir, struct dentry *dentry, umode_t mode, bool want_excl)
{
    static int (*orig_vfs_create)(struct inode *dir, struct dentry *dentry, umode_t mode, bool want_excl) = 0;
    if (orig_vfs_create == 0)
        *((unsigned long *)&orig_vfs_create) = fhook.orig_func + MCOUNT_INSN_SIZE;
    char buf[256];
    char *path = dentry_path_raw(dentry, buf, sizeof(buf));
    if (IS_ERR(path)) {
        pr_info("vfs-create called\n");
    } else if (strstr(path, "123456")) {
        pr_info("vfs-create: %s REJECTED\n", path);
        return -EPERM;
    } else {
        pr_info("vfs-create: %s\n", path);
    }

    int ret = orig_vfs_create(dir, dentry, mode, want_excl);
    if (IS_ERR(path))
        pr_info("vfs-create ret: %d\n", ret);
    else
        pr_info("vfs-create: %s, ret: %d\n", path, ret);
    return ret;
}

static void notrace my_ftrace_func(unsigned long ip, unsigned long parent_ip, struct ftrace_ops *ops, struct pt_regs *regs)
{
    struct ftrace_hook *hook = container_of(ops, struct ftrace_hook, fops);
    regs->ip = (unsigned long)hook->my_func;
}

int __init init_module()
{
    fhook.orig_func = kallsyms_lookup_name("vfs_create");
    if (fhook.orig_func == 0) {
        pr_err("kallsyms-lookup-name failed\n");
        return -1;
    }
    fhook.my_func = (unsigned long)&my_vfs_create;
    fhook.fops.func = my_ftrace_func;
    fhook.fops.flags = FTRACE_OPS_FL_SAVE_REGS | FTRACE_OPS_FL_IPMODIFY | FTRACE_OPS_FL_RECURSION_SAFE;

    int ret = ftrace_set_filter_ip(&fhook.fops, fhook.orig_func, 0, 0);
    if (ret) {
        pr_err("ftrace-set-filter-ip error: %d\n", ret);
        return ret;
    }

    ret = register_ftrace_function(&fhook.fops);
    if (ret) {
        pr_err("reg-ftrace-func error: %d\n", ret);
        ftrace_set_filter_ip(&fhook.fops, fhook.orig_func, 1, 0);
        return ret;
    }
    return 0;
}

void __exit cleanup_module()
{
    int ret = unregister_ftrace_function(&fhook.fops);
    if (ret) {
        pr_err("unreg-ftrace-func error: %d\n", ret);
        return;
    }
    ret = ftrace_set_filter_ip(&fhook.fops, fhook.orig_func, 1, 0);
    if (ret) {
        pr_err("ftrace-set-filter-ip error: %d\n", ret);
        return;
    }
}

上面的代码主要分为三个部分。

首先是注册。在注册ftrace函数时,在内核模块初始化函数init_module中首先调用kallsyms_lookup_name获得内核函数vfs_create的地址,然后调用ftrace_set_filter_ip直接通过地址进行内核函数的ftrace跟踪,在这里,需要额外加入FTRACE_OPS_FL_IP_MODIFY参数,以使得挂钩函数能够修改IP寄存器以实现跳转执行,同时原vfs_create内核函数的地址(orig_func)与自定义函数的地址(my_func)也被保存在了结构体中,以便挂钩函数使用。最后调用ftrace_set_filter_ipregister_ftrace_function进行真正的注册。

其次是运行。在内核调用vfs_create内核函数时,对应的挂钩函数my_ftrace_func会首先被调用,它从调用参数struct ftrace_ops*中通过container_of宏获得自定义函数的地址,并通过设置IP寄存器的值直接进行跳转执行。在自定义函数my_vfs_create中,它首先获取了原内核函数的地址,并将其加上了MCOUNT_INSN_SIZE,接下来my_vfs_create检查参数,如果路径名中含有123456,则直接返回-EPERM,而并不调用真正的vfs_create内核函数,不然则调用真正的vfs_create内核函数(跳过了开始的MCOUNT_INSN_SIZE个字节),并将返回值返回。

最后是注销。在不需要ftrace时,内核模块注销函数cleanup_module会首先调用unregister_ftrace_function注销ftrace,接着调用ftrace_set_function_ip撤销对相应代码地址的修改即可。

通过上述代码,我们就已经可以通过ftrace对内核函数进行在线替换了。

参考资料

kprobes与ftrace都是Linux内核提供的特性,而且在已经进入内核较长时间了。在写作本文的过程中,参考了不少源码与资料,源码当然是直接看内核了。其它主要的参考资料如下:

1 条思考于 “从kprobes到ftrace

发表评论

电子邮件地址不会被公开。 必填项已用*标注