在大量方法描述的资料查找中,我们注意到了Linux ftrace,一个可用于跟踪Linux内核函数调用的Linux内核框架。使用ftrace执行Linux内核跟踪是常见的做法。但是事实证明,ftrace比jprobes更适合跟踪函数调用的需求。ftrace可以通过名称来hook内核中的任何函数,并围绕其调用执行代码。
Ftrace允许我们通过函数名称hook关键Linux内核函数,并且可以在不重建内核的情况下安装钩子。
使用ftrace进行Linux内核hooking
Ftrace是一个用于在函数级别跟踪内核的框架。该框架自2008年以来一直在开发中,具有相当令人印象深刻的函数集。使用ftrace跟踪内核函数时,通常可以获得很多数据。Linux ftrace显示调用图,跟踪函数调用的频率和长度,按模板过滤特定函数等。
Ftrace的实现基于编译器选项-pg和-mfentry。 这些内核选项在每个函数的开头插入一个特殊跟踪函数的调用 —— mcount()或fentry ()。 在用户程序中,分析器使用此编译器功能来跟踪所有函数的调用。但是,在内核中,这些函数用于实现ftrace框架。
当然,从每个函数调用ftrace都是非常昂贵的。这就是为什么有一种针对流行架构的优化——动态ftrace。如果没有使用ftrace,它几乎不会影响系统,因为内核知道调用mcount()或fentry()的位置,并在早期阶段将机器码替换为nop(一个不执行任何操作的特定指令)。当Linux内核跟踪打开时,ftrace调用会被添加到必要的函数中。
必要函数声明
下面的结构可以用来描述每个钩子函数:
/*
* struct ftrace_hook—描述要安装的单个钩子
* @name:要挂起的函数名
* @function:指向要执行的函数的指针
* @original:指针指向保存原始函数指针的位置
* @address:函数入口的内核地址
* @ops:这个钩子的ftrace_ops状态
*用户应该只填写&name, &hook, &orig字段。
*其他字段被认为是实现细节。
*/
struct ftrace_hook {
const char *name;
void *function;
void *original;
unsigned long address;
struct ftrace_ops ops;
};
用户只需要填写三个字段:name、function和original。其余字段被认为是实现细节。这样可以把所有Hook函数的描述放在一起,并使用宏可以使代码更紧凑:
#define HOOK(_name, _function, _original) \
{ \
.name = SYSCALL_NAME(_name), \
.function = (_function), \
.original = (_original), \
}
static struct ftrace_hook demo_hooks[] = {
HOOK("sys_execve", fh_sys_execve, &real_sys_execve),
};
下面是钩子函数包装的结构:
/ *
它是指向原始系统调用处理程序execve()的指针。
可以从包装器调用它。
(注意“asmlinkage”)
* /static asmlinkage long (*real_sys_execve)(const char __user *filename,
const char __user *const __user *argv,
const char __user *const __user *envp);
/*
初始化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: %s\n", 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;
}
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: %d\n", err);
return err;
}
err = register_ftrace_function(&hook->ops);
if (err) {
pr_debug("register_ftrace_function() failed: %d\n", 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: %d\n", err);
}
err = ftrace_set_filter_ip(&hook->ops, hook->address, 1, 0);
if (err) {
pr_debug("ftrace_set_filter_ip() failed: %d\n", err);
}
}
当unregister_ftrace_function()调用结束时,可以保证系统中不会激活已安装的回调函数或包装器。我们可以卸载hook模块,而不用担心我们的函数仍然在系统的某个地方执行。
使用ftrace hook函数
那么如何配置内核函数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()和struct ftrace_hook中嵌入的struct ftrace_ops的地址为我们的函数获取struct ftrace_hook的地址。 接下来,我们使用处理程序的地址替换struct pt_regs结构中的寄存器%rip的值。对于x86_64以外的体系结构,此寄存器可以具有不同的名称(如PC或IP)。 但基本思想仍然适用。
有一个值得注意的地方,那就是为回调添加的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;
这种方法有三个主要优点:
较低的开销。只需要执行几个比较和减法,而不需要获取任何自旋锁或遍历列表。
它不必是全局的。由于没有同步,这种方法与抢占是兼容的,并且不绑定到全局进程列表。因此,我们甚至可以跟踪中断处理程序。
函数没有限制。这种方法没有主要的kretprobes缺点,可以支持开箱即用的任何数量的跟踪函数激活(包括递归)。在递归调用期间,返回地址仍然位于模块外部,因此回调测试可以正确工作。
意外情况
由于一些未知的原因,当在ftrace回调中调用原函数时,parent_ip仍然指向内核而不是函数包装器。这就启动了一个死循环,ftrace一遍又一遍地调用我们的包装器,而没有做任何有用的事情。
幸运的是,最终发现了问题的原因。我们统一了代码并去掉了我们现在不需要的部分,并使包装器函数代码的两个版本之间的差异缩小了。
这是稳定的代码:
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=%p\n",
filename, argv, envp);
ret = real_sys_execve(filename, argv, envp);
pr_debug("execve() returns: %ld\n", ret);
return ret;
}
这是导致系统挂起的代码:
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_devel("execve() called: filename=%p argv=%p envp=%p\n",
filename, argv, envp);
ret = real_sys_execve(filename, argv, envp);
pr_devel("execve() returns: %ld\n", ret);
return ret;
}
日志级别如何影响系统行为? 令人惊讶的是,当我们仔细研究这两个函数的机器代码时,我们发现这些问题背后的原因是编译器。
结果是,pr_devel()调用被扩展为no-op。这个printk-macro版本用于开发阶段的日志记录。由于这些日志在操作阶段没有任何意义,系统会自动将它们从代码中删除,除非激活了DEBUG宏。之后,编译器会看到这样的函数:
static asmlinkage long fh_sys_execve(const char __user *filename,
const char __user *const __user *argv,
const char __user *const __user *envp)
{
return real_sys_execve(filename, argv, envp);
}
结果是,pr_devel()调用被扩展为no-op。这个printk-macro版本用于开发阶段的日志记录。由于这些日志在操作阶段没有任何意义,系统会自动将它们从代码中删除,除非激活了DEBUG宏。之后,编译器会看到这样的函数:
static asmlinkage long fh_sys_execve(const char __user *filename,
const char __user *const __user *argv,
const char __user *const __user *envp)
{
return real_sys_execve(filename, argv, envp);
}
这就是优化的阶段。在我们的示例中,激活了所谓的尾部调用优化。如果一个函数调用另一个函数并立即返回它的值,这种优化让编译器可以用更直接的跳转到函数的主体来替换函数调用指令。
第一个调用指令与编译器在所有函数的开头插入的fentry()调用完全相同。但在那之后,错误代码和稳定代码的行为就不同了。在稳定的代码中,我们可以看到调用指令执行的real_sys_execve调用(通过存储在内存中的指针),在RET指令的帮助下,后面是fh_sys_execve()。然而,在错误代码中,直接跳转到JMP执行的real_sys_execve()函数。
尾部调用优化允许通过不分配包含call指令存储在堆栈中的返回地址的无用堆栈帧来节省一些时间。但是,由于我们使用parent_ip来决定是否需要hook,因此返回地址的准确性对我们来说至关重要。经过优化后,fh_sys_execve()函数不再将新地址保存在堆栈中,因此只有旧地址指向内核。这就是为什么parent_ip一直指向内核内部,而那个死循环一开始就出现了。
这个问题应该仅仅出现在某些发行版上。因为不同的发行版使用不同的编译标志集来编译模块。 在所有问题分布中,尾部调用优化默认是开启的。
我们通过使用包装器函数关闭整个文件的尾部调用优化来解决这个问题。
使用ftrace进行Linux内核hooking程序整体解决方案在下一篇文章中进行讲解
本文深入探讨了使用ftrace进行Linux内核函数跟踪的方法,包括如何hook内核中的任何函数,以及在不重启内核的情况下安装钩子。文章详细介绍了ftrace的实现原理,如何避免无限递归调用的问题,以及如何正确处理ftrace回调中的优化问题。

585

被折叠的 条评论
为什么被折叠?



