如何轻松编写Rootkit - Trail of Bits博客
我们开源了一个名为KRF的故障注入工具,它使用内核空间的系统调用拦截技术。您现在就可以用它来发现程序中的错误假设(以及由此产生的错误)。来看看吧!
这篇文章将介绍如何通过普通的内核模块在Linux内核中拦截系统调用。我们将快速回顾系统调用以及为什么要拦截它们,然后演示一个拦截read(2)系统调用的基础模块。
与其他故障注入策略有何不同?
其他故障注入工具依赖几种不同的技术:
-
众所周知的LD_PRELOAD技巧,它实际上拦截的是由libc(或您选择的语言运行时)暴露的系统调用包装器。这通常有效(并且对于例如在程序中欺骗系统时间或透明使用SOCKS代理非常有用),但有一些主要缺点:
- LD_PRELOAD仅在libc(或目标库)被动态链接时有效,但新语言(如Go)和部署趋势(完全静态构建和非glibc Linux容器)使动态链接变得不那么流行。
- 系统调用包装器经常与其底层系统调用有显著差异:根据您的Linux和glibc版本,open()可能调用openat(2),fork()可能调用clone(2),其他调用可能修改其标志或默认行为以符合POSIX标准。因此,很难可靠地预测给定的系统调用包装器是否会调用其同名的系统调用。
-
像DynamoRIO或Intel PIN这样的动态插桩框架可用于在函数或机器代码级别识别系统调用,并插桩它们的调用和/或返回。虽然这为我们提供了对单个调用的细粒度访问,但通常会带来大量的运行时开销。
在内核空间中注入故障可以避免这两种方法的缺点:它直接重写实际的系统调用,而不是依赖动态加载器,并且几乎不增加运行时开销(除了检查给定的系统调用是否是我们想要故障的)。
与其他关于系统调用拦截的博客文章有何不同?
其他博客文章也讨论了系统调用拦截,但许多:
- 通过解析内核的System.map获取系统调用表,这可能不可靠(并且比我们下面给出的方法慢)。
- 假设内核导出sys_call_table并且extern void *sys_call_table会起作用(在Linux 2.6+上不成立)。
- 涉及探测大范围的内核内存,这既慢又可能危险。
基本上,我们找不到一篇描述我们喜欢的系统调用拦截过程的近期(>2015)博客文章。所以我们开发了自己的方法。
为什么不直接使用eBPF或kprobes?
eBPF不能拦截系统调用。它只能记录它们的参数和返回类型。
kprobes API可能能够从内核模块中执行拦截,尽管我没有在网上找到关于它的非常好的信息来源。无论如何,这里的重点是我们自己动手!
这在$architecture上能工作吗?
大多数情况下是的。您需要对非x86平台的写解锁宏进行一些调整。
什么是系统调用?
系统调用(syscall)是一种函数,它将一些由内核管理的资源(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),但带有额外的传输标志。ioctl(2)是内核的垃圾场,重载为在文件描述符上执行每个可以想象的操作,其中没有更简单的方法存在。尽管使用上有这些额外的复杂性,但它们使用(和拦截)的基本原理仍然相同。如果您想深入了解,Filippo Valsorda为x86和x86_64维护了一个优秀的Linux系统调用参考。
与用户空间中的常规函数调用不同,系统调用非常昂贵:在x86架构上,int 80h(或更现代的sysenter/syscall指令)会导致CPU和内核执行缓慢的中断处理代码路径,并执行特权上下文切换。
为什么要拦截系统调用?
有几个不同的原因:
- 我们感兴趣收集关于特定系统调用使用情况的统计数据,超出eBPF或其他插桩API可以(轻松)提供的范围。
- 我们感兴趣的故障注入不能通过静态链接或手动syscall(3)调用来避免(我们的用例)。
- 我们感觉恶意,想编写一个难以从用户空间(甚至可能通过一些技巧在内核空间)移除的rootkit。
为什么需要故障注入?
故障注入会发现模糊测试和常规单元测试通常不会发现的错误:
- 由假设特定函数永远不会失败导致的NULL解引用(您确定总是检查getcwd(2)是否成功吗?)您确定比systemd做得更好吗?
- 由意外小的缓冲区导致的内存损坏,或由意外大的缓冲区导致的信息泄露
- 由无效或意外值导致的整数上溢/下溢(您确定没有对stat(2)的atime/mtime/ctime字段做出错误的假设吗?)
开始:找到系统调用表
在内部,Linux内核将系统调用存储在系统调用表中,这是一个包含__NR_syscalls指针的数组。这个表定义为sys_call_table,但自Linux 2.5以来没有直接作为符号暴露给内核模块。
首先,我们需要获取系统调用表的地址,最好不使用System.map文件或扫描内核内存中的已知地址。幸运的是,Linux提供了一个比这两种方法都更好的接口:kallsyms_lookup_name。
这使得获取系统调用表变得非常简单:
|
|
当然,这仅在您的Linux内核编译时启用了CONFIG_KALLSYMS=1时才有效。Debian和Ubuntu提供了这个,但您可能需要在其他发行版中测试。如果您的发行版默认不启用kallsyms,考虑使用启用了它的发行版的VM(您不会在主机上测试这段代码吧?)。
注入我们的替换系统调用
现在我们有了内核的系统调用表,注入我们的替换应该像这样简单:
|
|
…但在x86上并不是这么简单:sys_call_table被CPU本身写保护。尝试修改它将导致页面错误(#PF)异常。为了解决这个问题,我们调整cr0寄存器的第16位,它控制写保护状态:
|
|
然后,我们的插入就变成了:
|
|
和:
|
|
一切按预期工作…几乎。
我们假设的是单处理器;在我们调整cr0的方式中存在一个与SMP相关的竞态条件错误。如果我们的内核任务在禁用写保护后立即被抢占,并被放置到另一个WP仍启用的核心上,我们会得到一个页面错误而不是成功的内存写入。发生这种情况的几率相当小,但通过实现一个围绕关键部分的保护措施来小心谨慎并无害处:
|
|
(敏锐的人会注意到这与PaX/grsecurity的"rare write"机制几乎相同。这不是巧合:它基于它!)
接下来是什么?
上面的phony_read只是包装了真正的sys_read并添加了一个printk,但我们同样可以很容易地让它注入一个故障:
|
|
…或针对特定用户的故障:
|
|
…或返回虚假数据:
|
|
系统调用在内核中的任务上下文中发生,意味着当前task_struct是有效的。探索内核结构的机会比比皆是!
总结
这篇文章涵盖了内核空间系统调用拦截的基础知识。要做任何真正有趣的事情(如精确的故障注入或超出官方内省API提供的统计数据),您需要阅读一个好的内核模块编程指南,并自己完成工作。
我们的新工具KRF做了上面提到的所有事情甚至更多:它可以以每个可执行文件的精度拦截和故障系统调用,操作整个系统调用"配置文件"(例如,所有接触文件系统或执行进程调度的系统调用),并且可以实时故障而不会费力。哦,静态链接不会对它造成任何困扰:如果您的程序进行任何系统调用,KRF将很乐意对它们进行故障处理。
其他工作
除了用于内核空间拦截的kprobes和用于包装器用户空间拦截的LD_PRELOAD之外,还有一些其他聪明的技巧:
- syscall_intercept像普通的包装器拦截器一样通过LD_PRELOAD加载,但实际上在内部使用capstone来反汇编(g)libc并插桩它进行的系统调用。这只适用于由libc包装器进行的系统调用,但它仍然非常酷。
- ptrace(2)可用于插桩子进程进行的系统调用,全部在用户空间中完成。但它有两个相当大的缺点:它不能与调试器一起使用,并且在每个系统调用入口和退出时返回(PTRACE_GETREGS)架构特定的状态。它也很慢。Chris Wellons的精彩博客文章涵盖了ptrace(2)的许多能力。