无需费力编写Rootkit:Linux内核系统调用拦截实战

本文详细介绍了如何在Linux内核中通过内核模块拦截系统调用,包括查找系统调用表、绕过CPU写保护机制,并实现故障注入功能。适用于安全研究和漏洞挖掘场景。

如何无需费力编写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

为什么需要故障注入?

故障注入能在模糊测试和传统单元测试通常找不到的地方发现bug:

  • 由假设特定函数永远不会失败导致的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;
}

当然,这只有在你的Linux内核编译时启用了CONFIG_KALLSYMS=1时才有效。Debian和Ubuntu提供了这个选项,但在其他发行版中可能需要测试。

注入我们的替换系统调用

现在我们有了内核的系统调用表,注入我们的替换应该很简单:

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

和:

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

一切都能按预期工作…几乎。

我们假设了单处理器;在我们调整cr0的方式中存在一个SMP相关的竞态条件bug。如果我们的内核任务在禁用写保护后立即被抢占,并被放置到另一个WP仍然启用的核心上,我们会得到一个页面错误而不是成功的内存写入。这种情况发生的几率很小,但通过在关键部分实现保护来小心处理是没有坏处的:

 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)

(敏锐的人会注意到这与PaX/grsecurity的"rare write"机制几乎相同。这不是巧合:它基于此!)

下一步是什么?

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

系统调用在内核中的任务上下文中发生,意味着当前的task_struct是有效的。探索内核结构的机会很多!

总结

这篇文章涵盖了内核空间系统调用拦截的基础知识。要做任何真正有趣的事情(比如精确的故障注入或超出官方内省API提供的统计数据),你需要阅读一个好的内核模块编程指南并自己完成相关工作。

我们的新工具KRF做了以上所有事情甚至更多:它可以以每个可执行文件的精度拦截和故障系统调用,操作整个系统调用"配置文件"(例如,所有涉及文件系统或执行进程调度的系统调用),并且可以轻松地实时故障。哦,静态链接对它一点影响都没有:如果你的程序进行任何系统调用,KRF会很乐意地对它们进行故障注入。

其他工作

除了用于内核空间拦截的kprobes和用于用户空间包装器拦截的LD_PRELOAD之外,还有一些其他巧妙的技巧:

  • syscall_intercept像普通的包装器拦截器一样通过LD_PRELOAD加载,但实际上在内部使用capstone来反汇编(g)libc并检测它进行的系统调用。这只适用于libc包装器进行的系统调用,但仍然很酷。

  • ptrace(2)可用于检测子进程进行的系统调用,全部在用户空间内完成。但它有两个相当大的缺点:它不能与调试器一起使用,并且在每个系统调用进入和退出时返回(PTRACE_GETREGS)架构特定的状态。它也很慢。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计