本文是《HookingLinuxKernelFunctions,Part2:HowtoHookFunctionswithFtrace》的翻译文章

序言

Ftrace是一个用于跟踪Linux内核函数的Linux内核框架。并且,当我们尝试启用系统活动监控以制止可疑进程时,我们的团队设法找到了一种使用ftrace的新方式。事实证明,ftrace容许你从可加载的GPL模块安装钩子而无需重建内核。此方式适用于x86_64体系结构的Linux内核版本3.19和更高版本。

这是我们关于HookingLinux内核函数调用的三部份系列的第二部份。在本文中,我们将解释怎样使用ftrace来hookLinux内核中的关键函数调用。你可以阅读本系列的第一部份,以了解有关可用于完成此任务的其他方式的更多信息。

一种新方式:使用ftrace进行Linux内核hooking

哪些是ftrace?基本上,ftrace是一个用于在函数级别跟踪内核的框架。该框架自2008年以来仍然在开发中,具有相当令人印象深刻的函数集。使用ftrace跟踪内核函数时,一般可以获得什么数据?Linuxftrace显示调用图,跟踪函数调用的频度和厚度,按模板过滤特定函数等。在本文的下边,可以找到对官方文档和资源的引用,你可以使用它们来了解有关ftrace函数的更多信息。

ftrace的实现基于编译器选项-pg和-mfentry。这种内核选项在每位函数的开头插入一个特殊跟踪函数的调用——mcount()或fentry()。在用户程序中,剖析器使用此编译器功能来跟踪所有函数的调用。并且,在内核中,这种函数用于实现ftrace框架。

其实,从每位函数调用ftrace都是十分高昂的。这就是为何有一种针对流行构架的优化——动态ftrace。若果没有使用ftrace,它几乎不会影响系统,由于内核晓得调用mcount()或fentry()的位置,并在初期阶段将机器码替换为nop(一个不执行任何操作的特定指令)。当Linux内核跟踪打开时,ftrace调用会被添加到必要的函数中。

必要函数说明

下边的结构可以拿来描述每位钩子函数:

/**
 * struct ftrace_hook describes the hooked function
 *
 * @name: the name of the hooked function
 *
 * @function: the address of the wrapper function that will be called instead
 * of the hooked function
 *
 * @original: a pointer to the place where the address 
 * of the hooked function should be stored, filled out during installation
 * of the hook
 *
 * @address: the address of the hooked function, filled out during installation 
 * of the hook
 *
 * @ops: ftrace service information, initialized by zeros;
 * initialization is finished during installation of the hook
 */
struct ftrace_hook {
        const char *name;
        void *function;
        void *original;
        unsigned long address;
        struct ftrace_ops ops;
};

linux内核函数和系统调用_linux内核调试方法总结_linux 调用内核函数

用户只须要填写三个数组:name、function和original。其余数组被觉得是实现细节。你可以把所有Hook函数的描述放到一起,并使用宏使代码更紧凑:

#define HOOK(_name, _function, _original) 
        { 
            .name = (_name), 
            .function = (_function), 
            .original = (_original), 
        }
static struct ftrace_hook hooked_functions[] = {
        HOOK("sys_clone", fh_sys_clone, &real_sys_clone),
        HOOK("sys_execve", fh_sys_execve, &real_sys_execve),
};

下边是钩子函数包装的结构:

/*
 * It’s a pointer to the original system call handler execve().
 * It can be called from the wrapper. It’s extremely important to keep the function signature
 * without any changes: the order, types of arguments, returned value,
 * and ABI specifier (pay attention to “asmlinkage”).
 */
static asmlinkage long (*real_sys_execve)(const char __user *filename,
                const char __user *const __user *argv,
                const char __user *const __user *envp);
/*
 * This function will be called instead of the hooked one. Its arguments are
 * the arguments of the original function. Its return value will be passed on to 
 * the calling function. This function can execute arbitrary code before, after,
 * or instead of the original function.
 */
static asmlinkage long fh_sys_execve (const char __user *filename,
                const char __user *const __user *argv,
                const char __user *const __user *envp)
{
        long ret;
        pr_debug("execve() called: filename=%p argv=%p envp=%pn",
                filename, argv, envp);
        ret = real_sys_execve(filename, argv, envp);
        pr_debug("execve() returns: %ldn", ret);
        return ret;
}

如今,钩子函数有最少的额外代码。惟一须要非常注意的是函数签名。它们必须完全相同;否则,参数都会被错误地传递,一切就会出错。不过,对于hooking系统调用来说,这并不重要,由于它们的处理程序十分稳定,但是出于性能缘由,系统调用ABI和函数调用ABI在寄存器中使用相同的参数布局。并且,假如要hook其他函数,请记住内核没有稳定的插口。

初始化ftrace

我们的第一步是查找和保存钩子函数地址。你可能晓得,在使用ftrace时,Linux内核跟踪可以通过函数名执行。并且,我们依然须要晓得原始函数的地址能够调用它。

您可以使用kallsyms(所有内核符号的列表)来获取所需函数的地址。此列表除了包括为模块导入的符号,实际上还包括所有的符号。获取钩子函数地址的过程如下所示:

static int resolve_hook_address (struct ftrace_hook *hook)
        hook->address = kallsyms_lookup_name(hook->name);
        if (!hook->address) {
                pr_debug("unresolved symbol: %sn", hook->name);
                return -ENOENT;
        }
        *((unsigned long*) hook->original) = hook->address;
        return 0;
}

接出来,我们须要初始化ftrace_ops结构。这儿我们有一个必要的数组func,指向反弹。并且,须要一些关键flags:

int fh_install_hook (struct ftrace_hook *hook)
        int err;
        err = resolve_hook_address(hook);
        if (err)
                return err;
        hook->ops.func = fh_ftrace_thunk;
        hook->ops.flags = FTRACE_OPS_FL_SAVE_REGS
                        | FTRACE_OPS_FL_IPMODIFY;
        /* ... */
}

linux 调用内核函数_linux内核调试方法总结_linux内核函数和系统调用

fh_ftrace_thunk()特点是ftrace在跟踪函数时调用的反弹函数。我们稍后将讨论这个反弹。hooking须要这种flags——它们命令ftrace保存和恢复处理器寄存器,我们可以在反弹中修改这种寄存器的内容。

如今我们打算好开始hook了。首先,我们使用ftrace_set_filter_ip()为所需的函数打开ftrace实用程序。其次,我们使用register_ftrace_function()给ftrace权限来调用我们的反弹:

int fh_install_hook (struct ftrace_hook *hook)
{
        /* ... */
        err = ftrace_set_filter_ip(&hook->ops, hook->address, 0, 0);
        if (err) {
                pr_debug("ftrace_set_filter_ip() failed: %dn", err);
                return err;
        }
        err = register_ftrace_function(&hook->ops);
        if (err) {
                pr_debug("register_ftrace_function() failed: %dn", err);
                /* Don’t forget to turn off ftrace in case of an error. */
                ftrace_set_filter_ip(&hook->ops, hook->address, 1, 0); 
                return err;
        }
        return 0;
}

要关掉钩子,我们只需反向重复相同的操作:

void fh_remove_hook (struct ftrace_hook *hook)
{
        int err;
        err = unregister_ftrace_function(&hook->ops);
        if (err)
                pr_debug("unregister_ftrace_function() failed: %dn", err);
        }
        err = ftrace_set_filter_ip(&hook->ops, hook->address, 1, 0);
        if (err) {
                pr_debug("ftrace_set_filter_ip() failed: %dn", err);
        }
}

当unregister_ftrace_function()调用结束时,可以保证系统中不会激活已安装的反弹函数或包装器。我们可以卸载hook模块,而不用害怕我们的函数一直在系统的某个地方执行。接出来,我们提供了函数hook过程的详尽描述。

用ftracehook函数

这么怎样配置内核函数hook呢?这个过程十分简单:ftrace才能在退出反弹后修改注册状态。通过改变寄存器%rip——一个指向下一个执行指令的表针——我们可以改变处理器执行的函数。换句话说,我们可以逼迫处理器无条件地从当前函数跳到我们的函数并接管控制权。

这是ftrace反弹的样子:

static void notrace fh_ftrace_thunk(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, ops);
        regs->ip = (unsigned long) hook->function;
}

我们使用宏container_of()和structftrace_hook中嵌入的structftrace_ops的地址为我们的函数获取structftrace_hook的地址。接出来,我们使用处理程序的地址替换structpt_regs结构中的寄存器%rip的值。对于x86_64以外的体系结构,此寄存器可以具有不同的名称(如PC或IP)。但基本思想一直适用。

linux内核调试方法总结_linux 调用内核函数_linux内核函数和系统调用

请注意,为反弹添加的notrace说明符须要非常注意。此说明符可用于标记Linux内核跟踪中严禁使用ftrace的函数。比如,你可以标记跟踪过程中使用的ftrace函数。通过使用这个说明符,倘若不留神从ftrace反弹中调用了一个函数,系统就不会挂起,由于ftrace正在跟踪这个函数。

ftrace反弹常常使用禁用占领来调用(如同kprobes一样),虽然可能有一些例外。并且在我们的事例中,这个限制并不重要,由于我们只须要替换pt_regs结构中%rip值的8个字节。

因为包装函数和原始函数在相同的上下文中执行,因而两个函数具有相同的限制。诸如,假如你hook一个中断处理程序,这么在包装函数中休眠依然是不可能的。

避免递归调用

在我们之前给出的代码中有一个问题:当包装函数调用原始函数时,原始函数将被ftrace再度跟踪,因而造成无穷无尽的递归。通过使用parent_ip——ftrace反弹参数之一,我们想出了一种十分巧妙的方式来打破这个循环——它包含了调用钩子函数的返回地址。一般,这个参数用于建立函数调用图。并且,我们可以使用这个参数来分辨第一个跟踪函数调用和重复调用。

差别十分明显:在第一次调用期间,参数parent_ip将指向内核中的某个位置,而在重复调用期间,它只指向包装函数内部。你应当只在第一个函数调用期间传递控制。所有其他调用都必须执行原始函数。

我们可以通过将地址与当前模块的边界与我们的函数进行比较来运行入口测试。并且,只有当模块不包含调用钩子函数的包装函数以外的任何内容时,此方式才有效。否则,你须要更挑剔。

这是一个正确ftrace反弹的样子:

static void notrace fh_ftrace_thunk (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, ops);
        /* Skip the function calls from the current module. */
        if (!within_module(parent_ip, THIS_MODULE))
                regs->ip = (unsigned long) hook->function;
}

这些技巧有三个主要优点:

hooking程序的方案

这么,ftrace是怎样工作的呢?让我们来看一个简单的示例:你在终端中键入了命令linux操作系统怎么样,以查看当前目录中的文件列表。命令行类库(例如Bash)使用标准C库中的常用函数fork()和execve()来启动一个新进程。在系统内部,这种函数分别通过系统调用clone()和execve()来实现。我们建议hookexecve()系统调用,以获得启动新进程的控制权。

下边的图给出了一个ftrace示例,并说明了hooking处理函数的过程。

在此图中,我们可以看见用户进程(红色)怎样执行对内核(蓝色)的系统调用,其中ftrace框架(红色)从我们的模块(红色)调用函数。

下边,我们详尽描述了这个过程的每一步:

SYSCALL指令由用户进程执行。该指令容许切换到内核模式,并让低级系统调用处理程序entry_SYSCALL_64()负责。此处理程序负责64位内核上64位程序的所有系统调用。

一个特定的处理器接收控制。内核快速完成汇编程序上实现的所有低级任务linux启动盘制作工具,并将控制权移交给中级的do_syscall_64()函数,该函数使用c语言编撰。该函数抵达系统调用处理程序表sys_call_table,并通过系统调用号调用特定的处理程序。在我们的示例中,它是sys_execve()函数。

调用ftrace。在每位内核函数的开头都有一个fentry()函数调用。该函数由ftrace框架实现。在不须要跟踪的函数中,这个调用被替换为nop指令。但是,对于sys_execve()函数,没有这样的调用。

Ftrace调用我们的反弹。Ftrace调用所有注册的跟踪反弹,包括我们的。其他反弹不会干扰,由于在每位特定的位置,只能安装一个反弹来修改%rip寄存器的值。

反弹函数执行hooking。这个反弹函数查看在do_syscall_64()函数内部的parent_ip引导的值——因为它是调用sys_execve()处理程序的特定函数——并决定hook函数linux 调用内核函数,在pt_regs结构中修改寄存器%rip的值。

Ftrace恢复寄存器的状态。在FTRACE_SAVE_REGS标志以后,框架在调用处理程序之前将注册状态保存在pt_regs结构中。当处理结束时,从相同的结构恢复寄存器。我们的处理程序更改了寄存器%rip——一个指向下一个执行函数的表针——这会造成将控制传递到一个新的地址。

包装函数接收控制。无条件跳转使它看上去像sys_execve()函数的激活早已中止。不是这个函数,而是fh_sys_execve()函数。同时,处理器和显存的状态保持不变,因而我们的函数接收原始处理程序的参数,并将控制权返回给do_syscall_64()函数。

原函数是由包装函数调用的。如今,系统调用在我们的控制之下。在剖析系统调用的上下文和参数以后,fh_sys_execve()函数可以容许或严禁执行。假如严禁执行,函数返回一个错误代码。否则,函数须要重复对原始处理程序的调用,但是通过钩子设置期间保存的real_sys_execve表针再度调用sys_execve()。

反弹获得控制权。如同在sys_execve()的第一次调用期间,控件通过ftrace到我们的反弹。但这一次,这个过程以不同的形式结束。

反弹哪些也不做。sys_execve()函数不是由内核从do_syscall_64()调用的,而是由我们的fh_sys_execve()函数调用的。因而,寄存器保持不变,sys_execve()函数照常执行。惟一的问题是,ftrace两次见到sys_execve()的入口点。

包装函数获得控制权。系统调用处理程序sys_execve()第二次将控制权交给我们的fh_sys_execve()函数。如今linux 调用内核函数,一个新进程的启动早已接近完成。我们可以看见execve()调用是否完成了一个错误,研究新的进程,对日志文件做一些注释,等等。

内核接收控制。最后,运行完fh_sys_execve()函数,并返回do_syscall_64()函数。该函数将调用视为正常完成的调用,而内核照常运行。

控制权转交给用户进程。最后,内核执行IRET指令(或SYSRET,但对于execve()只能执行IRET),为新用户进程安装寄存器,并将处理器切换到用户代码执行模式。系统调用结束了,新进程的启动也结束了。

如你所见,用ftracehookingLinux内核函数调用的过程并不复杂。

推论

虽然ftrace的主要目的是跟踪Linux内核函数调用,而不是hook它们,但我们的创新方式被证明既简单又有效。并且,我们前面描述的方式只适用于内核版本3.19或更高版本,而且只适用于x86_64构架。

在本系列的第三部份(也是最后一部份)中,我们将介绍ftrace的主要优点和缺点,以及若果你决定实现这些方式,可能会碰到的一些意外惊喜。与此同时,你还可以了解安装钩子的另一种不同寻常的解决方案——使用带有LD_PRELOAD的GCC属性构造函数。

本文原创地址:https://www.linuxprobe.com/rhsyflhnhzdg.html编辑:刘遄,审核员:暂无