———————————————————————————————————————————————————————————————————————————————————————————
本篇开始进入正题,因为涉及 MDL,所以相关的背景知识是必须的:
nt!_MDL 代表一个“内存描述符链表”结构,它描述着用户或内核模式虚拟内存(亦即缓冲区),其对应的那些物理页被锁定住,
无法换出。
因为一个虚拟的,地址上连续的用户或内核缓冲区可能映射到多个不连续的物理页,所以 nt!_MDL 定长(0x1c 字节)的头部后紧
跟数量可变的页框号(Page Frame Numbers),MDL 描述的每个物理页面都有一个页框号,
于是这些页框号引用的物理地址范围就对应了一片特定的用户或内核模式缓冲区。
通常虚拟和物理页的大小为 4 KB,KiServiceTable 中的系统服务数量为 401 个,每函数的入口点占用 4 字节,整张调用表大小
为 1.6 KB,通过 MDL 仅需要一张物理页即可描述这个缓冲区;在这种情况下,该 MDL 后只有一个页框号。
尽管 nt!_MDL 是半透明的结构,不过在内核调试器以及 WRK 源码面前还是被脱的一丝不挂,如下图为 WRK 源码
的“ntosdef.h”头文件中的定义,如你所见,称为“链表”乃因它的首个字段“Next”是一枚指针,指向后一个 nt!_MDL 结构。
对于我们 hook KiServiceTable 的场景而言,无需用到 Next 字段;那什么情况下会用到呢?
Windows 中某些类型的驱动程序,例如网络栈,它们支持 MDL 链,其内的多个 MDL 描述的那些缓冲区实际上是零散的,
假设栈中每个驱动都分配一个 MDL,其后跟着一些物理页框号来描述它们各自用到的虚拟缓冲区,那么这些缓冲区就通过
每个 _MDL 的 Next 字段(指向下一个 MDL)链接起来。
————————————————————————————————————————————————————————————
对于描述用户模式缓冲区的 MDL,其内的 Process 字段指向所属进程的 EPROCESS 结构,进程中的这块虚拟地址空间被 MDL 锁
住。
如果由 MDL 描述的缓冲区映射到内核虚拟地址空间中,_MDL 的 MappedSystemVa 字段指向内核模式缓冲区的基地址。
仅当 _MDL 的 MdlFlags 字段内设置了 MDL_MAPPED_TO_SYSTEM_VA 或 MDL_SOURCE_IS_NONPAGED_POOL 比特位,
MappedSystemVa 字段才有效。
_MDL 的 Size 字段含有 MDL 头部加上其后的整个 PFN 数组总大小。
上图还包含了 MdlFlags 字段的所有标志宏定义,这个 2 字节的字段可以是任意宏的组合,用于说明 MDL 的一些状态与属性。
MDL 的 StartVa 字段和 ByteOffset 字段共同定义了由该 MDL 锁定的原始缓冲区的起始地址。
(原始缓冲区可能会映射到其它内核缓冲区或用户缓冲区)
StartVa 指向虚拟页的起始地址,ByteOffset 包含实际从 StartVa 开始的缓冲区偏移量
MDL 的 ByteCount 字段描述由该 MDL 锁定的缓冲区大小(以字节为单位)
对于我们要 hook 的 KiServiceTable 而言, KiServiceTable 这片内核缓冲区所在的虚拟页起点由 StartVa 字段携带;
ByteOffset 字段则携带 KiServiceTable 的页内偏移量,ByteCount 字段携带 KiServiceTable 这片内核缓冲区的大小。
如果你现在看得云里雾里,不用担心,后面我们在调试时会把描述 KiServiceTable 的一个 nt!_MDL 结构实例拿出来分析,
到时候你就会恍然大悟这些字段的设计思想了。
————————————————————————————————————————————————————————————
通过编程方式使用 MDL 绕过 KiServiceTable 的只读属性,需要借助 Windows 执行体组件中的 I/O 管理器以及
内存管理器导出的一些函数,大致流程如下:
IoAllocateMdl() 分配一个 MDL 来描述 KiServiceTable -> MmProbeAndLockPages() 把该 MDL 描述的 KiServiceTable 所
属物理页锁定在内存中,并赋予对这张页面的读写访问权限(实际是将描述该页面的 PTE 内容中的“R”标志位修改成“W”)
->MmGetSystemAddressForMdlSafe() 将 KiServiceTable 映射到另一片内核虚拟地址区域(一般而言,位于 rootkit 被加载
到的内核地址范围内)。
如此一来,KiServiceTable 的原始虚拟地址与新映射的虚拟地址都转译到相同的物理地址,而且描述新虚拟地址的 PTE 内容标记了
写权限比特位,这样我们就能够通过修改这个新的虚拟地址中的系统服务例程实现安全挂钩 KiServiceTable,不会导致
BugCheck。
如下所示,我把上述涉及的所有操作都封装到一个自定义的函数 MapMdl() 里面。由于整个逻辑比较长,截图分为多张说明:
MapMdl() 在我们的 rootkit 入口点——DriverEntry() 中被调用,而在 DriverEntry() 外部声明几个与 MDL 相关的全局变量,
它们被 MapMdl() 与 DriverEntry() 共享。
注意,os_ki_service_table 存储着 KiServiceTable 的地址(参见前一篇定位 KiServiceTable 的代码),
把它从 DWORD 转换为泛型指针是为了符合 MapMdl() 中的 IoAllocateMdl() 调用时的形参要求;最后一个参数——表达式
0x191 * 4——就是整个 KiServiceTable 缓冲区的大小:假若 MapMdl() 成功返回,则全局变量 mapped_ki_service_table
持有 KiServiceTable 新映射到的内核虚拟地址;这些全局变量都是“自注释”的,pfn_array_follow_mdl 持有的地址处内容
就是 MDL 描述的物理页框号:
——————————————————————————————————————————————————————————————
MapMdl()第一部分逻辑如下图所示,局部变量 mapped_addr 预期存放 KiServiceTable 新映射到的内核虚拟地址,并作为
MapMdl() 的返回值给 DriverEntry(),进一步初始化全局变量 mapped_ki_service_table。
注意,PVOID 可以赋给其它任意类型的指针,这是合法的。
IoAllocateMdl() 返回一枚指针,指向分配好的 MDL,该 MDL 描述 KiServiceTable 的物理内存布局;这枚指针被用来初始化
作为实参传入的全局变量 mdl_ptr(mdl_pointer 是形参)。
我添加的第一个软件断点就是为了研究 IoAllocateMdl() 分配的 MDL 其中 MappedSystemVa,StartVa,以及 MdlFlags 这些
字段的内容——事实上,这些字段值会在
IoAllocateMdl() -> MmProbeAndLockPages() ->MmGetSystemAddressForMdlSafe()
调用链的每一阶段发生变化,所以我总共添加了三个断点在相关的检查区域,有助于我们在后面的调试过程中深入理解 nt!_MDL
的设计思想。
我把使用 Windows 执行体组件例程进行的操作放入一个 try-except 块内,以便处理可能出现的异常,except 块内的逻辑如下
图,当违法访问出现时,调用 IoFreeMdl() 释放我们的 MDL 指针,然后 MapMdl() 返回 NULL,从而导致 DriverEntry() 打印出
错信息。
————————————————————————————————————————————————————————————
关于 IoAllocateMdl() 的第二个参数,我们有必要进一步了解,所以我翻译了 MSDN 文档上的相关片段,如下:
IoAllocateMdl() 的第二个参数指定要通过分配的 MDL 描述的缓冲区的大小。如果这个长度小于 4KB,
那么映射它的 MDL 就只描述了一个被锁定的物理页面;
如果长度是 4KB 的整数倍,那么映射它的 MDL 就描述了相应数量的物理页面(通过紧接 MDL 后面的 PFN 数组)
对于 Windows Server 2003,Windows XP,以及 Windows 2000,
此例程支持的最大缓冲区长度(以字节为单位)是:
PAGE_SIZE * (65535 - sizeof(MDL)) / sizeof(ULONG_PTR) (约 67 MB)
对于 Windows Vista 和 Windows Server 2008,能够传入的最大缓冲区大小为:
(2 gigabytes - PAGE_SIZE)
对于 Windows 7 和 Windows Server 2008 R2,能够传入的最大缓冲区大小为:
(4 gigabytes - PAGE_SIZE)
执行此例程的 IRQL 要求为 <= DISPATCH_LEVEL
————————————————————————————————————————————————————————————
MapMdl()第二部分逻辑如下图所示,它紧跟在第一个软件断点之后。我们检查 MDL 中的 MDL_ALLOCATED_FIXED_SIZE 标志是
否置位,该标志因调用 IoAllocateMdl() 传入第二个参数指示固定大小而置位;MmProbeAndLockPages() 的第三个参数是实现
写访问的关键所在,能否锁定内存倒是其次,因为像 KiServiceTable 这种系统范围的调用表,地位非常重要,如果被换出物理内
存,系统岂不就崩溃了,所以坦白讲我们只是因为需要写权限才调用它的。
第二个断点紧跟其后,这样就可以在调试器中检查 MmProbeAndLockPages() 是如何修改 MDL 中的标志;也可以使用编程手段
检查,如图中的第二个 if 块逻辑,事实上 MmProbeAndLockPages() 调用会向 MdlFlags 字段内添加 MDL_WRITE_OPERATION
与 MDL_PAGES_LOCKED 标志,这就是我们想要的结果!
最后我们调用 MmGetSystemAddressForMdlSafe() 把该 MDL 描述的原始虚拟地址映射到内核空间的另一处,新地址通常位于
驱动加载到的内核空间某处;局部变量 mapped_addr 持有这个新地址,最终用来返回并初始化全局变量
mapped_ki_service_table。
同理我们可以检查 MmGetSystemAddressForMdlSafe() 修改了哪些 MDL 结构成员,对于理解 MDL 的工作机理非常关键。
————————————————————————————————————————————————————————————
MapMdl()第三部分逻辑如下图所示,我们检查 MmGetSystemAddressForMdlSafe()是否多添加了一个
MDL_MAPPED_TO_SYSTEM_VA 标志,然后以 DBG_TRACE 宏打印信息。
全局变量 backup_mdl_ptr 是我们在调用 IoAllocateMdl() 就做好备份的 MDL 指针,它与 mdl_ptr 指向同一个 nt!_MDL 结构。
接下来的逻辑有助于你理解 MDL 头部后面的 PFN 数组:mdl_ptr 指向 nt!_MDL 结构头部,把它加上 1 ,意味着把它持有的
内存地址加上 1 * sizeof(MDL) 个字节,于是就定位到了 MDL 头部后面的 PFN 数组起始地址——现在全局变量
pfn_array_follow_mdl(一枚 PPFN_NUMBER 型指针)持有这个地址;正如图中倒数第三条 DbgPrint() 调用所言——
MDL 结构后偏移 xx (0x1b)地址处是一个 PFN 数组,用来存储该 MDL 描述的虚拟缓冲区映射到的物理页框号。
最后一条 DbgPrint() 调用通过解引 pfn_array_follow_mdl 来输出该地址处存放的物理页框号。
在 return mapped_addr; 语句的后面,则是 try-except 块的异常捕获逻辑,请参前面截图。
————————————————————————————————————————————————————————————
现在,程序访问可读写的 mapped_ki_service_table 与只读的 os_ki_service_table 都转译到同一块物理内存,
后者就是实际上存储 KiServiceTable 的地方。接下来,我们用一枚函数指针保存 KiServiceTable 中某个原始的系统服务,
然后用我们的钩子例程地址替换掉该位置处的原始系统服务,而钩子例程内部仅仅是调用原始系统服务,实现安全转发。
为了演示简单起见,我选取 KiServiceTable 中 0x39(57)号例程,因为它的参数只有一个,方便我们的钩子例程仿效同样的
参数声明——内核系统服务调度器(nt!KiFastCallEntry())并不知道它调用的目标系统服务已经被替换成我们的钩子例程,
所以他会以既定方式使用钩子例程的返回值和输出参数,在这种情况下,只要我们的钩子例程原型声明与被挂钩系统服务有
细微差别,都可能导致非预期的内核错误而蓝屏,显然,那些参数既多又复杂的系统服务不适合我用来演示。
此外,某些系统服务接收的参数类型的定义不在 wdm.h / ntddk.h 头文件内,讲明了这些数据类型不是给驱动开发人员使用的,
仅供内核组件使用,为了引入包含该定义的头文件则会碰到复杂的头文件嵌套包含问题,其麻烦程度丝毫不逊于 Linux 平台上
的“二进制软件包依赖性地狱”。
57 号系统服务例程亦即 nt!NtCompleteConnectPort(),有且仅有一个文档化的参数,WRK 源码中的相关定义如下图:
————————————————————————————————————————————————————————————
所以我们的钩子例程只要完全仿效它的返回值类型与形参类型即可,然后在内部调用指向原始例程的函数指针实施重定向。
通过 typedef 定义一个函数指针,其返回值类型与形参类型与 NtCompleteConnectPort() 一致,然后声明一个该函数指针
实例。相关代码如下图:
————————————————————————————————————————————————————————————
全局变量 ori_sys_service_ptr 持有 NtCompleteConnectPort() 的入口点地址,前者是在我们的 rootkit 入口点
DriverEntry() 中初始化的;保存这枚指针后就可以用钩子例程替换 NtCompleteConnectPort(),如下图所示:
需要指出一点,尽管把指针名称 mapped_ki_service_table 当作数组名称来访问 KiServiceTable 是被 C 语言核心规范允许的,
但是上图那段代码在编译器会产生警告,如下:
1 1>warnings in directory d:\kmdsource_use_mdl_mapping_ssdt 2 1>d:\kmdsource_use_mdl_mapping_ssdt\usemdlmappingssdt.c(155) : warning C4047: '=' : 'OriginalSystemServicePtr' differs in levels of indirection from 'DWORD' 3 1>d:\kmdsource_use_mdl_mapping_ssdt\usemdlmappingssdt.c(157) : warning C4047: '=' : 'DWORD' differs in levels of indirection from 'NTSTATUS (__stdcall *)(HANDLE)'
ori_sys_service_ptr 是一枚 OriginalSystemServicePtr 型函数指针( NTSTATUS (__stdcall *)(HANDLE) ),而
mapped_ki_service_table 是普通指针,它的数组名称表示法结合数组下标,实际上被视为一个存储对应元素的 DWORD 变量,
两者的间接寻址级别不同。
就目前而言我们可以无视这两条警告,因为含有这段代码的 rootkit 源码在编译后确实能够安全地 hook 目标系统服务函数,系统
正常运作不会有问题,类似的警告可以通过指定警告级别的编译选项来过滤掉。
——————————————————————————————————————————————————————————————————————————————————
讲到这里你一定会嫌我既罗嗦又婆婆妈妈的,那么来看下面这一张简明扼要的全局概览,它解释了 MDL 是如何把一片缓冲区
映射到另一处,并描述两者相同的物理布局,注意,图中的组织结构是执行完 MmGetSystemAddressForMdlSafe() 后才会产生的。
注意,上图中我没有给出 PFN 数组中第一个成员携带的具体 20 位物理页框号,原始和映射到的新内核缓冲区,以及实际 RAM
中的物理页框号,而“byte within page”就是页内特定偏移处开始的字节序列,亦即系统服务例程入口点的实际物理地址!
这些“占位符”我会在第三部分的调试单元内给出,毕竟,驱动开发与调试是相辅相成的,只有理论没有实践怎么行,只有源码
没有调试怎知真理,不然,任何人对于内存的需求就真的不会超过 640 K 了。。。。。
——————————————————————————————————————————————————————————————————————————————————————
to be continued