一、前情提要
在前一篇文章《Linux内核跟踪:ftrace hook入门手册(上)》中,我们对部分ftrace hook经典方案中的实现细节进行了优化。本文会深入说明这些优化的原理和目的。
二、内核版本的差异
目前的ftrace hook实现中,总是需要使用大量条件编译以解决Linux内核的版本差异问题。其中较为关键的一个差异点,就是Linux内核从4.17版本开始修改了系统调用过程中的函数签名,这对ftrace hook的实现造成了较大的困扰。
下为4.16版本Linux内核源码/arch/x86/entry/common.c[1],尤其关注第287行,可见该版本Linux内核在执行系统调用时会将寄存器结构体中的6个参数展开来调用sys_call_table[nr]:
图1:Linux内核4.16版本do_syscall_64函数实现
而在4.17.0版本中,同样在第287行,可见已经改用单个参数(指向整个寄存器结构体的指针)来调用sys_call_table[nr]:
图2:Linux内核4.17版本do_syscall_64函数实现
而如前一篇文章所述,ftrace hook是通过编译时处理,在各个内核函数实现代码的开头插桩call指令,所以ftrace hook介入系统调用是在do_syscall_64之后:
图3:ftrace hook子程中打印的部分内核调用堆栈(上为栈顶,下为栈底)
因此ftrace中直接使用的hook子程在获取系统调用参数时,必须考虑这种差异才行。
三、 经典方案的缺陷
针对这个问题,在笔者找到的几乎所有经典方案[2]中,都通过条件编译定义了两套hook子程,分别适用于4.17版本前后的两种情况:
图4:经典方案中条件编译两个hook子程
但这样实现的话,相同的功能都要写两套,代码开发和维护都十分不便。以一般的编程思路,我们可以封装定义一个形式上的hook子程函数(后简称外套子程),在这个外套子程中将传递到系统调用的参数统一结构后,再调用实际实现业务功能hook子程(后简称业务子程)。
然而事情并没有这么简单。经典方案通常针对x86架构,并不是在ftrace_set_filter_ip所设置的过滤器函数中调用hook子程,而是在这个过滤器函数中修改EIP/RIP寄存器到hook子程的入口地址。hook子程并非在ftrace框架内调用,而是在ftrace框架返回到系统调用时跳转到hook子程(而没有回到真正的系统调用函数)。
这种做法的好处是,hook子程在运行流程上直接替代了原有的系统调用函数,两者可以使用完全相同的函数签名处理业务,有点类似于修改系统调用表的hook方法。
hook子程可以直接定义与系统调用函数相同的形式参数来获取系统调用参数值,而返回时也会直接返回到系统调用函数的直接调用方(参考下图[3]):
图5:经典方案中的hook执行流程
然而,由于Linux内核模块通常为纯C语言实现,缺少将参数值或者其它信息绑定到回调函数的原生支持。ftrace_set_filter_ip所设置的过滤器函数中姑且可以根据第三个参数所指向的地址来找到与当前hook实例有关的信息(即代码中的“container_of(ops,…)”)。但如果我们通过修改RIP跳转到外套子程,那就意味着所有的ftrace hook都会跳转到同一个外套子程,而此时外套子程所接收到的参数实际上是由系统调用函数的直接调用方(如do_syscall_64)提供的,我们很难在过滤器函数中修改或传递更多的参数给外套子程——结果导致在同时存在多个hook目标的情况下,外套子程内部难以确定应该调用哪个业务子程。
当然,并非完全没有方法来解决这个问题。我们可以将业务子程绑定到系统调用号,然后在外套子程中根据系统调用号(x86架构是AX)来找到对应的业务子程;还可以在过滤器函数中将额外信息存放在返回值寄存器(x86架构还是AX)中,而不影响其它运行流程。
四、 优化方案
不过,最为简单的优化方法,还是在过滤器函数内直接调用业务子程。经典方案中设置IP寄存器来进行跳转的根本目的,大概也只是为了让hook子程获取系统调用参数和执行返回逻辑。接下来,我们将会在过滤器函数内直接获取当前系统调用的参数,并设置它的返回值。
首先是参数值的获取。Linux系统调用的大致过程是,用户程序将系统调用的实际参数设置到特定的寄存器中,然后通过中断指令(int 30)切换到内核空间并实际执行系统调用过程。此时,用户空间的寄存器会以pt_regs结构体的形式,存储在当前内核栈空间的最高地址处。取得这个地址的方法有很多,前一篇文章中的代码可供参考:
//获取用户线程原本的寄存器保存位置
struct pt_regs *GetUserRegisters(struct task_struct *task)
{
struct unwind_state state;
task = task ? : current;
unwind_start(&state, task, NULL, NULL);
return (struct pt_regs *)(((size_t)state.stack_info.end) - sizeof(struct pt_regs));
}
或者下面的方法经验证也是可以的:
#include
#if LINUX_VERSION_CODE>=KERNEL_VERSION(4,11,0)
#include
#endif
struct pt_regs *GetUserRegisters(struct task_struct *task)
{
return (struct pt_regs *)(task_stack_page(task ? : current) + THREAD_SIZE) - 1;
}
获取到用户寄存器内容后,即可从中读取出系统调用的参数了。作为对经典方案的优化之一,我们可以在此处加入对架构和位宽等因素导致参数寄存器约定差异的处理:
static void notrace FTraceHookHandler(size_t ip, size_t parent_ip, struct ftrace_ops *ops, struct ftrace_regs *fregs)
{
struct pt_regs *kernel_regs = ftrace_get_regs(fregs);
struct pt_regs *user_regs = GetUserRegisters(NULL);
#if PTREGS_SYSCALL_STUBS
#define argument_regs user_regs
#else
#define argument_regs kernel_regs
#endif
#if defined(CONFIG_X86_64)
#define INSTRUCTION_POINTER kernel_regs->ip
struct FTraceHookContext context =
{
.Hook = container_of(ops, struct FTraceHook, FTraceOPS),
.KernelRegisters = kernel_regs,
.UserRegisters = user_regs,
.SysCallNR = &argument_regs->ax,
.Arguments =
{
&argument_regs->di,
&argument_regs->si,
&argument_regs->dx,
&argument_regs->r10,
&argument_regs->r8,
&argument_regs->r9
},
.ReturnValue = &argument_regs->ax
};
#elif defined(CONFIG_X86_32)
#define INSTRUCTION_POINTER kernel_regs->ip
struct FTraceHookContext context =
{
.Hook = container_of(ops, struct FTraceHook, FTraceOPS),
.KernelRegisters = kernel_regs,
.UserRegisters = user_regs,
.SysCallNR = &argument_regs->ax,
.Arguments =
{
&argument_regs->bx,
&argument_regs->cx,
&argument_regs->dx,
&argument_regs->si,
&argument_regs->di,
&argument_regs->bp
},
.ReturnValue = &argument_regs->ax
};
#else
#error Unsupported architecture config?
#endif
context.Hook->Handler(&context);
…其它hook业务流程…
}
然后是返回流程和返回值的设置。如果过滤器函数正常返回,ftrace框架会让执行流程回到系统调用函数实现的开头。如果我们不希望这样,可以在代码中随便寻找一个返回指令(x86中为0xC3),然后在过滤器函数中修改IP寄存器到这个返回指令的位置即可:
#if defined(CONFIG_X86_64)||defined(CONFIG_X86_32)
#define RET_CODE 0xC3
#else
#error Unsupported architecture config?
#endif
static size_t RET_ADDRESS;
//在过滤器函数中
static void notrace FTraceHookHandler(size_t ip, size_t parent_ip, struct ftrace_ops *ops, struct ftrace_regs *fregs)
{
struct pt_regs *kernel_regs = ftrace_get_regs(fregs);
struct pt_regs *user_regs = GetUserRegisters(NULL);
#if PTREGS_SYSCALL_STUBS
#define argument_regs user_regs
#else
#define argument_regs kernel_regs
#endif
…其它hook业务流程…
if (希望跳过真实系统调用函数的执行而立即返回)
{
argument_regs->ax = 返回值;
kernel_regs->ip = RET_ADDRESS;
}
}
//在初始化函数中
int FTraceHookInitialize(struct FTraceHook *hooks, size_t hooks_size)
{
//随便找一个ret指令的地址,基本上就用当前函数尾部的ret就好;如果求稳(比如担心当前函数内存在复杂的跳转等),可以另外定义一个空函数,注意避免选取内联函数
RET_ADDRESS = (size_t)FTraceHookInitialize;
while (* (unsigned char *) RET_ADDRESS != RET_CODE)
++RET_ADDRESS;
…其它初始化流程…
}
这样一来,我们就可以顺利地获取系统调用的参数、顺利地设置系统调用的返回值,因而没有必要再通过修改IP寄存器的方法跳转到hook子程了。
由于改在过滤器函数中调用hook子程,我们不仅可以轻易地根据过滤器函数的第三个参数确定hook实例信息,而且也不必再强制要求hook子程的函数签名保持与原始系统调用函数一致了。过滤器函数封装过程中,可以一站式解决大量的版本差异处理问题,包括对指令架构和位宽差异的处理等。
除此之外,由于优化方案中可以直接使用ftrace框架自带的防递归机制,经典方案中花费大量代码实现但仍然有所不足的防递归机制也就可以省略了。
五、 后记
实际上,相比于eBPF等用户空间的终端监控方法,ftrace hook这样的内核模块实现终究属于比较沉重的方案,尤其是开发过程中需要进行大量的系统适配处理和测试。
但相应地,ftrace hook可以实现很多eBPF中难以实现的功能,尤其是对系统调用的阻断等。如果您需要非常深入地监测和控制Linux主机上的应用活动,那么ftrace hook也不失为一种不错的选择。
更多前沿资讯,还请继续关注绿盟科技研究通讯。
如果您发现文中描述有不当之处,还请留言指出。在此致以真诚的感谢~
参考文献
[1] BOOTLIN. common.c - arch/x86/entry/common.c - Linux source code (v4.16.18) - Bootlin [Z]. 2022
[2] PHILLIPS H. Linux Rootkits Part 2: Ftrace and Function Hooking [J/OL] 2020, https://xcellerator.github.io/posts/linux_rootkits_02/.
[3] OLEKSII LOZOVSKYI M G, KRZYSZTOF ZDULSKI. ftrace-hook [J/OL] 2021, https://github.com/ilammy/ftrace-hook/.
内容编辑:创新研究院 吴复迪
责任编辑:创新研究院 陈佛忠
本公众号原创文章仅代表作者观点,不代表绿盟科技立场。所有原创内容版权均属绿盟科技研究通讯。未经授权,严禁任何媒体以及微信公众号复制、转载、摘编或以其他方式使用,转载须注明来自绿盟科技研究通讯并附上本文链接。