/

Linux Rookit 教程(2):Ftrace和Hook函数

1 前言

本篇文章翻译自Linux Rootkits Part 2: Ftrace and Function Hooking,由于近段时间有了解Linux Rookit的需要,借这篇翻译文章补一下自己Linux Kernel的基础知识和学习写Linux Kernel shellcode的方法。

2 正文

学习完Linux Rootkits Part 1: Introduction and Workflow之后,你已经会写一个内核模块了。不想止步于此,你可能会想做一点更酷的事,比如篡改运行中内核的执行流程。在这篇文章中,我们介绍的更改Linux内核执行流程的方法就是hook函数,不过问题是,我们怎么知道自己应该hook哪一个函数呢?

我们很幸运,Linux内核提供了一堆潜在的可Hook的目标函数——syscalls(系统调用函数)!可以在系统调用函数中寻找用于Hook的函数,Syscalls是内核函数,可以在用户态下调用。常见的系统调用函数如下:

  • open
  • read
  • write
  • close
  • execve
  • fork
  • kill
  • mkdir

完整的syscalls列表可以在linux源码的syscall_64.tbl看到。内核态中看到这些函数都会加上sys_的前缀的,可以在linux操作系统中用sudo cat /proc/kallsyms | grep function_name获取目标函数地址的偏移量。通过hook syscalls,可以让我们打断类似于sys_read系统调用函数执行的流程,返回与sys_read原本会读取出来不同的数据。或者用sys_execve添加我们自定义的函数变量,甚至可以利用sys_kill系统调用函数中不被用到的信号向rookit中发送下一条指令让其执行特定的操作。

要完成上述的操作之前,需要我们先了解如何在用户态中调用syscalls(系统调用函数), 了解完之后才能知道如何中断系统调用函数的调用过程。

1
2
3
4
hook->ops.func = fh_ftrace_thunk;
hook->ops.flags = FTRACE_OPS_FL_SAVE_REGS
| FTRACE_OPS_FL_RECURSION_SAFE
| FTRACE_OPS_FL_IPMODIFY;

如上所述,rip可能已经被修改好了,所以在这里通过设置FTRACE_OPS_FL_IP_MODIFY标志位向ftrace告警。为了设置FTRACE_OPS_FL_IP_MODIFY标志位,我们还需要在hook的过程中给原本系统调用函数中的pt_regs结构体传一个FTRACE_OPS_FL_SAVE_REGS标志位。最后,为了关闭ftrace内置的递归保护机制,就设置了FTRACE_OPS_FL_RECURSION_SAFE标志位。FTRACE_OPS_FL_SAVE_REGS标志位是默认开启的,为了我们的hook能起作用最有效的方式就是关闭ftrace的保护机制。

显而易见,如果ftrace的保护机制利用的是保存在rip寄存器中的返回地址,仅仅告诉ftrace我们要修改rip的值是不够的,ftrace仍旧有不利于我们的保护机制。

除了设置上面的标志位之外,我们还需要给fh_trace_thunk函数中的ops.func子域设置标志位(fh_trace_thunk函数是我们之前讲回调时就提到的函数)。这个函数的功能,是修改rip寄存器中的数值,让其指向hook->function存的地址。剩下的工作就是要保证不论什么时候rip寄存器保存着sys_mkdir地址的时候,都能回调hook函数。

这个工作,交给了最后讲解的两个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
err = ftrace_set_filter_ip(&hook->ops, hook->address, 0, 0);
if(err)
{
printk(KERN_DEBUG "rootkit: ftrace_set_filter_ip() failed: %d\n", err);
return err;
}

err = register_ftrace_function(&hook->ops);
if(err)
{
printk(KERN_DEBUG "rootkit: register_ftrace_function() failed: %d\n", err);
return err;
}

ftrace_set_filter_ip()函数,告知ftrace当rip寄存器中保存sys_mkdir系统调用函数的地址的时候,就执行我们写的回调函数(此时我们要hook的sys_mkdir函数的地址已经早早存在了hook->function中)。最后,设置好这些就达成了调用register_ftrace_function()的目的。到这一步,我们的函数钩子就已经就位了。

如你所想,一旦unload rookit模块并调用rookit_exit()函数,fh_remove_hooks()函数就会反向完成上面的操作,还原原本的执行流程。

看到这里你应该就知道,为什么我一开始说不用百分百了解完ftrace使用的原理就能写一个系统调用函数的hook函数。实际上这里面最大的挑战是写hook函数的本身,不过在实际写的过程中还是会遇到很多问题的。

2.1 Linux在用户态中调用syscalls

linux源码的syscall_64.tbl中可以看到每一个syscalls前面都对应着一个数字,这个数字实际上是用户在调用系统函数的时候需要传递的系统调用号(syscalls number)。不过这些系统调用号不是固定的,系统调用号在不同版本的架构和内核版本中有可能会不同。

调用syscalls之前,需要先将系统调用号(syscall number)存储到特定的寄存器中——rax寄存器(x86架构是在eax寄存器中), 之后再利用软件中断syscall调用内核中的函数。所有的syscalls调用之前都需要先将syscalls number存到rax中,大多数函数调用的返回值也会存储在rax中。

syscall 0(sys_read())是一个很典型的例子,在Linux操作系统中用man 2 read可以找到read函数定义如下:

1
ssize_t read(int fd, void *buf, size_t count);

根据手册对read函数的描述,fd是文件描述符(调用open()函数返回的值),buf是一个用于存储读取的数据的缓冲区,count是读取的字节数。如果read函数成功读取的话会返回读取的字节数,读取失败则会返回-1。根据上面的描述,我们可以知道,在调用sys_read函数之前,我们需要传递三个参数。不过,我们怎么知道这三个参数应该放在哪个对应的寄存器中呢?从Linux Syscall Reference(64 bit)的表中我们可以查到,sys_read函数在传递参数的过程中会使用到rax、rdi、rsi、rdx寄存器,其存放的对应参数具体如下:

function_name rax rdi rsi rdx
sys_read 0x00 unsigned int fd char __user * buf size_t count

rdi寄存器存储文件描述符fdrsi寄存器存储缓冲区bufrdx寄存器用于存储字节数count。只要我们往rax寄存器中存入0x00就能调用sys_read函数了。用nasm汇编写的调用sys_read代码大致的样子如下:

1
2
3
4
5
6
section .text
mov rax, 0x00
mov rdi, 5
mov rsi, buf
mov rdx, 10
syscall

这段代码从文件描述符5(这个数是随便选的)中读取了10字节的数据存入到buf指向的内存地址中。

2.2 内核处理syscalls的过程

上一段结尾写的汇编代码能够在用户态的环境下正常运行,不过内核中是如何处理syscalls的呢?本篇文章介绍的rookit需要运行在内核态中,所以我们接下来应该了解一下内核是如何处理syscalls调用的。接下来介绍的内容,跟上面的会有一些区别。从4.17.0版本的Linux内核开始,内核处理syscalls调用的方式就发生了变化。不过我们在这里还是会先从旧的内核处理syscalls的方式开始介绍,因为Ubuntu 16.04发行版到现在还在使用这种处理方式,而且一旦理解了旧的处理方法,新的处理方法也会更好懂。

由于作者本人的Rootkit应用场景是在CTF中,且应用环境的内核版本低于4.17.0,环境中配置了sudo,因此在操作过程中可以不用密码以root权限运行insmod命令。有点冲突的是,运行的环境是Ubuntu 16.04,而作者写的rookits应用于hook新的syscalls

syscall.h源码中可以看到sys_read在内核中定义如下:

1
asmlinkage long sys_read(unsigned int fd, char __user *buf, size_t count);

回到2016年,想要准确地给syscalls传递参数,只要按照它地定义方式来就可以了。如果我们想hook sys_read,在把hook放对位置的前提下,只要模仿sys_read函数定义即可,具体参数处理的过程想怎么写都行。

然而,64位的4.17.0版本的内核中我们不能这样做了。用户存储在寄存器中的参数,会被拷贝到一个特殊的结构体中——pt_regs,只有这个结构体会被传递给syscall。接下来,syscall只负责从结构体中取出它需要的参数。可以从ptrace.h的源码中看到,pt_regs结构体的定义如下:

1
2
3
4
5
6
7
8
struct pt_regs {
unsigned long bx;
unsigned long cx;
unsigned long dx;
unsigned long si;
unsigned long di;
/*为了看起来更清晰省略了后面的内容*/
}

这就意味着,我们不能随意地操作sys_read传递地参数了,我们要写的话必须像下面这样写:

1
2
3
4
5
6
asmlinkage long sys_read(const struct pt_regs * regs)
{
int fd = regs->fd;
char __user *buf = reg->si;
size_t count = regs->dx;
}

当然,真实的sys_read函数并不需要这样做,因为内核已经帮我们处理好了这部分工作了。不过,在我们写hook的函数的时候,我们还会需要这样处理这些参数。

2.3 首次写hook系统调用函数的函数

带着前面了解的知识,我们接下来一起学习怎么写一个hook功能的函数。在下面的示例中囊括了新旧两版Hook系统函数的方法(Linux Kernel 4.17.0前后写Hook函数的方式不同),该函数简单地hook了sys_mkdir系统调用函数,并且将sys_mkdir函数创建地目录名称输出到内核缓冲区中。完成上述功能之后,我们需要担忧的就是原本的sys_mkdir的函数的功能没有实现,只有我们写的hook函数起作用了。

首先,我们需要利用linux/version.h头文件check Linux内核版本,接着只要直接使用一堆预处理宏(preprocessor macros)就行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/syscalls.h>
#include <linux/version.h>
#include <linux/namei.h>

MODULE_LICENSE("GPL");
MODULE_AUTHOR("TheXcellerator");
MODULE_DESCRIPTION("mkdir syscall hook");
MODULE_VERSION("0.01");

#if defined(CONFIG_X86_64) && (LINUX_VERSION_CODE >= KERNEL_VERSION(4,17,0))
#define PTREGS_SYSCALL_STUBS 1
#endif

#ifdef PTREGS_SYSCALL_STUBS
static asmlinkage long (*orig_mkdir)(const struct pt_regs *);

asmlinkage int hook_mkdir(const struct pt_regs *regs)
{
char __user *pathname = (char *)regs->di;
char dir_name[NAME_MAX] = {0};

long error = strncpy_from_user(dir_name, pathname, NAME_MAX);

if (error > 0)
printk(KERN_INFO "rootkit: trying to create directory with name: %s\n", dir_name);

orig_mkdir(regs);
return 0;
}
#else
static asmlinkage long (*orig_mkdir)(const char __user *pathname, umode_t mode);

asmlinkage int hook_mkdir(const char __user *pathname, umode_t mode)
{
char dir_name[NAME_MAX] = {0};

long error = strncpy_from_user(dir_name, pathname, NAME_MAX);

if (error > 0)
printk(KERN_INFO "rootkit: trying to create directory with name %s\n", dir_name);

orig_mkdir(pathname, mode);
return 0;
}
#endif

/* init and exit functions where the hooking will happen later */

好吧,这堆代码中有很多需要进行解释的地方。直接看代码能发现,代码被if/else分成了两个类似的功能模块。用if判断check完操作系统的内核版本以及架构之后,若满足Linux操作系统是64位的且内核版本大于4.17.0的条件,则定义宏PTREGS_SYSCALL_STUBS并赋值为1,若不满足该条件则不定义。当PTREGS_SYSCALL_STUBS的值为1时(内核版本大于4.17.0的64位Linux操作系统),代码通过定义orig_mkdir函数指针和hook_mkdir函数声明去操作pt_regs结构体。PTREGS_SYSCALL_STUBS的值不为1时,在hook函数声明时直接使用参数实际名称。需要注意的是,在新版本内核的Hook函数中,必须包含下面的一行代码才能实现从regs结构体中取出pathname参数的值的功能。

1
char __user *pathname = (char *)regs->di;

另一个需要注意的点是,strncpy_from_user函数的使用。pathname参数定义时使用了__user标识符,这意味着pathname参数指向用户空间的位置,不需要映射到我们的地址空间(内核空间)中。当printk()间接引用pathname指针时(取pathname指针的值),可能会造成段错误或直接打印出垃圾数据,上述两种情况都不是我们想要的。

为了避免这两种情况,Linux内核给我们提供了一系列的函数,有像copy_from_user()strncpy_from_user()等从用户空间拷贝数据的函数,还有将数据拷回用户空间的copy_to_user函数。经过上面的简要介绍后,就能知道代码中的strncpy_from_user()函数从用户空间中的pathname拷贝NAME_MAX个字符的数据到dir_name中,NAME_MAX通常为255(该值为Linux文件名的最大长度)或空字节。与简单的旧函数copy_from_user()相比,strncpy_from_user()函数可以识别出空字节。

一旦我们获取了存储在dir_name缓冲区中新创建的文件夹名称后,代码就会继续运行,并调用printk()函数以常见的字符串%s格式将文件夹名称输出到内核缓冲区中。

最后,讲解一下两个条件分支中最重要的部分——orig_mkdir()函数的调用。在调用orig_mkdir()函数的时候,传递了与sys_mkdir()函数调用时相对应的参数,这保证了原本sys_mkdir()函数的功能能够正常实现。你可能好奇,orig_mkdir()函数和sys_mkdir()函数是怎么联系起来的,实际上,代码实现时用函数指针原型(function pointer prototype)定义orig_mkdir()就可以让orig_mkdir()函数实现sys_mkdir()的功能了。

2.4 用Ftrace Hook函数

为了在内核中加上Hook函数,代码中调用了Ftrace函数,具体的Ftrace函数利用过程没必要非常了解,只要知道个大概就行。源码ftrace_helper.h中一开始创建了一个ftrace_hook数组,接着在rootkit_init()中调用fh_install_hooks(),在rootkit_exit()中调用fh_uninstall_hooks()。知道前面这一点,你就能直接进行实操了。任何一个rookit最需要消化的部分是hook它本身,这部分的内容我会在后续的博客中重点介绍。我们所有的需要利用Ftrace()实现的功能已经打包到ftrace_helper.h头文件中了。

可能有一部分人会因为就学了一丢丢感觉不满意,所以在2.2.5节中对Ftrace()进行了更全面的讲解。如果你没有这样的想法,那就更好了。

接下来继续讲解如何用Ftrace hook函数,使用之前需要将ftrace_helper.h的文件头包含到我们自己写的module源码中,接着为我们写的module写好init和exit函数。

不过我们先要做的,还是定义好Ftrace用于处理hook的函数的数组。

1
2
3
static struct ftrace_hook hook[] = {
HOOK("sys_mkdir", hook_mkdir, &orig_mkdir),
};

HOOK宏函数需要传递的参数如下:

  • .name: hook的系统调用函数的名称或内核函数的名称,我们这里hook的目标函数是sys_mkdir()函数。
  • .function: 写好的用于hook的hook_mkdir()函数。
  • .original: 存储在orig_mkdir中原本的系统调用函数的地址。

要注意的是,hook[]数组能够一次性包含多个hook函数实现复杂的rookit。

一旦hook[]这个数组设置好后,就可以用fh_install_hooks()给函数安装钩子,用fh_remove_hooks()将钩子卸载掉。具体需要做的就只用将fh_install_hooks()fh_remove_hooks()分别放到init和exit函数中,并用if检查这两个函数是否成功运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static int __init rootkit_init(void)
{
int err;
err = fh_install_hooks(hooks, ARRAY_SIZE(hooks));
if(err)
return err;

printk(KERN_INFO "rootkit: loaded\n");
return 0;
}

static void __exit rootkit_exit(void)
{
fh_remove_hooks(hooks, ARRAY_SIZE(hooks));
printk(KERN_INFO "rootkit: unloaded\n");
}

module_init(rootkit_init);
module_exit(rootkit_exit);

下载好3个编译所需要的文件,就可以开始编译了。make指令运行完成后,在当前目录下会生成一个rookit.ko文件。用insmod rookit.ko在操作系统内核中加载rookit.ko模块,接着利用mkdir指令创建一个文件夹。拉起hook函数之后,dmesg可以查看到rookit.ko在内核中的输出。具体的命令行的输入如下:

1
2
3
4
5
6
$ sudo dmesg -C
$ sudo insmod rootkit.ko
$ mkdir lol
$ dmesg
[ 3271.730008] rootkit: loaded
[ 3276.335671] rootkit: trying to create directory with name: lol

获得了上面的输出之后,说明我们已经成功hook了sys_mkdir系统调用函数。Ftrace已经保证了orig_mkdir会指向原本的sys_mkdir系统调用函数,因此只要在我们的hook函数中直接调用就可以了,不用了解太多的细节。

考虑到未来rookits通用性,我们需要写一个新的hook函数,让其适用于任意目标函数(想hook的函数),此外,还需要重写hook[]数组和其他的小细节。

这里指出一点,我们只能hook内核中export出来的函数。/proc/kallsyms可以列出所有内核export出来的函数,不过在用的时候记得在前面加上sudo,不然出现的内存地址会都是0x0。显然,所有的系统调用函数都需要内核export出来,这样用户空间中运行的代码才能去找到它,不过内核中也有我们感兴趣的函数是非系统调用函数的(这些函数也会被内核暴露出来的),这个我们后续会再进行介绍。

2.5 福利:ftrace_helper.h的细节介绍

看到这里,就是说你想更了解ftrace在rookit中到底做了些什么。简单来说,rookit利用了Ftrace函数可以attach一个内核的callback函数。(翻译到这里我突然想起今年的BlackHat的Fixing a Memory Forensics Blind Spot: Linux Kernel Tracing]议题介绍了Linux Kernel Tracing的内容,里面也提及到了Ftrace、kprobes、eBFP和tracepoints这些用于hook内核函数的方法,感兴趣的可以看看)。具体点说,我们可以在任何时候让ftrace单步执行rip寄存器中包含的一个具体的内存地址。如果我们设的是sys_mkdir系统调用函数的地址,利用ftrace我们可以用另外一个函数替换sys_mkdir运行。

Ftrace所有需要掌握的信息,实际上就是封装到ftrace_hook结构体中的内容。由于我们想要实现一次性hook多个函数,所以在代码中就使用了hooks[]数组。

1
2
3
static struct ftrace_hook hooks[] = {
HOOK("sys_mkdir", hook_mkdir, &orig_mkdir),
};

hooks[]数组有一点需要拆开来讲的,首先,我们来看看ftrace_helper.h源码中的ftrace_hook结构体。

1
2
3
4
5
6
7
8
struct ftrace_hook {
const char *name;
void *function;
void *original;

unsigned long address;
struct ftrace_ops ops;
};

利用HOOK宏,能够更加简单快速地给结构体赋值:

1
2
3
4
5
6
#define HOOK(_name, _hook, _orig) \
{ \
.name = SYSCALL_NAME(_name), \
.function = (_hook), \
.original = (_orig), \
}

使用到SYSCALL_NAME宏是因为考虑到在64位的Linux内核中,系统调用函数在他们的函数名称前会带有__64_的前缀。

结构体的部分介绍起来挺简单的,现在我们该看看fh_install_hooks()函数了,该函数是实现hook功能重要的一环。骗你的,我只是利用这种方式来提醒你们,fh_install_hooks()仅仅利用循环实现了hooks[]数组遍历以及为数组中每个内核函数调用fh_install_hook()函数。

fh_install_hook()函数中第一步就是调用fh_resolve_hook_address(),并给该函数传递一个作为参数的ftrace_hook对象。fh_install_hook()函数用了<linux/kallsyms.h>头文件提供的kallsyms_lookup_name()函数获取原本系统调用函数加载到内存中的地址,比如在我们想要的应用场景中就是获取sys_mkdir系统调用函数的内存加载地址。获取内存地址的这个步骤很重要,考虑到orig_mkdir()函数需要分配系统调用函数的内存加载地址,以及我们编写的rookit内核模块在重加载的时候需要恢复运行环境,所以保存获取的内存地址是很有必要的。源码中,将获取的系统调用函数加载地址保存在ftrace_hook结构体中的.address字段中。

下面,来看看ftrace_helper.h源码中略显诡异的预处理声明:

1
2
3
4
5
#if USE_FENTRY_OFFSET
*((unsigned long*) hook->original) = hook->address + MCOUNT_INSN_SIZE;
#else
*((unsigned long*) hook->original) = hook->address;
#endif

想要理解上面的声明,需要先想到在我们尝试hook函数的时候使用递归可能会面临的风险。主要有两种思路可以避免这个风险,第一种是利用函数返回的地址监测递归,第二种就直接用上面的+ MCOUNT_INSN_SIZE跳过ftrace的调用(在Linux内核的源码中,MCOUNT_INSN_SIZE表示着mcount()函数总体的大小,而mcount()函数又会用ftrace_caller()调用ftrace,所以在hook地址上加上MCOUNT_INSN_SIZE就能跳过ftrace的调用,从而避免Ftrace在hook函数调用orig_mkdir后又再次调用该函数,让递归陷入死循环的情况)。通过判断USE_FENTRY_OFFSET的值,源码选择不同的方式避免递归死循环。当USE_FENTRY_OFFSET的值为0的时候,使用用返回地址监测递归的方法,为1的时候用第二种方法。

如果想使用第一种方式监测递归,那就需要关闭ftrace提供的保护机制(ftrace会恢复现场,也就是恢复rip寄存器的值,因此调用了ftrace之后会无止境地递归hook函数)。ftrace内置的保护机制需要利用保存在rip中函数返回地址,不过如果我们仍旧要用rip的话,还是需要有保护机制的,因此最后需要用我们自己的保护机制替换掉ftrace内置的保护机制。这些可以利用ftrace_hook结构体中的.original字段实现,.original字段存着.name字段对应的系统调用函数的内存地址。

下一个介绍fh_install_hook()的就是ftrace_hook结构体的.ops字段,该字段自己本身就是一个带有两个字段的结构体。

1
2
3
4
hook->ops.func = fh_ftrace_thunk;
hook->ops.flags = FTRACE_OPS_FL_SAVE_REGS
| FTRACE_OPS_FL_RECURSION_SAFE
| FTRACE_OPS_FL_IPMODIFY;

3 参考文章