介绍

上次(第 1 部分、第 2 部分)我们演示了几种将 64 位模块注入 WoW64 进程的不同方法。这篇文章将从我们离开的地方继续,并描述如何利用在此类进程中执行 64 位代码的能力来挂接本机 x64 API。若要完成此任务,注入的 DLL 必须托管能够在 WoW64 进程的本机区域中运行的挂钩引擎。不幸的是,我们检查的挂钩引擎都无法开箱即用地管理这个问题,因此我们被迫修改其中一个引擎,以使其符合我们的需求。

创建注入的 DLL

选择要适应的挂钩引擎

挂钩是计算机安全领域的一种成熟技术,被防御者和攻击者广泛使用。自 1999 年发表开创性文章 Detours: Binary Interception of Win32 Functions 以来,开发了许多不同的挂钩库。它们中的大多数都与该文章中介绍的概念相似,但在其他方面有所不同,例如它们对各种 CPU 架构的支持、对事务的支持等。从这些库中,我们必须选择一个最适合我们要求的库:

  1. 支持 x64 函数的内联挂钩。

  2. 开源和免费许可 - 因此我们可以合法地修改它。

  3. 优选地,钩子引擎应该相对较小,以便需要尽可能少的修改。

在考虑了所有这些要求之后,我们选择了 MinHook 作为我们的首选引擎。最终使天平有利于它的是它的小代码库,使其在 PoC 中相对容易使用。后面介绍的所有修改都是在它的基础上完成的,如果使用另一个钩子引擎,可能会略有不同。

我们修改后的钩子引擎的完整源代码可在此处获得。

看 马,没有依赖关系!

在第 1 部分中,我们简要提到,并非所有 64 位模块都可以轻松加载到 WoW64 进程中。大多数 DLL 倾向于使用(隐式和显式)常见 Win32 子系统 DLL 中的各种函数,例如 kernel32.dlluser32.dll 等。但是,默认情况下,这些模块的 64 位版本不会加载到 WoW64 进程中,因为它们不是 WoW64 子系统运行所必需的。此外,由于地址空间布局施加的一些限制,强制进程加载其中任何一个都有些困难且不可靠。

为了避免不必要的复杂性,我们选择修改我们的钩子引擎和托管它的 DLL,以便它们只依赖于 WoW64 进程中常见的原生 64 位模块。基本上,这给我们留下了原生的 NTDLL,因为构成 WoW64 环境的 DLL 通常不包含对我们有益的函数。

在更实际的意义上,为了强制生成环境仅链接到 NTDLL,我们在 链接器设置中指定 /NODEFAULTLIB 标志,并将“ntdll.lib”显式添加到其他依赖项列表中:

图19 — 主机DLL的链接器配置

API 重新实现

此更改引发的第一个也是最明显的影响是,我们无法使用更高级别的 Win32 API 函数,必须使用其 NTDLL 对应项重新实现。如图 20 所示,对于 MinHook 使用的每个 Win32 API,我们引入了一个替换函数,该函数具有相同的公共接口并实现相同的核心功能,同时在内部仅使用 NTDLL 工具。

大多数时候,这些“转换”相当简单(例如,对 VirtualProtect() 的调用几乎可以直接替换为对 NtProtectVirtualMemory() 的调用)。在其他更复杂的情况下,Win32 API 函数与其本机对应函数之间的映射并不那么清晰,因此我们不得不求助于一些逆向工程或窥视 ReactOS 源代码


图 20 – VirtualProtect() 的私有实现

项目配置

在 MinHook 中重新实现所有 Win32 API 调用后,我们仍然留下了一堆错误:

图 21 – 一堆错误

幸运的是,解决这些错误中的大多数只需要对项目进行轻微的配置更改。从图中可以看出,大多数错误采用通常从 CRT 导出的未解析外部符号的形式(不可用)。可以通过更改链接器设置中的一些标志来解决它们:

  • 禁用基本运行时检查( 从命令行中删除 /RTC 标志)

  • 禁用缓冲区安全检查(/GS- 标志)

  • 入口点必须显式指定为 DllMain,因为 DllMainCRTStartup 未链接。

  • 此外,memcpy() 和 memset() 必须手动实现,或者替换为 从 NTDLL 导出的对 RtlCopyMemory() 和 RtlFillMemory() 的调用。

应用所有这些更改后,我们成功创建了一个自定义 64 位 DLL,该 DLL 不包含除 NTDLL 之外的任何依赖项:

图 22 – 仅具有单个导入描述符 (NTDLL) 的简约 DLL

挂接本机 NTDLL

一旦我们修改了钩子引擎以匹配上述所有限制,我们就可以仔细研究钩子机制本身。MinHook 以及绝大多数此类库采用的挂钩技术被称为“内联挂钩”。该技术的内部工作原理有据可查,但以下是该方法所包含步骤的简化说明:

  1. 在进程的地址空间中分配一个“蹦床”,并将最终将被挂钩的函数的序号复制到其中。

  2. 将JMP指令放在蹦床上,紧挨着复制的序言。此 JMP 应指向原始函数 prolog 之后的指令。

  3. 将另一条JMP指令放在蹦床中,紧挨着复制的序言。这个JMP应该指向一个绕行函数(通常在我们之前注入到进程中的DLL中找到)。

  4. 用指向蹦床的JMP指令覆盖挂钩函数prolog。

图 23.1 – 内联钩子的一般图示。

图 23.2 – 蹦床视图。红色标记为跳转到绕行功能,绿色标记为从挂钩功能复制的指令并跳回其中。

这种挂钩方法的工作原理是修改挂钩函数的 prolog,因此每当应用程序调用它时,都会调用 detour 函数。然后,detour 函数可以在原始函数之前、之后或代替原始函数执行任何代码。

在 64 位模式下,大多数挂钩引擎对挂钩功能和蹦床使用两种不同类型的跳跃:

  1. 从钩子函数到蹦床的跳跃是 编码为“E9 <4 字节偏移量>”相对跳跃。由于此指令在 DWORD 大小的操作数上运行,因此蹦床与挂钩函数的距离不得超过 2GB。通常为此步骤选择这种形式的跳转,因为它只占用 5 个字节,因此它足够紧凑,可以整齐地放入函数的 prolog 中。

  2. 从蹦床跳到绕行函数,然后回到钩子函数,如图 23.2 所示,是编码为“FF25 <4 字节偏移量>”的间接 RIP 相对跳跃(助记符形式:JMP qword ptr [rip+offset])。此指令将跳转到存储在 RIP 和偏移量指向的位置的 64 位绝对地址。

在本机 64 位进程中运行时,采用此技术的挂钩引擎工作正常。不出所料,蹦床被分配到距离目标函数不远的地方(最多 2GB),从而允许成功的二进制检测。

但是,最近对 WoW64 进程的内存布局进行了一些更改,这保证了如果不进行一些其他更改,则无法将此技术应用于本机 NTDLL。正如 Alex Ionescu 在他的博客中所展示的那样,在最近的 Windows 版本(从 Windows 8.1 更新 3 开始)中,本机 NTDLL 已被重新定位:它现在不再与进程的其余部分模块一起加载到较低的 4GB 地址空间中,而是加载到更高的地址。

图 24 – Windows 10(左)和 Windows 7(右)上 64 位 NTDLL 的基址。

4GB 边界以上的其余地址空间(本机 NTDLL 和本机 CFG 位图除外)受SEC_NO_CHANGE VAD 保护,因此任何人都无法访问、分配或释放。这意味着蹦床将始终分配在地址空间的较低 4GB 中。由于 64 位系统中的总用户模式地址空间为 128TB,因此本机 NTDLL 和蹦床之间的距离必然大于 2GB。这使得大多数钩子引擎发出的JMP不足。

图 25 – 图示显示了 Windows 8.1 及更高版本上 WoW64 进程中内联挂钩所需的控制传输。请注意,在 Windows 10 RS4 预览版(内部版本 17115)中,SEC_NO_CHANGE VAD 似乎不再存在,内存可以分配在进程地址空间中的任何位置。

JMP的另一种形式

为了克服这个问题,我们不得不用不同的指令替换相对的JMP,该指令能够传递高达128TB的距离。在寻找替代方案时,我们偶然发现了 Gil Dabah 的一篇文章,其中列出了一些可能的选择。在取消了所有“弄脏”登记册的选项的资格后,我们只剩下几个可行的选择。最初,我们试图用类似于蹦床中使用的间接的、相对 RIP 的 JMP 替换相对 JMP:


该指令在 Windows 10 上表现良好,并为我们提供了一种在 WoW64 进程中检测各种本机 API 函数的方法。但是,在早期的 Windows 版本(如 Windows 8.1 和 Windows 7)上测试修改后的代码时,它无法完全创建钩子。事实证明,这些 Windows 版本中的 NTDLL 函数比 Windows 10 中的对应函数短,并且通常没有足够的空间来容纳我们选择的 JMP 指令,该指令占用 14 个字节。

图 26 – Windows 10 RS2(左)和 Windows 8.1(右)中 ZwAllocateVirtualMemory() 的实现。

为了使我们的DLL在所有Windows版本中通用,我们必须找到一个更短的指令,仍然能够分支到蹦床。最终,我们想出了一个真正利用蹦床位置的解决方案:由于蹦床必须分配在地址空间的下部 4GB 中,因此其 8 字节地址的上部 4 个字节被清零。这允许我们使用以下选项,它只占用 6 个字节:

此方法之所以有效,是因为在 x64 代码中,当提供 4 字节操作数时,PUSH 指令实际上将 8 字节的值推送到堆栈上。上面的 4 个字节用作符号扩展,这意味着只要 4 字节地址不大于 2GB,它们就会归零。

然后,我们使用 RET 指令,该指令从堆栈中弹出一个 8 字节的地址并跳转到该地址。由于我们刚刚将蹦床的地址推到了堆栈的顶部,这将是我们的退货地址。

图 27 – NtAllocateVirtualMemory() 包含我们修改后的钩子。 请注意前两条指令,它们将蹦床的地址推入堆栈并立即“返回”到它。

这种方法只剩下一个问题,由CFG引起。如本系列的第 2 部分所述,WoW64 进程中的所有私有内存分配(包括用于钩子的蹦床)都仅在 WoW64 CFG 位图中标记。

每当我们想从迂回处执行原始 API 函数时,我们首先需要调用蹦床才能运行该函数的 prolog。但是,如果我们的 DLL 是使用 CFG 编译的,它将尝试在调用蹦床之前根据本机 CFG 位图验证蹦床的地址。由于这种不匹配,验证将失败,导致进程终止。

这个问题的解决方案相当简单——控制 DLL 的配置,我们可以简单地编译它,而无需启用 CFG。这是通过 从编译器的命令行中删除 /guard:cf 标志来完成的。

防止无限递归

在调整钩子引擎时要考虑的最后一个问题是无限递归。放置钩子后,每当调用挂钩函数时,此调用将绕道而行。但是我们的绕行函数也会执行自己的代码,这些代码本身可能会调用挂钩函数,从而导致我们回到绕行。除非小心处理,否则这可能会导致无限递归。

图 28 – 当 LdrLoadDll 上的钩子尝试加载另一个 DLL 时的无限递归

通常,这个问题有一个简单的解决方案:声明一个线程局部变量,它计算我们所处的递归的“深度”,并且只在第一次执行绕行函数中的代码(计数器 == 1):


图 29 – 计算递归深度的线程局部变量

不幸的是,我们不能在 DLL 中使用线程局部变量,原因有两个:

  1. 隐式 TLS__declspec(thread)))严重依赖 CRT,而 CRT 对我们来说是不可用的。

  2. 显式 TLS API(TlsAlloc() / TlsFree() 等)完全在 kernel32.dll 中实现,其 64 位版本不会加载到 WoW64 进程中。

尽管有这些限制,wow64.dll 确实使用 TLS 存储,可以通过查看“!wow64exts.info”命令的输出来验证:

图 30 – WoW64 DLL 使用的 TLS 变量

事实证明,wow64.dll 不会在运行时动态分配 TLS 插槽,而是使用 TlsSlots 数组中的硬编码位置,可直接从 TEB 访问(已基于每个线程实例化)。

图31 – Wow64SystemServiceEx将线程局部变量写入TlsSlots数组中的硬编码位置

经过一些实证测试,我们发现 64 位 TEB 中的大多数 TLS 插槽从未被 WoW64 DLL 使用过,因此出于此 PoC 的目的,我们可以预先分配其中一个来存储我们的计数器。无法保证此插槽在将来的 Windows 版本中保持未使用状态,因此生产级解决方案可能会研究 TEB 的其他一些可用成员。

图 32 – 使用 TEB 中未使用的成员来计算递归的“深度”。


结论

我们的“Deep Hooks”系列的第三部分也是最后一部分到此结束。在这三篇文章中,我们介绍了几种不同的方法,将 64 位 DLL 注入 WoW64 进程,然后使用它来挂接 64 位 NTDLL 中的 API 函数。希望此选项将使安全产品受益,因为它们可以更好地了解 WoW64 流程,并使其更能抵御“天堂之门”等绕过。

本系列中介绍的方法仍有其局限性,以新的缓解选项的形式出现,例如动态代码限制、CFG 导出抑制和代码完整性保护。启用后,这些可能会阻止我们创建钩子或完全阻止我们的注入,但在以后的帖子中会详细介绍。