如何轻松编写Rootkit - Trail of Bits博客
我们开源了一个故障注入工具KRF,它使用内核空间的系统调用拦截技术。你现在就可以使用它来发现程序中的错误假设(以及由此产生的漏洞)。快来试试吧!
这篇文章介绍了通过普通的内核模块在Linux内核中拦截系统调用的方法。我们将快速回顾系统调用以及为什么要拦截它们,然后演示一个拦截read(2)系统调用的基础模块。
什么是系统调用?
系统调用(syscall)是一种函数,它将一些内核管理的资源(I/O、进程控制、网络、外设)暴露给用户空间进程。任何接收用户输入、与其他程序通信、更改磁盘文件、使用系统时间或通过网络联系其他设备的程序通常都是通过系统调用来实现的。
核心的UNIX系统调用相当原始:open(2)、close(2)、read(2)和write(2)用于绝大多数I/O操作;fork(2)、kill(2)、signal(2)、exit(2)和wait(2)用于进程管理等等。
为什么要拦截系统调用?
有几个不同的原因:
- 我们想要收集关于特定系统调用使用情况的统计数据,超出eBPF或其他检测API能够(轻松)提供的范围
- 我们想要进行无法通过静态链接或手动syscall(3)调用避免的故障注入(我们的用例)
- 我们感觉恶意,想要编写一个难以从用户空间(甚至通过一些技巧在内核空间)移除的rootkit
为什么需要故障注入?
故障注入能够发现模糊测试和传统单元测试通常无法发现的错误:
- 由假设特定函数永远不会失败导致的NULL解引用
- 由意外小的缓冲区导致的内存损坏,或由意外大的缓冲区导致的信息泄露
- 由无效或意外值导致的整数上溢/下溢
开始:找到系统调用表
在内部,Linux内核将系统调用存储在系统调用表中,这是一个包含__NR_syscalls个指针的数组。这个表被定义为sys_call_table,但自Linux 2.5以来就没有直接作为符号暴露给内核模块。
首先,我们需要获取系统调用表的地址,最好不使用System.map文件或扫描内核内存中的已知地址。幸运的是,Linux提供了一个比这两种方法都优越的接口:kallsyms_lookup_name。
这使得检索系统调用表变得简单:
1
2
3
4
5
6
7
8
9
10
11
12
|
static unsigned long *sys_call_table;
int init_module(void) {
sys_call_table = (void *)kallsyms_lookup_name("sys_call_table");
if (sys_call_table == NULL) {
printk(KERN_ERR "Couldn't look up sys_call_table\n");
return -1;
}
return 0;
}
|
注入我们的替换系统调用
现在我们有了内核的系统调用表,注入我们的替换应该很简单:
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
|
static unsigned long *sys_call_table;
static typeof(sys_read) *orig_read;
asmlinkage long phony_read(int fd, char __user *buf, size_t count) {
printk(KERN_INFO "Intercepted read of fd=%d, %lu bytes\n", fd, count);
return orig_read(fd, buf, count);
}
int init_module(void) {
sys_call_table = (void *)kallsyms_lookup_name("sys_call_table");
if (sys_call_table == NULL) {
printk(KERN_ERR "Couldn't look up sys_call_table\n");
return -1;
}
orig_read = (typeof(sys_read) *)sys_call_table[__NR_read];
sys_call_table[__NR_read] = (void *)phony_read;
return 0;
}
void cleanup_module(void) {
sys_call_table[__NR_read] = (void *)orig_read;
}
|
但这并不那么容易,至少在x86上不是:sys_call_table受到CPU本身的写保护。尝试修改它会导致页面错误(#PF)异常。为了解决这个问题,我们需要调整cr0寄存器的第16位,它控制写保护状态:
1
2
3
4
5
6
|
#define CR0_WRITE_UNLOCK(x) \
do { \
write_cr0(read_cr0() (~X86_CR0_WP)); \
x; \
write_cr0(read_cr0() | X86_CR0_WP); \
} while (0)
|
然后,我们的插入操作就变成了:
1
2
3
|
CR0_WRITE_UNLOCK({
sys_call_table[__NR_read] = (void *)phony_read;
});
|
接下来是什么?
上面的phony_read只是包装了真正的sys_read并添加了一个printk,但我们也可以很容易地让它注入故障:
1
2
3
|
asmlinkage long phony_read(int fd, char __user *buf, size_t count) {
return -ENOSYS;
}
|
或者为特定用户注入故障:
1
2
3
4
5
6
7
|
asmlinkage long phony_read(int fd, char __user *buf, size_t count) {
if (current_uid().val == 1005) {
return -ENOSYS;
} else {
return orig_read(fd, buf, count);
}
}
|
或者返回虚假数据:
1
2
3
4
5
6
|
asmlinkage long phony_read(int fd, char __user *buf, size_t count) {
unsigned char kbuf[1024];
memset(kbuf, 'A', sizeof(kbuf));
copy_to_user(buf, kbuf, sizeof(kbuf));
return sizeof(kbuf);
}
|
总结
这篇文章涵盖了内核空间系统调用拦截的基础知识。要做任何真正有趣的事情(如精确的故障注入或超出官方内省API提供的统计数据),你需要阅读一个好的内核模块编程指南并自己完成相关工作。
我们的新工具KRF做了上面提到的所有事情甚至更多:它可以以每个可执行文件的精度拦截和故障系统调用,操作整个系统调用"配置文件"(例如,所有涉及文件系统或执行进程调度的系统调用),并且可以轻松地进行实时故障注入。