在本系列的第一部分中,我们介绍了几种能够将 64 位 DLL 注入 WoW64 进程的注入方法,目的是最终使用此 DLL 在进程中挂钩 64 位 API 函数。

我们通过介绍通过 APC 进行注入来结束这篇文章,并发现,当对 CFG 感知进程进行测试时,它无法注入 DLL 并使进程崩溃。要理解为什么会发生这种情况,我们必须深入研究 CFG 的一些实现细节。

CFG简介

CFG (Control Flow Guard) 是一项相对较新的漏洞利用缓解功能,首次在 Windows 8.1 Update 3 中引入,后来在 Windows 10 中得到增强。它是一种支持编译器的缓解措施,旨在通过防止对非法目标的间接调用来对抗内存损坏漏洞。在每次间接函数调用之前,编译器都会插入对位于 NTDLL 中的专用验证例程的附加调用。此例程接收调用目标,并检查它是否是函数的起始地址,粒度在 8 字节以内。如果不是,则会引发安全检查失败 (int 0x29) 并强制终止进程。


图 8 – Mimikatz,使用(右)和不使用(左)CFG 编译。

为了使此验证变得简单高效,CFG 利用专门为此目的添加的新内存区域,称为 CFG 位图。在此位图中,每个位表示进程地址空间中 8 个字节的状态,并标记它们是否构成有效的呼叫目标。由于这种映射比率,位图必须是进程总虚拟地址空间的 1/64,这可能会变得非常大——在 64 位进程中为 2TB,其总地址空间为 128TB。

显然,在 64 位进程中,此位图的大部分内容都是未提交的,因为实际上只有一小部分进程的地址空间在使用中。仅当引入新的可执行页时(通过直接分配虚拟内存、映射节对象的视图或以其他方式将页面保护更改为可执行页),内核才会提交,然后在位图中设置与该页对应的位。

魔兽世界64进程中的CFG

Alex Ionescu 在题为“关闭”天堂之门“的博客文章中描述了 CFG 在 WoW64 流程中的一些独特特征。如图所示,CFG 感知的 WoW64 进程不是一个,而是两个单独的 CFG 位图:

  • 本机位图,用于标记进程中 64 位代码的有效调用目标。由于此位图必须无法被 32 位代码访问,因此它位于 4GB 边界之上,通常位于本机 NTDLL 旁边。

  • WoW64 位图,标记过程中 32 位代码的有效调用目标。它的保留大小为 32MB,因为它仅覆盖较低的 4GB 地址空间,其中 32 位代码可以存在。显然,它始终位于 4GB 边界下方,通常位于主图像旁边。

由于 WoW64 进程有两个 CFG 位图和两个版本的 NTDLL,因此自然也有两个版本的验证函数。该函数的 32 位版本根据 WoW64 位图检查提供的地址,而 64 位版本根据本机位图检查它。


图9 -在Windows 10 x64上运行的32位notepad.exe的虚拟地址空间的快照

如前所述,每当引入新的可执行页时,内核都会在CFG位图中设置位。这就提出了一个问题,在WoW64进程中,这两个位图中的哪一个会受到影响?正如Ionescu所指出的,答案在于MiSelectCfgBitMap()和MiSelectBitMapForImage()函数,每当需要更改CFG位图时,内存管理器都会调用它们。

图10 -部分调用堆栈显示当可执行内存映射到进程时对MiSelectCfgBitMap()和MiSelectBitMapForImage()的调用

这两个函数的伪代码如下所示:

图11.1 -在Windows 10 x64 RS3上看到的MiSelectCfgBitMap()伪代码

图 11.2 – 在 Windows 10 x64 RS3 上看到的 MiSelectBitMapForImage() 伪代码

从这两个函数中可以得出一些结论:

  1. 不出所料,所有 32 位模块都标记在 WoW64 位图中。

  2. 所有 64 位模块都标记在本机位图中,包括映射到地址空间较低 4GB 的模块。这是必不可少的,否则本机 NTDLL 将无法与构成 WoW64 环境的本机 DLL 进行互操作。例如,NTDLL 甚至无法加载这些模块,因为调用其入口点将使 CFG 验证逻辑失败。

  3. 所有低于 4GB 边界的私有内存分配都会在 WoW64 位图中标记,无论谁分配它们或出于什么目的。精明的读者可能已经注意到,在图 8 所示的示例中,4GB 边界以上的所有地址空间都是保留的(本机 NTDLL 和本机 CFG 位图除外)。由于无法从保留区域分配内存,这实际上意味着所有私有内存分配都将仅在 WoW64 位图中标记。

现在很清楚为什么前面显示的使用 APC 的 DLL 注入技术注定会失败:尽管“适配器 thunk”包含 64 位代码,但它是一个私有内存分配,因此它将填充 WoW64 位图。但是,负责初始调度 APC 的函数是 KiUserApcDispatcher() 的 64 位版本,它将尝试根据本机位图验证 thunk 的地址,但无济于事。

因此,如果我们希望保持我们的 APC 注入能力,我们必须以某种方式修改我们的技术以克服 CFG 验证问题。

返回 APC 注射

在对 CFG 实现详细信息有一些先验知识后,可能会建议通过使用 VmCfgCallTargetInformation 信息类调用 NtSetInformationVirtualMemory() 来简单地将适配器 thunk 标记为有效的调用目标。这个选项虽然很有希望,但实际上并不能解决问题。这样做的原因是,在内部,NtSetInformationVirtualMemory() 依赖于 MiSelectCfgBitMap() 来帮助确定两个位图中的哪一个应该受到影响。 出于前面描述的相同原因,MiSelectCfgBitmap() 在提供适配器 thunk 的地址时仍将返回 WoW64 位图,从而保持本机位图不变。

引理 12 – NtSetInformationVirtualMemory() 只会影响 WoW64 位图,因为它内部依赖于 MiSelectCfgBitMap()。证据:亚历克斯·约内斯库是这么说的。Q.E.D.公司

取消此解决方案的资格后,想到的下一个选项是找到一种方法,以某种方式“欺骗”MiSelectCfgBitmap() 返回本机位图,就在为适配器 thunk 分配内存时。

“Nativising”一个 WoW64 过程

在查看图 11.1 中显示的 MiSelectCfgBitmap() 的伪代码时 ,可以清楚地看到,对于“真正的”64 位进程,将始终返回本机位图。这是显而易见的,因为 64 位进程应该只有一个本机 CFG 位图。因此,如果我们以某种方式设法“本土化”WoW64 进程,适配器 thunk 将在本机位图中被标记,因此 APC 调度应该按计划成功。

内核判断给定进程是否是本机进程的方法是探测 EPROCESS 结构的 WoW64Process 成员 。如果此成员设置为 NULL,则该进程被视为本机进程,否则将被视为 WoW64 进程。

图 12 – Windows 10 RS3 中显示的 EPROCESS 结构的(非常)部分视图。请注意偏移量 0x428处的 Wow64Process 指针。

考虑到这一点,我们可以应用基于 DKOM 的解决方案,其中 WoW64Process 在为适配器 thunk 分配内存之前被清零,然后恢复到其原始值。

图 13 – 用于使适配器 thunk 占据本机位图的伪代码

该解决方案在附录ix B 中介绍,使我们的 APC 注入在 CFG 感知 WoW64 流程中成功,并在 Windows 10 RS3 上进行了测试。

虽然简单,但这种方法确实有一些明显的缺点。首先,必须修改的 EPROCESS 结构在很大程度上是无文档记录的,并且经常在 Windows 版本之间更改。因此,不能依赖该结构内部 WoW64Process 的偏移量来保持恒定,并且必须在运行时以启发式方式搜索。其次,将 WoW64Process 成员归零可能会产生一些意想不到的副作用和危险,尤其是在进程包含多个线程的情况下。

总而言之,这是使 APC 进样器在 CFG 感知过程中工作的有效选项,但它相当不稳定且不可靠,应格外谨慎使用。考虑到这些缺点,我们希望找到一个更可靠的解决方案来解决这个问题,最好是完全不依赖私有的、可执行的内存分配。

无 Thunkless APC 注射

初始化 APC 时,可以将 APC 例程设置为指向我们选择的任何函数,无论是现有函数还是我们专门为此目的创建的函数。这意味着 - 至少在理论上 - 我们可以通过创建一个 APC 来注入 DLL,该 APC 将直接调用本机 LdrLoadDll(),而无需通过适配器 thunk。显然,LdrLoadDll() 是 64 位代码的有效调用目标,因此它可以用作 APC 目标,而不会触发 CFG 冲突。

但是,在二进制级别似乎存在一个问题:LdrLoadDll() 和 KNORMAL_ROUTINE 的原型不匹配。虽然 LdrLoadDll() 需要四个参数,但 KNORMAL_ROUTINE 类型的函数似乎只接收三个参数:

图 14.1 – LdrLoadDll() 的原型

图 14.2 – KNORMAL_ROUTINE 的原型

尽管如此,还是应该考虑根据 x64 ABI 使用的__fastcall调用约定:每个函数的前四个参数通过寄存器 RCX、RDX、R8 和 R9 传递给它,因此当 LdrLoadDll() 将由 KiUserApcDispatcher() 调用时, R9 当前持有的任何值都将被解释为第四个参数。根据上面介绍的原型,LdrLoadDll() 接收到的第四个参数被声明为“_Out_ PHANDLE ModuleHandle”。这意味着要使 LdrLoadDll() 成功,R9 必须包含指向能够保存指针大小数据的可写内存位置的有效指针。

遗憾的是,由于标准 APC 过程仅采用三个参数,因此在 APC 初始化期间显然无法为第四个参数指定值。因此,R9 在进入 APC 例程时持有的值基本上是未知的。那么问题来了:我们能否以某种方式保证 R9 将持有一个有效的指针,以便它满足所有 LdrLoadDll() 要求?令人惊讶的是,这个问题的答案是肯定的,但我们怎么能确定呢?

图 15 – KeInitializeApc 和 KeInsertQueueApc 的原型。用户模式 APC 例程 (NormalRoutine) 的用户控制参数以红色突出显示。

在他探讨 APC 调度的一些内部方面的帖子中,Skywing 演示了 64 位 KiUserApcDispatcher() 实际上向 APC 例程发送了第四个“隐藏”参数,指向 CONTEXT 结构。此结构保存 APC 调度过程完成后将通过 NtContinue() 还原的 CPU 状态。虽然这篇文章很旧,但看看 KiUserApcDispatcher() 在较新的系统(如 Windows 10)中的实现,就会发现这仍然成立:

Figure 16 – Part of the implementation of KiUserApcDispatcher() from native NTDLL in Windows 10 RS3. Notice that RSP, pointing to the CONTEXT structure, is moved into R9.

So, we can conclude that in this scenario, the value received by LdrLoadDll() as ModuleHandle will always point to a writable memory block which holds a CONTEXT structure, thus allowing for a successful injection. However, overwriting members of the CONTEXT structure might get risky; if any important information is trashed, the thread might crash when attempting to resume its execution after the call to NtContinue() is made. As we’ve seen before, LdrLoadDll() only writes 8 bytes (pointer size on x64) to the memory location pointed to by ModuleHandle, so it would only overwrite the first member of the CONTEXT structure, which happens to be P1Home:

Figure 17 – the first 0x34 bytes of the CONTEXT structure. The parameters passed to the APC routine are stored in offsets 0, 0x8 and 0x10 of that context, while the address of the APC routine is at offset 0x18.

Luckily, the first four members of the CONTEXT structure are actually used to store the arguments for KiUserApcDispatcher() and are no longer required once the APC routine itself is executed. In order to make sure that overwriting P1Home is indeed safe, it is enough to take a look at the prolog of KiUserApcDispatcher(), presented in Figure 16. By carefully reviewing its prolog, we can see that KiUserApcDispatcher() has a somewhat unique calling convention. The top of the stack points to the aforementioned CONTEXT structure, which – In addition to a CPU state – also encapsulates the address of the APC routine and the values of the other three parameters that will be passed to it.

By correlating the offsets of this structure, shown in Figure 17, with the arguments’ offsets presented in Figure 16, we can conclude that:

  • P1Home holds NormalContext

  • P2Home holds sysarg1

  • P3Home holds sysarg2

  • P4Home holds NormalRoutine, which is the address of the APC routine that will be called from KiUserCallForwarder().

Figure 18 – the first 0x30 bytes of the CONTEXT structure when the APC routine is called

Since members P1Home to P4Home were never used to hold any CPU-related data, they will not be used by NtContinue() to restore the context. Knowing that, we can assume there is no harm in overwriting P1Home from the APC routine. We can now recreate our injector (shown in Appendix C) to inject a native module into any WoW64 process by queueing an APC that directly calls LdrLoadDll(), without causing the notorious CFG violation error.

Conclusion

This brings to an end the second part of the series. In these first two posts, we demonstrated the ability to inject 64-bit DLLs into WoW64 processes using several different methods. Obviously, more methods exist for doing so, but finding them is left as an exercise to the interested reader.

Up next: adapting an x64 hooking engine to support hooking the native NTDLL.

附录

附录B—本地化WoW 64进程

附录 C – 无 thunk APC 注入