Rootkit 核心技术——利用 nt!_MDL(内存描述符链表)突破 SSDT(系统服务描述符表)的只读访问限制 Part I
————————————————————————————————————————————————————————
在 rootkit 与恶意软件开发中有一项基本需求,那就是 hook Windows 内核的系统服务描述符表(下称 SSDT),把该表中的
特定系统服务函数替换成我们自己实现的恶意例程;当然,为了确保系统能够正常运作,我们需要事先用一个函数指针保存原始
的系统服务,并且在我们恶意例程的逻辑中调用这个函数指针,此后才能进行 hook,否则损坏的内核代码与数据结构将导致
一个 BugCheck(俗称的蓝屏)。
尽管 64 位 Windows 引入了像是 PatchGuard 的技术,实时监控关键的内核数据,包括但不限于 SSDT,IDT,GDT。。。等等,
保证其完整性,但在 32 系统上修改 SSDT 是经常会遇到的场景,所以本文还是对此做出了介绍。
OS 一般在系统初始化阶段把 SSDT 设定成只读访问,这也是为了避免驱动与其它内核组件无意间改动到它;所以我们的首要任务
就是设法绕过这个只读属性。
在此之前,先复习一下与 SSDT 相关的几个数据结构,并解释定位 SSDT 的过程。
我们知道,每个线程的 _KTHREAD 结构中,偏移 0xbc 字节处是一枚叫做 ServiceTable 的泛型指针(亦即 PVOID 或 void*),
该字段指向一个全局的数据结构,叫做 KeServiceDescriptorTable,它就是 SSDT,SSDT 中首个字段又是一枚指针,指向
全局的数据结构 KiServiceTable,而后者是一个数组,其内的每个成员都是一枚函数指针,持有相应的系统服务例程入口地址。
有的时候,用言语来描述内核的一些概念过于抽象和词穷,还是来看看下图吧,它很形象地展示了上述关系:
根据上图我们有了思路:首先设法获取当前运行线程的 _KTHREAD 结构,然后即可逐步定位到 KiServiceTable,它就是我们最终
hook 的对象!
鉴于 ServiceTable 是一枚指针,持有另一枚指针 KeServiceDescriptorTable 的地址
(亦即“指向指针的指针”,往后我会不加以区分“持有”与“指向”术语),而 KiServiceTable 则是一个函数指针数组;
在 Rootkit 源码中,它们可以分别用三个全局变量(在驱动的入口点 DriverEntry() 之外声明 )表示,如下图,我使用了
“自注释”的变量名,很易于理解;而且我把星号紧接类型保留字后面,避免与“解引”操作混淆(所以星号是一个重载的运算
符):
————————————————————————————————————————————————————————
对于内核模式驱动程序开发人员来讲,自己实现一个例程来获取当前运行线程的 _KTHREAD 结构显然并不轻松,幸运的是,文档
化的 PsGetCurrentThread() 例程能够完成这一任务。
(事实上,PsGetCurrentThread()的反汇编代码恰恰说明了这很简单,如下代码,仅仅
只是把 fs:[00000124h] 地址处的内容移动到 eax 寄存器作为返回值,而且 KeGetCurrentThread() 的逻辑与它如出一撤! )
1 kd> u PsGetCurrentThread 2 3 nt!PsGetCurrentThread: 4 83c6cd19 64a124010000 mov eax,dword ptr fs:[00000124h] 5 83c6cd1f c3 ret 6 83c6cd20 90 nop 7 83c6cd21 90 nop 8 83c6cd22 90 nop 9 83c6cd23 90 nop 10 83c6cd24 90 nop 11 nt!KeReadStateMutant: 12 83c6cd25 8bff mov edi,edi 13 14 15 kd> u KeGetCurrentThread 16 17 nt!PsGetCurrentThread: 18 83c6cd19 64a124010000 mov eax,dword ptr fs:[00000124h] 19 83c6cd1f c3 ret 20 83c6cd20 90 nop 21 83c6cd21 90 nop 22 83c6cd22 90 nop 23 83c6cd23 90 nop 24 83c6cd24 90 nop
老生常谈,fs 寄存器通常用来存放“段选择符”,“段选择符”用来索引 GDT 中的一个“段描述符”,后者有一个“段基址”
属性,也就是 KPCR(Kernel Processor Control Region,内核处理器控制区域)结构(nt!_KPCR)的起始地址;nt!_KPCR
偏移 0x120 字节处是一个 nt!_KPRCB 结构,后者偏移 0x4 字节处的“CurrentThread”字段就是一个 _KTHREAD 结构,每次
线程切换都会更新该字段,这就是 fs:[00000124h] 简洁的背后隐藏的强大设计思想!
注意,PsGetCurrentThread() 返回一枚指向 _ETHREAD 结构的指针(亦即“PETHREAD”,如你所见,微软喜欢在指针这一概念
上大玩“头文字 P”游戏),而 _ETHREAD 结构的首个字段 Tcb 就是一个 _KTHREAD 实例——这意味着,我们无需计算额外的
偏移量,只要考虑那个 ServiceTable 的偏移量 0xbc 即可,如下图:
而我们需要在这枚指针上执行加法运算,移动它到 ServiceTable 字段处,所以不能声明一个 PETHREAD 变量来存储
PsGetCurrentThread() 的返回值,因为“指针加上数值 n ”会把指针当前持有的地址加上( n * 该指针所指的数据类型大小 )个
字节—— 表达式
1 PETHREAD ethread_ptr += 0xbc;
实际上把起始地址加上了 0xbc * sizeof(ETHREAD) 个字节,远远超出了我们的预期。。。。
怎么办呢?好办,声明一个字节型指针来保存 PsGetCurrentThread() 的返回值,同时把返回值强制转型为一致的即可!
如此一来,表达式
1 BYTE* byte_ptr += 0xbc;
就是把起始地址加上 0xbc * sizeof(BYTE) 个字节,符合我们的预期。
注意,这要求我们添加相关的类型定义,如下图:
这表明 BYTE 与 无符号字符型等价(还等于微软自家的 UCHAR),大小都是单字节;DWORD 则与无符号长整型等价,大小都是
四字节——我们用一个 DWORD 变量存储数组 KiServiceTable 的地址。
————————————————————————————————————————————————————————
接下来就是通过一系列的指针转型和解引操作,定位到 KiServiceTable 的过程,再次凸显了指针在 C 编程中的地位,无论是应用
程序还是内核。。。。。经过如下图的赋值运算,最终,全局变量 os_ki_service_table 持有了 KiServiceTable 的地址。注意,除
了那个偏移量的宏定义外,所有的运算都在我们的驱动入口例程 DriverEntry() 中完成,而且为了支持动态卸载,我注册了
Unload() 回调,稍后你会看到 Unload() 的内部实现——大致就是卸载时取消对 KiServiceTable 的写权限映射。
————————————————————————————————————————————————————————————————————————————————
为了验证定位 KiServiceTable 过程的准确性,我添加了下列打印输出语句,注意,DbgPrint() 的输出需要在被调试机器上以
DbgView.exe 查看;抑或直接输出到调试机器上的 windbg.exe/kd.exe 屏幕上:
——————————————————————————————————————————————————————————————————————————————
结合上图,在调试器中进行验证——“dd”命令可以按双字(四字节)显示给定虚拟内存地址处的内容;“dps”命令可以按照函
数符号显示从给定内存地址开始的例程地址——它就是专为函数指针数组(例如 KiServiceTable)设计的,如下图:
——————————————————————————————————————————————————————————
现在,KiServiceTable 可以经由全局变量 os_ki_service_table 以只读形式访问,在我们 hook 它之前,需要设法更改为可写。
先来看看尝试向只读的 KiServiceTable 写入时会发生什么事情,如下图所示,我通过 RtlFillMemory() 试图向 KiServiceTable
持有的第一个四字节(亦即系统服务 nt!NtAcceptConnectPort )填充 4 个 ASCII 字符“A”:
————————————————————————————————————————————————————————————
注意,RtlFillMemory() 的第一个参数是一个指针,指向要被填充的内存块,后面二个参数分别是填充的长度与数据;由于我们的
变量 os_ki_service_table 是 DWORD 型,所以我把它强制转型为匹配的指针,再作为实参传入。。。。重新构建驱动,
放入以调试模式运行的虚拟机中加载,宿主机中发生的情况如下图所示,假设我们编译好的 rootkit 名称为
UseMdlMappingSSDT.sys ,
图中表明出现一个致命系统错误,代码为 0x000000BE,圆括号里边是携带错误信息的四个参数,在故障排查时会用到它们。
事实上,这就是一个 BugCheck,当错误检查发生时,如果目标系统连接着宿主机上的调试器,就断入调试器,否则目标系统
上将执行 KeBugCheckEx() 例程,后者会屏蔽掉所有处理器核上的中断事件,然后将显示器切换到低分辩率的 VGA 图形模式下,
绘制一个蓝色背景,然后向用户显示 “检查结果” 对应的停机代码。这就是“蓝屏”的由来。
——————————————————————————————————————————————————————————
在此场景中,我们得到一个 0x000000BE 的停机代码,将其作为关键字串搜索 MSDN 文档,给出的描述如下图:
————————————————————————————————————————————————————————
官方讲解的很清楚:0x000000BE(ATTEMPTED_WRITE_TO_READONLY_MEMORY)停机代码是由于驱动程序尝试向一个只读
的内存段写入导致的;第一个参数是试图写入的虚拟地址,第二个参数是描述该虚拟地址所在虚拟页-物理页的 PTE(页表项)
内容;后面两个参数为保留未来扩展使用,所以被我截断了。结合前面一张图我们知道,尝试写入的虚拟地址为
0x83CAFF7C,描述映射它的物理页的 PTE 内容是 0x03CAF121,后面两个参数就目前而言可以忽略。
如下图所示,0x83CAFF7C 就是 KiServiceTable 的起始地址;描述它的 PTE 经解码后的标志部分有一个“R”属性,表示
只读;BugCheck 时刻的栈回溯信息显示,内核中通用的异常处理程序 MmAccessFault() 负责处理与内存访问相关的错误,
它是一个前端解析例程,如果异常或错误能够处理,它就分发至实际的处理函数,否则,它调用 KeBugCheck*() 系列函数,
该家族函数会根据调试器的存在与否作出决定——要么调用 KiBugCheckDebugBreak() 断入调试器;要么执行如前文所述的操作
流程来绘制蓝屏:
————————————————————————————————————————————————————————————
至此确定了 BugCheck 是由于在驱动中调用 RtlFillMemory() 写入只读的内核内存引发的。另一个更强大的调试器扩展命令
“!analyze -v”可以输出详细的信息,包括 BugCheck “现场”的指令地址和寄存器状态,如下图所示,导致 BugCheck 的
指令地址为 0x9ff990b4,该指令把 eax 寄存器的当前值(0x41414141,亦即我们调用 RtlFillMemory() 传入的 4 个 ASCII 字
符“A”)写入 ecx 寄存器持有的内存地址处,试图把 nt!NtAcceptConnectPort() 的入口点地址替换成 0x41414141 ;另外它会
给出驱动源码中对应的行号——也就是第 137 行的 RtlFillMemory() 调用:
——————————————————————————————————————————————————————————
如你所见,微软 C/C++ 编译器(cl.exe)把 RtlFillMemory() 内联在它的调用者内部,换言之,尽管有公开的文档描述它的
返回值,参数。。。。具体的实现还是由编译器说了算——为了性能优化,RtlFillMemory() 直接实现为一条简洁的数据移动
指令,相关的参数由寄存器传递,没有因函数调用创建与销毁栈帧带来的额外开销!
到目前为止,尽管我们通过一系列步骤从 _KTHREAD 定位到了系统服务指针表,但以常规手段却无法 hook 其中的系统服务函
数,因为它是只读的。
下一篇文章我将讨论如何使用 MDL(Memory Descriptor List,内存描述符链表)来绕过这种限制,随心所欲地读写
KiServiceTable!