[聚合文章] Rootkit 核心技术——利用 nt!_MDL(内存描述符链表)突破 SSDT(系统服务描述符表)的只读访问限制...

SQL Server 2018-01-26 17 阅读
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              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!

 

注:本文内容来自互联网,旨在为开发者提供分享、交流的平台。如有涉及文章版权等事宜,请你联系站长进行处理。