----------- Rootkit 核心技术之绕过 IopParseDevice() 调用源检测逻辑 ---------------
————————————————————————————————————————————————————————————————
在上一篇文章中,我们已经看到 IopParseDevice() 如何对传入的 OPEN_PACKET 结构进行验证。假设 ObReferenceObjectByName() 的调用者没有分配并初始化第七个参数 ParseContext,而仅是简单地传入 “NULL” ,那么当调用链深入到 IopParseDevice() 内部时,就会因验证失败返回 C0000024(STATUS_OBJECT_TYPE_MISMATCH)。
我们根据源码中的暗示来追踪 OPEN_PACKET 结构究竟在哪分配的,如前所述,调用链 NtCreateFile->IoCreateFile()->IopCreateFile() 的结尾,也就是在 IopCreateFile() 内部,实际负责 OPEN_PACKET 的初始化。下面贴出的代码片段以 NT 5.2 版内核源码为样例:
也就是说,我们直接复制 IopCreateFile() 中的 OPEN_PACKET 结构初始化部分逻辑就行了?
这里还有一个问题,负责分配该结构体内核内存的例程 IopAllocateOpenPacket() 是一个宏,Visual C++ 2015 中给出它是用 ExAllocatePoolWithTag() 定义的。这就好办了,在我们自己的驱动源码中,添加相应定义即可,如下图:
————————————————————————————————————————————————————————————
因为 OPEN_PACKET 结构同样没有公开的文档来描述,所以要么在我们的驱动源码中用 “#include” 包含定义它的头文件,要么直接复制定义的那一部分黏贴进来。很显然,后者比较轻松——OPEN_PACKET 在内核源码的 “iomgr.h” 中定义,而该头文件又嵌套包含了一堆杂七杂八的内核头文件,要理清这些嵌套包含关系很麻烦,而且最重要的是,其中一些头文件定义的数据类型会与驱动开发中用的 “ntddk.h” 和“wdm.h”重复,引起编译器的抱怨。所以直接在 “iomgr.h” 中搜索字串 “typedef struct _OPEN_PACKET”,把找到的定义块拷贝进来即可。
然而,OPEN_PACKET 结构中唯有一个字段不是 “原生” 定义的——这就是 “PDUMMY_FILE_OBJECT” 类型,需要包含其它头文件才不致使编译器报错。
我的解决方案是,直接把该字段的声明所在行注释掉,下图展示了该字段具体的位置(在 “iomgr.h” 中的行号),方便各位快速查找:
——————————————————————————————————————————————————————————————————
注意,NT 6.1 版内核在编译时刻的 OPEN_PACKET 结构显然是未经 “恶意” 修改的,所以编译器为其 “sizeof(OPEN_PACKET)” 表达式计算 0x70 的值,而我们在自己的驱动中拿掉了 OPEN_PACKET 其中一个字段使得编译器为表达式 “sizeof(OPEN_PACKET)” 预计算 0x58 的值(后面的调试阶段会验证),这会造成 “Size” 字段不是 IopParseDevice() 内部逻辑预期的 0x70,从而导致返回 C0000024(STATUS_OBJECT_TYPE_MISMATCH)。
解决办法也很简单,我们的驱动中,不要依赖编译时刻的计算,直接把 “Size” 字段的值硬编码为 0x70 不就好了?
如下图所示,你还会注意到,我把 “Type” 字段的常量 “IO_TYPE_OPEN_PACKET” 改成了对应的数值,以确保万一。
另外,由于 IopAllocateOpenPacket() 等价于 ExAllocatePoolWithTag(),而后者通常返回泛型指针(“ PVOID ”,亦即 “ void * ”),
所以我强制把它转型为与 “openPacket” 一致的类型。
万事俱备,“东风” 就在于调用 ObReferenceObjectByName() 时,为第七个参数传入“openPacket” 即可,上图显示的很清楚了。
——————————————————————————————————————————————————————————————————
很不幸的是,我把编译出来的驱动放到虚拟机(Windows 7,基于 NT 6.1 版内核)里面动态加载测试,还是无法获取到
“\Device\QQProtect” 相应的设备对象指针,ObReferenceObjectByName() 返回 C0000024。
为了找出故障原因,我在分配 OPEN_PACKET 逻辑的前面利用内联汇编添加了一个软中断 “__asm{ int 3; } ”,宿主机器上启动内核调试器 kd.exe,我的启动参数像是这样:
kd.exe -n -v -logo d:\virtual_machine_debugging.txt -y SRV*C:\Symbols*http://msdl.microsoft.com/download/symbols -k com:pipe,port=\\.\pipe\com_1,baud=115200,reconnect
参数 “logo” 指定要把整个调试过程的输出信息写入日志;
“-y” 指定符号文件的位置(机器指令中没有内核函数与变量的符号,所以调试器需要查找额外的符号以向用户显示人类可读的名称);
“-k” 参数指定调试类型为 “命名管道模拟串口1”,波特率数值越高,响应越快。
把重新编译好的驱动放到虚拟机中,在提升权限后的命令提示符中执行 bcdedit.exe,启用调试模式,这样重启虚拟机后,就会进入调试模式(无需在启动过程中按下 F8 选择菜单)。
我把自己的驱动实现成按需加载,也就是利用服务控制管理器(sc.exe)发出命令来动态加载和卸载,实现此功能的相应批处理文件内容如下图,注意该文件要放在虚拟机中执行,“start= demand” 表明通过 sc.exe 按需启动;“binpath” 就是驱动文件存放的磁盘路径,假设我的驱动名为 hideprocess.sys,执行该批处理任务后,就在相关的注册表位置添加了一项,往后只需在 cmd.exe 中执行 “sc.exe start/stop hideprocess” 就能够动态加卸载。
按照上述方式加载时,就会自动触发我们设定好的软件断点,即可在宿主机中检查虚拟机的内核空间。
另外还需注意一点:编译驱动时的 “构建” 环境应该选择 Check Build,这样会一并生成同名称的符号文件,后缀为 “.pdb”,从而调试器能够显示我们自己驱动中的函数与变量名称,提高调试效率,如下图:
——————————————————————————————————————————————————————————————————————
触发软件断点后,我们一般会用 “kv” 命令查看栈回溯信息,它披露出我们的驱动入口点 DriverEntry() 是由 I/O 管理器的 IopLoadDriver() 调用的:
栈的顶层函数 “ReferenceDeviceAndHookIRPdispatchRoutine+0x56” 是我添加软中断的地方。执行 “r” 命令查看当前的 x86 通用寄存器状态,EIP 指向地址 0x8f4a3196 ,执行 “u hideprocess!ReferenceDeviceAndHookIRPdispatchRoutine+0x56 L2”,反汇编输出的第一行地址就是 0x8f4a3196,与 EIP 的值相符;第二行是把一个 16 进制值 “ 704F6F49h” 压栈,实际上它是 ASCII 字符 “pOoI” 的 16 进制编码,换言之,这是在通过内核栈传递 ExAllocatePoolWithTag() 的第三个参数(从右往左传递,请回顾之前的 IopAllocateOpenPacket() 宏定义那张图)。
————————————————————————————————————————————————————————————————
继续按下 “t” 单步执行,如下图所示,你可以看到,ExAllocatePoolWithTag() 的第二个参数,分配的内核内存大小为 0x70 字节,因为我在宏定义中硬编码了这个值,而不是用 sizeof(OPEN_PACKET) 表达式让编译器计算;另一方面,图中的 “dt” 命令也证实了它的大小为 0x70 字节。
首个传入的参数 “NonPagedPool” 为不可换页池,其内的数据无法被换出物理内存,该常量对应的数值为 “0”:
我不想浪费时间在查看内核内存的分配细节上,所以我按下 “p”,步过 ExAllocatePoolWithTag() 函数调用,接下来的 cmp/jne 汇编序列对应源码中检查是否成功分配了内存并用于 openPacket 指针,实际的执行结果是跳转到地址 0x8f4a31c6 ,对应源码中初始化 OPEN_PACKET 结构前两个字段的逻辑:
接下来一直单步执行到调用 ObReferenceObjectByName() 前夕,在此处我们要 “步入” 它的内部,进行故障排查,所以按下 “t” 跟进,这里有一个小技巧,我们已经分析过 ObReferenceObjectByName() 的源码,知道它会调用很多函数,而且大致清楚问题出现在 ObpLookupObjectName() 里面,所以指令 “tc”可以跟踪到每个函数调用处停止,再由用户决定是否跟进该函数内部。
这是我的美好梦想,但现实总是残酷的,在我跟踪到原子操作系列函数
nt!ExInterlockedPopEntrySList() 调用时,kd.exe 就卡住了,无法继续追踪此后的调用链。从稍早的栈回溯信息来看,与源码中和我们预测的调用序列大致相符,只是不晓得为啥在 nt!ObpAllocateObjectNameBuffer() 中,为了给传入的驱动对象名称 “\Device\QQProtect” 分配内核内存,调用 nt!ExInterlockedPopEntrySList(),而后者却无法追踪。。。。是虚拟机环境的缘故,还是原子操作类函数的不可分割性质?
——————————————————————————————————————————————————————————————
讲一点废话,一般我们在栈回溯中看到的顶层说明行,有一个 “Args to Child” 项目,表示调用者传递给它的参数,不过最多也只能显示前三个。
以下图为例子吧,传递给 nt!ExAllocatePoolWithTag() 的三个参数(从左到右)就是 00000000(NonPagedPool),00000070(我硬编码的值),704f6f49(ASCII 字符串“pOoI”),同理,传递给 hideprocess!DriverEntry() 的第一个参数 867c3550 是 _DRIVER_OBJECT 结构的地址,由I/O 管理器加载它时为它分配(注意与源码中 DriverEntry() 定义的一枚 _DRIVER_OBJECT 指针不同,“Args to Child”
列出的数据相当于执行解引操作符 * 后的结果),第二个参数是 UNICODE_STRING 结构的地址,对应源码定义中的一枚 _UNICODE_STRING 指针,该结构中存储的是我们驱动在注册表中的完整路径:
——————————————————————————————————————————————————————————————————
总而言之 ,基于以上理由我无法继续跟进到 ObpLookupObjectName() 里面查看它是否执行了 IopParseDevice() 回调,从而无法确定究竟为啥后者返回 C0000024。
我想可能是因为内核源码版本的变化,导致相关例程的判断逻辑也不一样了,不能根据前一版源码的逻辑来编写预计运行在后一版内核上的驱动。
其实解决方案还是有的,比较花时间罢了,就是利用 “u” 指令反汇编 ObpLookupObjectName() 起始处对应的机器指令,再反编译成近似的 C 伪码,与 NT 5.2 版内核源码对比,找出其中改动的地方,但这是一个费时费力的工作,且收益甚微,还不如直接在互联网上搜释出的 NT 6.1 版内核源码,或者接近的版本,再思考绕过的方法。
顺带说一下,根据 A 设备名获得 A 设备对象的指针,然后把 rootkit/自己驱动创建的恶意设备 attach 到 A 设备所在的设备栈,从而拦截检查经过 A 设备的 IRP 内数据。。。。这种方法已经比较过时了,因为现在反病毒软件的内核模式组件也会检查这些设备栈,寻找任何匹配特征码的恶意设备,再者,内核调试器的 “!devstack” 命令很容易遍历揭示出给定设备所在的设备栈内容,被广泛用于计算机调查取证中,从 rootkit 的首要目标——实现隐身——的角度来看, attach 到设备栈就不是一个好榜样。
相反,通过 ObReferenceObjectByName() 总是能够获得驱动对象的指针,进而能够 hook 该驱动的 IRP 分发例程,这种手段隐蔽性极高,而且不容易被检测出来。
后续的博文将讨论如何将这种技术用在 rootkit 中,同时适应当前流行的对称多处理器(SMP)环境。
————————————————————————————————————————————————————————————————
posted on 2018-01-18 00:24 f1yin9_0x5hark 阅读(976) 评论(0) 编辑 收藏 举报