系统调用故障注入框架的基本原理

系统调用故障注入框架的基本原理

1.初期思考

重新梳理总结实习期间的关于KRF的相关工作,参考了trailofbits作者的设计理念。

我们将从零到一的带你体会一个系统调用故障注入框架的设计过程,首先你可能会问我们的故障注入框架与其他故障注入策略的不同之处?

其他的故障注入工具依赖于一些不同的技术:

  • 有一种著名的 LD_PRELOAD 技巧,可以真正拦截 libc(或你选择的语言运行时)暴露的系统调用 wrapper。这种方法通常很有效(对于在程序中欺骗系统时间或透明地使用 SOCKS 代理等情况非常有用),但也有一些主要缺点:
    • LD_PRELOAD 仅在 libc(或所选目标库)已被动态链接的情况下才会起作用,但较新的语言(如 Go)和部署趋势(如完全静态构建和非 glibc Linux 容器)已使动态链接变得不那么流行。
    • 系统调用 wrapper 经常与其底层系统调用有很大偏差:根据你的 Linux 和 glibc 版本,open() 可能调用 openat(2),fork() 可能调用 clone(2),其他调用可能修改其标志或默认行为以符合 POSIX 标准。因此,很难可靠地预测某个系统调用wrapper 是否会调用与其同名的系统调用。
  • DynamoRIO 或 Intel PIN 等动态指令框架可用于识别函数或机器代码级别的系统调用,并对其调用和/或返回进行指令化。虽然这样可以对单个调用进行细粒度访问,但通常会带来大量运行时开销

在内核空间中注入故障可以避免这两种方法的缺点:它直接重写实际的系统调用,而不是依赖动态加载器,而且几乎不增加运行时开销(除了检查给定的系统调用是否是我们想要故障的调用)

那么,可能又有人问了,我们的系统调用拦截策略与别人的有什么不同吗?

其他博文也提到了拦截系统调用的问题,但很多博文都是:

  • 通过解析内核的 System.map 获取系统调用表,这种方法可能不可靠(而且比下面的方法慢)。
  • 假设内核导出了 sys_call_table,并且 extern void *sys_call_table 可以工作(在 Linux 2.6 以上版本中并非如此)。
  • 需要探查大范围的内核内存,速度很慢,而且可能很危险

那么,我们开发的系统调用故障注入框架使用的架构是什么?

对于非 x86 平台,你需要对 write-unlocking 宏做一些调整。

2.系统调用回顾

系统调用是将内核管理的某些资源(I/O、进程控制、网络、外设)暴露给用户空间进程的函数。任何程序接收用户输入、与其他程序通信、更改磁盘上的文件、使用系统时间或通过网络联系其他设备,通常都是通过系统调用实现的。

UNIX 的核心系统调用相当原始:open(2)、close(2)、read(2) 和 write(2) 用于绝大多数 I/O;fork(2)、kill(2)、signal(2)、exit(2) 和 wait(2) 用于进程管理等等。套接字管理系统调用大多是在 UNIX 模型上附加的:send(2) 和 recv(2) 的行为与 read(2) 和 write(2) 非常相似,但增加了额外的传输标志。

详细的系统调用表查询:Searchable Linux Syscall Table for x86 and x86_64

image-20240212114413212

与用户空间中的普通函数调用不同,系统调用的代价异常昂贵:在 x86 架构上,int 80h(或更现代的 sysenter/syscall 指令)会导致 CPU 和内核执行缓慢的中断处理代码路径,并执行权限上下文切换。

拦截系统调用目的:

  • 我们对收集特定系统调用使用情况的统计数据很感兴趣,这超出了 eBPF 或其他工具 API 可以(轻松)提供的数据。
  • 我们感兴趣的是静态链接或手动调用 syscall(3)(我们的用例)无法避免的故障注入。
  • 我们觉得自己很邪恶,想编写一个很难从用户空间删除的 rootkit(如果用一些小技巧,甚至可能删除内核空间的 rootkit)。

故障注入的目的:

故障注入能发现模糊测试和传统单元测试通常无法发现的漏洞:

  • 假设特定函数永远不会失败而导致的 NULL 解引用(你确定你总是检查 getcwd(2) 是否成功?)
  • 缓冲区意外变小导致内存损坏,或缓冲区意外变大导致内存泄露
  • 由无效或意外值引起的整数溢出/下溢(您确定没有对 stat(2) 的 atime/mtime/ctime 字段做出不正确的假设?)

3.寻找系统调用表

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
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;
}

当然,这只有在编译 Linux 内核时使用 CONFIG_KALLSYMS=1 时才有效。Debian 和 Ubuntu 提供了这一功能,但你可能需要在其他发行版中进行测试。如果你的发行版默认情况下没有启用 kallsyms,可以考虑使用默认情况下启用 kallsyms 的虚拟机

4.注入替换系统调用

现在我们有了内核的系统调用表,注入替换程序应该很容易:

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
26
27
28
29
30
31
static unsigned long *sys_call_table;
static typeof(sys_read) *orig_read;

/* asmlinkage is important here -- the kernel expects syscall parameters to be
* on the stack at this point, not inside registers.
*/
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) {
/* Don't forget to fix the syscall table on module unload, or you'll be in
* for a nasty surprise!
*/
sys_call_table[__NR_read] = (void *)orig_read;
}

但这并不容易,至少在 x86 系统上不是:sys_call_table 受 CPU 自身的写保护。试图修改它将导致page fault异常。为了解决这个问题,我们需要修改控制写保护状态的 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
4
5
6
7
CR0_WRITE_UNLOCK({
sys_call_table[__NR_read] = (void *)phony_read;
});

CR0_WRITE_UNLOCK({
sys_call_table[__NR_read] = (void *)orig_read;
});

我们假设使用的是单处理器在,我们操作 cr0 的过程中存在一个与 SMP 相关的条件竞争错误。如果我们的内核任务在禁用写保护后立即被抢占,并被放置到另一个仍启用写保护的内核上,我们就会出现page fault,而不是成功写入内存。虽然发生这种情况的几率很小,但在关键部分实施保护措施也不失为一个好办法。

1
2
3
4
5
6
7
8
9
10
11
12
13
#define CR0_WRITE_UNLOCK(x) \
do { \
unsigned long __cr0; \
preempt_disable(); \
__cr0 = read_cr0() & (~X86_CR0_WP); \
BUG_ON(unlikely((__cr0 & X86_CR0_WP))); \
write_cr0(__cr0); \
x; \
__cr0 = read_cr0() | X86_CR0_WP; \
BUG_ON(unlikely(!(__cr0 & X86_CR0_WP))); \
write_cr0(__cr0); \
preempt_enable(); \
} while (0)

5.升级故障注入框架

上面的 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
7
8
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);
}

系统调用是在内核的任务上下文中进行的,这意味着当前的 task_struct 是有效的。窥探内核结构的机会比比皆是!