3.1 拦截的方法
Rootkit必须在操作系统的特定位置拦截控制,以防止antirootkit工具启动或初始化。在标准的操作系统机制和未注册的机制中,这些拦截点是大量存在的。拦截方法的一些例子有:修改关键函数中的代码,改变内核及其驱动程序的各种数据结构中的指针,以及使用直接内核对象操作(DKOM)等技术操作数据。
为了给这个看似无穷无尽的列表添加一些顺序,我们将考虑Rootkit可以拦截的三种主要操作系统机制:系统事件、系统调用和对象分派器。
3.1.1 拦截系统事件
获得控制权的第一种方法是通过事件通知回调来拦截系统事件,事件通知回调是用于处理各种类型的系统事件的文档化操作系统接口。合法的驱动程序需要通过加载可执行的二进制文件以及创建和修改注册表项来响应新进程或数据流的创建。为了防止驱动程序程序员创建脆弱的、未注册的钩子解决方案,微软提供了标准化的事件通知机制。恶意软件编写者使用相同的机制,用自己的代码对系统事件做出反应,而不考虑合法的响应。
例如,内核模式驱动程序的CmRegisterCallbackEx
例程注册了一个回调函数,每当有人在系统注册表项上执行操作(例如创建、修改或删除注册表项)时,该例程将执行。通过滥用这个功能,恶意软件可以拦截所有对系统注册表的请求,检查它们,然后拦截它们或允许它们通过。
这允许Rootkit保护其内核模式驱动程序对应的任何注册表项—通过对安全软件隐藏它并阻止任何试图删除它的行为。
在系统注册表中注册内核模式驱动程序
在Windows中,每个内核模式驱动程序在系统注册表中都有一个专用条目,位于HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services键下。该条目指定驱动程序的名称、驱动程序类型、驱动程序映像在磁盘上的位置以及应该加载驱动程序的时间(按需加载、在引导时加载、在系统初始化时加载等)。如果这个条目被删除,操作系统将无法加载内核模式驱动程序。为了在目标系统上保持持久性,内核模式的Rootkit通常会保护它们对应的注册表项不被安全软件删除。
另一个恶意系统事件拦截滥用内核模式驱动程序的PsSetLoadImageNotifyRoutine
例程。这个例程注册回调函数ImageNotifyRoutine
,当可执行映像映射到内存时,就执行这个函数。回调函数接收关于被加载的映像的信息,即映像的名称和基地址,以及将映像加载到其地址空间的进程的标识符。
Rootkit经常滥用PsSetLoadImageNotifyRoutine
例程,向目标进程的用户模式地址注入恶意负载。通过注册回调例程,Rootkit将在映像加载操作发生时得到通知,并可以检查传递给ImageNotifyRoutine
的信息,以确定是否对目标进程感兴趣。例如,如果Rootkit希望仅将用户模式有效负载注入Web浏览器,那么它可以检查正在加载的映像是否与浏览器应用程序相对应,并相应地采取行动。
内核提供的其他接口也具有类似功能,我们将在接下来的章节中讨论它们。
3.1.2 拦截系统调用
第二种感染方法涉及拦截另一个关键的操作系统机制—系统调用,这是用户程序与内核交互的主要方式。由于实际上任何用户API调用都会生成一个或多个相应的系统调用,因此能够调度系统调用的Rootkit可以获得对系统的完全控制。
下面我们将以拦截文件系统调用的方法为例,这对于必须始终隐藏自己的文件以防止意外访问它们的Rootkit尤其重要。当安全软件或用户扫描文件系统寻找可疑或恶意文件时,系统发出系统调用,告诉文件系统驱动程序查询文件和目录。通过拦截这样的系统调用,Rootkit可以操作返回数据,并从查询结果中排除有关其恶意文件的信息(正如我们在2.2.7节中介绍的)。
为了理解如何抵消这些滥用并保护Rootkit的文件系统调用,首先我们需要简要地研究一下文件子系统的结构。它是一个完美的例子,说明了如何将操作系统内核内部划分为许多专门的层,并遵循这些层之间的许多交互约定—这些概念对大多数系统开发人员来说甚至都是不透明的,但对Rootkit的编写者来说就不是这样了。
文件子系统
Windows文件子系统与它的I/O子系统紧密集成。这些子系统是模块化和层次化的,独立的驱动程序负责其每一层的功能。驱动程序主要有三种类型。
存储设备驱动程序是与特定设备(如端口、总线和驱动器)的控制器交互的低层驱动程序。大多数存储设备驱动程序是即插即用(PnP)的,由PnP管理器加载和控制。
存储卷驱动程序是控制存储设备分区上的卷抽象的中层驱动程序。为了与磁盘子系统的较低层交互,这些驱动程序创建一个物理设备对象(PDO)来表示每个分区。当一个文件系统挂载在一个分区上时,文件系统驱动程序创建一个卷设备对象(VDO),它向更高层的文件系统驱动程序表示这个分区,后文中将进行解释。
文件系统驱动程序实现特定的文件系统,如FAT32、NTFS、CDFS等,还创建一对对象—VDO和CDO(控制设备对象),它们表示给定的文件系统(与底层分区相对)。这些CDO设备的名称为\Device\Ntfs。
注意 要了解不同类型的驱动程序的更多信息,请参考Windows文档(https://docs.microsoft.com/en-us/windows-hardware/drivers/ifs/storage-device-stacks--storage-volumes--and-file-system-stacks/)。
图3-1以SCSI磁盘设备为例展示了这个设备对象层次结构的简化版本。
图3-1 存储设备驱动程序栈示例
在存储设备驱动程序层,我们可以看到SCSI适配器和磁盘设备对象。这些设备对象由三个不同的驱动程序创建和管理:PCI总线驱动程序,枚举(发现)PCI总线上可用的存储适配器;SCSI端口/微型端口驱动程序,初始化和控制枚举的SCSI存储适配器;磁盘类驱动程序,控制附加到SCSI存储适配器的磁盘设备。
在存储卷驱动程序层,我们可以看到分区0和分区1,它们也是由磁盘类驱动程序创建的。分区0表示整个原始磁盘,并且始终存在,无论磁盘是否分区。分区1表示磁盘设备上的第一个分区。我们的示例只有一个分区,因此我们只显示分区0和分区1。
分区1必须公开给用户,以便他们能够存储和访问存储在磁盘设备上的文件。要公开分区1,文件系统驱动程序在存储栈文件系统驱动程序层的顶部创建一个VDO。请注意,在VDO的顶部或设备栈中的设备对象之间可能还有可选的存储过滤设备对象,为简单起见,我们在图3-1中省略了这些设备对象。我们还可以在图3-1的右上方看到一个文件系统CDO,操作系统使用它来控制文件系统驱动程序。
图3-1还展示了存储驱动程序栈的复杂性如何为Rootkit提供了拦截文件系统操作和修改、隐藏数据的机会。
3.1.3 拦截文件操作
Rootkit在顶层(即文件系统驱动程序层)拦截文件操作要比在更低层容易得多。这样,Rootkit就可以在应用程序的程序员级别看到所有这些操作,而不必查找和解析程序员看不见的文件系统结构,这些文件系统结构对应于传递给低层驱动程序的输入/输出请求包(IRP)。
如果Rootkit在较低的层拦截操作,那么它必须重新实现Windows文件系统的部分,这是一项复杂且容易出错的任务。然而,这并不意味着不存在较低层的驱动程序拦截:逐扇区的磁盘映射仍然相对容易获得,并且阻塞或转移扇区操作在微型端口驱动程序级别也是可行的,正如TDL3所展示的。
不管Rootkit在什么级别拦截存储IO,都有三种主要的拦截方法:
- 将过滤的驱动程序附加到目标设备的驱动程序栈。
- 替换驱动程序描述符结构中指向IRP或FastIO处理函数的指针。
- 替换这些IRP或FastIO驱动程序函数的代码。
FastIO
为了执行输入/输出操作,IRP要遍历整个存储设备栈,从最顶层的设备对象一直到底层。FastIO是一种可选方法,用于对缓存的文件执行快速同步输入/输出操作。在FastIO操作中,数据直接在用户模式缓冲区和系统缓存之间传输,绕过文件系统和存储驱动程序栈。这使得对缓存文件的I/O操作更快。
在第2章中,我们讨论了Festi的Rootkit,它使用了拦截方法1:Festi在文件系统驱动程序层的存储驱动程序栈顶附加了一个恶意的过滤设备对象。
在本书的后面,我们将讨论TDL4(第7章)、Olmasco(第10章)和Rovnix(第11章)Bootkit,它们都使用方法2:它们在尽可能低的级别(存储设备驱动程序层)拦截磁盘输入/输出操作。我们将在第12章中看到Gapz Bootkit使用方法3,同样是在存储设备驱动层。你可以参考这些章节来了解更多关于每个方法的实现细节。
对Windows文件系统的简要回顾表明,基于这个系统的复杂性,Rootkit在这个驱动程序栈中有丰富的选择目标。Rootkit可以在这个栈的任何层拦截控制,甚至可以同时在多个层拦截控制。一个antirootkit程序需要处理所有这些可能—例如,通过安排自己的拦截或者检查注册的回调是否合法。这显然是一项艰巨的任务,但防御者至少必须了解相应驱动程序的调度链。
3.1.4 拦截对象调度器
我们将在本章中讨论的第三类拦截针对的是Windows对象调度器方法。对象调度器是管理操作系统资源的子系统,这些资源在所有现代Windows发行版基础上的Windows NT体系结构分支中都表示为内核对象。不同版本的Windows之间,对象调度器和相关数据结构的实现细节可能不同。本节主要针对Windows 7之前的Windows版本,但一般方法也适用于其他版本。
Rootkit控制对象调度器的一种方法是拦截组成调度器的Windows内核的Ob*
函数。然而,Rootkit很少这样做,原因和它们很少以顶级系统调用表条目为目标是一样的:这样的钩子太明显,太容易被检测到。在实践中,Rootkit使用更复杂的技巧来针对内核,我们将对此进行描述。
每个内核对象本质上都是一个内核模式的内存结构,它可以大致分为两部分:带有调度器元数据的头和对象主体(由创建和使用该对象的子系统根据需要填充)。头被布置为OBJECT_HEADER
结构,它包含一个指向对象类型描述符OBJECT_TYPE
的指针。后者也是一个结构,是对象的主要属性。与现代类型系统一样,表示类型的结构也是其主体包含适当类型信息的对象。该设计通过存储在头中的元数据实现对象继承。
然而,对于一个典型的程序员来说,这些类型系统的复杂性并不重要。大多数对象是通过系统服务处理的,系统服务通过其描述符(HANDLE
)引用每个对象,同时隐藏了对象分派和管理的内部逻辑。
也就是说,对象的类型描述符OBJECT_TYPE
中有一些字段是Rootkit感兴趣的,比如用于处理某些事件(例如,打开、关闭和删除对象)的例程指针。通过连接这些例程,Rootkit可以拦截、控制、操作或更改对象数据。
但是,系统中出现的所有类型都可以作为ObjectType目录中的对象在调度器名称空间中枚举。Rootkit可以通过两种方式将这些信息作为目标来实现拦截:直接替换指向处理程序函数的指针来指向Rootkit本身,或者替换对象头中的类型指针。
由于Windows调试器使用并信任这种元数据来检查内核对象,因此很难检测利用这种完全相同类型的系统元数据的Rootkit拦截。
要准确地检测劫持现有对象的类型元数据的Rootkit就更难了。由此产生的拦截粒度更细,因此更微妙。图3-2显示了这样一个Rootkit拦截的示例。
图3-2 通过ObjectType
操作挂载OpenProcedure
处理程序
在图3-2的顶部,我们可以看到对象被Rootkit拦截之前的状态:对象的头和类型描述符是原始的,没有修改。在图3-2的底部,我们可以看到Rootkit修改了对象的类型描述符后的状态。Rootkit获取一个指向表示存储设备的对象的指针,比如\Device\Harddisk0\DR0。然后,它为该设备创建自己的OBJECT_TYPE
结构副本❷。在副本内部,它更改了指向感兴趣的处理程序的函数指针(在我们的示例中,它是OpenProcedure
处理程序),以便它指向Rootkit自己的处理程序函数❸。然后,指向这个“邪恶的双胞胎”结构的指针将替换原始设备描述符中的类型指针❶。现在,受感染的磁盘的行为(如其元数据所描述的)几乎与未受损害的磁盘对象的行为相同—除了已替换的处理程序之外(仅针对该对象实例)。
注意,描述所有其他同类磁盘对象的合法结构仍然保持原始状态。已更改的元数据只出现在一个由目标对象指向的副本中。要查找和识别这种差异,检测算法必须枚举所有磁盘对象实例的类型字段。系统地发现这样的差异是一项艰巨的任务,需要完全理解对象子系统抽象是如何实现的。