————————————————————————————————————————————————————————
在 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 nop10 83c6cd24 90 nop11 nt!KeReadStateMutant:12 83c6cd25 8bff mov edi,edi13 14 15 kd> u KeGetCurrentThread16 17 nt!PsGetCurrentThread:18 83c6cd19 64a124010000 mov eax,dword ptr fs:[00000124h]19 83c6cd1f c3 ret20 83c6cd20 90 nop21 83c6cd21 90 nop22 83c6cd22 90 nop23 83c6cd23 90 nop24 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!
注:本文内容来自互联网,旨在为开发者提供分享、交流的平台。如有涉及文章版权等事宜,请你联系站长进行处理。