Part 1

简介

这篇文章是由三部分组成的系列文章之中的第一篇,该系列文章描述了在 WoW64应用程序(在64位Windows平台上运行的32位进程)中连接本机NTDLL时必须克服的困难。正如某些来源所记录的那样,WoW64进程包含两个版本的NTDLL。第一个是专用的32位版本,它将系统调转到WoW64环境,并在那里进行调整以适应x64 ABI第二个本机64位版本,由WoW64环境调用,最终负责用户模式到内核模式的转换。
由于在连接64位NTDLL时遇到了一些技术困难,大多数与安全相关的产品在这样的进程中只钩住了32位模块。唉,从攻击者的角度来看,绕过这些32位钩子与它们提供一些众所周知的技术帮助相比是微不足道的。尽管如此,为了调用系统并执行其他各种任务,最终这些技术中的大多数会调用NTDLL的本机(即64位)版本。因此,通过连接本机NTDLL,端点保护解决方案可以更好地了解进程的操作,并在一定程度上对旁路通道的影响更有弹性。
本文中,我们将介绍将64位模块注入WoW64应用程序的方法。下一篇文章将更深入地研究其中一种方法,并深入研究处理CFG-aware系统需要修改的一些细节。本系列的最后一篇文章将介绍为了钩住64位的NTDLL,必须更改现成的挂钩引擎应用。
当我们开始这项研究时,我们决定把主要精力放在Windows 10上。我们提供的所有注入方法都在几个Windows 10版本(主要是RS2和RS3)上进行了测试,如果在较老的Windows版本上使用,可能需要不同的方法。

注入

向WoW64应用程序中注入64位模块始终是可能的,尽管在这样做时需要考虑一些限制。通常,WoW64进程包含很少的64位模块,即本机ntdll.dll和组成WoW64环境本身的模块:WoW64.dll,wow64cpu.dll, wow64win.dll。不幸的是,通常使用的Win32子系统dll的64位版本(例如kernelbase.dll, kernel32.dll, user32.dll等)没有加载到进程的地址。强迫进程加载这些模块中的任何一个都是可能的,尽管有些困难和不可靠
因此,作为成功而可靠的注入的第一步,我们应该去掉候选模块的所有外部依赖项,除了本机NTDLL。在源代码级别,这意味着对更高级别Win32 api(如VirtualProtect())的调用将必须被本地对应api(在本例中是NtProtectVirtualMemory())的调用所取代。还需要进行其他修改,将在本系列的最后部分详细讨论。


图1 导入描述符的DLL (NTDLL)

wow64log.dll劫持

正如之前Walied Assar所发现的可知,在初始化时,WoW64环境尝试加载一个64位DLL,名为wow64log.dll直接从system32目录。如果找到这个DLL,它将被加载到系统中的每个WoW64进程中,因为它导出了一组特定的、定义良好的函数。dll目前并没有附带Windows的零售版本,这种机制实际上可以被滥用为注入方法,只需劫持这个dll并将我们自己的版本放到system32中。


图2 ProcMon捕获了一个WoW64进程并试图加载wow64log.dll

这种方法的主要优点在于它非常简单——注入模块所需要做的就是将其部署到前面提到的位置,然后让系统加载器完成剩下的工作。第二个优点是,加载这个DLL是WoW64初始化阶段的合法部分,因此它支持所有当前可用的64位Windows平台。

但是,这种方法有一些可能的缺点,比如wow64log.dll的Dll文件可能已经存在于system32目录中,尽管(如上所述)它在缺省情况下并不存在。其次,由于对LdrLoadDll()的底层调用最终是由系统代码发出的,因此该方法几乎不能控制注入过程。这限制了我们从注入中排除某些进程、指定模块何时加载的能力等等。

天堂之门

只需自己对LdrLoadDll()发出调用,而不是让内置系统机制代我们调用,就可以实现更多对注入过程的控制。实际上,这并不像看上去那么简单。可以如此假设,32位图像加载器将拒绝任何加载64位图像的尝试,从而停止这种操作过程。因此,如果我们希望将本机模块加载到WoW64进程中,我们必须以某种方式通过本机加载器。我们可以分为两个阶段:
1.获得在目标进程中执行任意32位代码的能力。
2.对64位版本的LdrLoadDll()进行调用,将目标DLL的名称作为其参数之一传递。
如果能够在目标进程的上下文中执行32位代码(有很多方法),那么我们仍然需要一个可以自由调用64位api的方法。一种方法是利用所谓的“天堂之门”。
“天堂之门”是一种常用的技术的名称,它允许32位二进制文件执行64位指令,而无需通过WoW64环境强制执行的标准流。这通常通过用户发起的对代码段 0x33的控制传输来完成,代码段0x33将处理器的执行模式从32位兼容模式切换到64位长模式。


图3 一个执行x86代码的线程,在它转换到x64领域之前。

在跳转到x64领域之后,直接调用64位NTDLL的选项变得很容易。在使用漏洞和其他潜在恶意程序的情况下,这允许它们避免攻击放置在32位api上的钩子。对于DLL注入器,这解决了手头的问题,因为它打开了调用64位版本的LdrLoadDll()的可能性,该版本能够加载64位模块。


图4-出于演示目的,我们使用Blackbone库成功地使用Heaven 's Gate将64位模块注入WoW64进程。

我们将不再详细介绍“天堂之门”的具体实现,但好奇的读者可以在这里了解更多。

利用APC进行注入

随着向系统中加载内核模式驱动程序的能力的提高,可供我们使用的注入方法的数量显著增加。在这些方法中,最流行的可能是通过APC注入:它被一些 一些AV供应商恶意开发者广泛使用,甚至可能被 CIA使用。
简而言之,APC(异步过程调用)是一种内核机制,它提供了一种在特定线程上下文中执行定制例程的方法。一旦被分派,APC将异步转移目标线程的执行流以调用所选的例程。
apc可分为两大类:

1. 内核模式`APCs: APC`例程最终将执行内核模式代码。这些被进一步分为特殊的内核模式的apc和普通的内核模式的apc,但是我们不会详细讨论 [它们之间的细微差别](http://www.opening-windows.com/download/apcinternals/2009-05/windows_vista_apc_internals.pdf)。
2. 用户模式`APCs: APC`例程最终将执行用户模式代码。只有当拥有apc的线程变得可警报时,才会发出用户模式apc。这是我们将在本节其余部分中讨论的APC类型。

apc主要用于系统级组件,用于执行各种任务(例如促进I/O完成),但也可以用于DLL注入目的。从安全产品的角度来看,内核空间的APC注入提供了一种方便可靠的方法,可以确保特定模块被加载到(几乎)整个系统所需的每个进程中。
对于64位NT内核,负责初始调度用户模式APCs(用于本机64位进程和WoW64进程)的函数是从本机NTDLL导出的KiUserApcDispatcher()的64位版本。除非APC发行者另有明确要求(通过PsWrapApcWow64Thread()), APC例程本身也将执行64位代码,因此能够加载64位模块。
通过APC实现DLL注入的经典方法是使用所谓的“适配器thunk”。适配器thunk是写入目标进程地址空间的位置无关代码的一小段。它的主要目的是从用户模式APC的上下文中加载一个DLL,因此它将根据KNORMAL_ROUTINE规范接收它的参数:


图5 用户模式APC过程的原型,取自wdm.h**如上图所示,

如上图所示,KNORMAL_ROUTINE类型的函数接收三个参数,第一个参数是NormalContext。与WDM模型中的许多其他“上下文”参数一样,这个参数实际上是指向用户定义结构的指针。在我们的例子中,我们可以使用这个结构将以下信息传递到APC过程中:

1.  用于加载DLL的API函数的地址。在WoW64进程中,这必须是本地LdrLoadDll(),作为64位内核32的版本。dll没有加载到进程中,因此无法使用LoadLibrary()及其变体。
2.  我们希望加载到进程中的DLL的路径。

一旦适配器thunk被KiUserApcDispatcher()调用,它就会解包NormalContext,并使用给定的DLL路径和其他一些硬编码参数对提供的loader函数发出调用:


图6 典型的“适配器thunk”设置为用户模式APC的目标

为了更好地使用这种技术,我们编写了一个标准的内核级APC注入器,并对其进行了修改,使其能够支持向WoW64进程注入64位dll(如附录a所示)。尽管很有希望,但是当尝试将我们的DLL注入到任何支持CFG的WoW64进程时,进程崩溃了,并且出现了CFG验证错误。


图7 由于试图调用适配器thunk而导致的CFG验证错误

预告:

在下一篇文章中,我们将深入研究CFG的一些实现细节,以帮助理解这种注入方法失败的原因,并提出几种可能的解决方案来克服这个障碍。

附录

附录A APC注入的完整源代码与适配器thunk

原文:https://www.sentinelone.com/blog/deep-hooks-monitoring-native-execution-wow64-applications-part-1/

Part 2

前情回顾

在本系列的 第一部分中,我们介绍了几种能够向WoW64进程注入64位DLL的方法,最终目的是使用这个DLL在进程中钩住64位API函数。
我们通过APC提供注入完成了这篇文章,发现在CFG-aware进程测试时,注入DLL失败,导致进程崩溃。为什么要理解为这样,所以我们必须深入了解实现CFG的一些细节。

CFG简介

CFG(控制流保护)是一个相对较新的漏洞缓解程序,最初在Windows 8.1更新3中引入,后来在Windows 10中得到增强。它是一种支持编译器的缓解措施,旨在通过防止对非合法目标的间接调用来对抗内存损坏漏洞。在每次间接函数调用之前,编译器会向位于NTDLL中的专用验证例程插入一个额外的调用。这个例程接收调用目标,并在8字节的粒度范围内检查它是否是函数的起始地址。如果不是,则安全检查失败(i)。


图8 Mimikatz,使用(右)和不使用(左)CFG编译
为了使这种验证更简单和高效,CFG使用了一个专门为此添加了新内存区域,称为CFG位图。在这个位图中,每个位表示进程地址空间中8字节的状态,并标记它们是否构成有效的调用目标。由于这种映射比率,位图必须是进程虚拟地址空间总数的1/64,在64位进程中可以得到相当大的-2TB,其总地址空间是128TB。
显然,在64位进程中,这个位图的大部分都是未提交的,因为实际上只使用了进程地址空间的很小一部分。只有在引入新的可执行页面时(通过直接分配虚拟内存、映射节对象的视图或将页保护更改为可执行文件),内核才提交并设置位图中与该页对应的位。

WoW64进程中的CFG

Alex Ionescu在他的 博客文章《关闭“天堂之门”》中描述了WoW64工艺中CFG的一些独特特征。如图所示,支持CFG的WoW64进程不是一个而是两个单独的CFG位图:

*  一个本地位图,为进程中的64位代码标记有效的调用目标。由于这个位图必须对32位代码不可访问,所以它位于4GB边界之上,通常位于本机NTDLL的旁边。
*  一个WoW64位图,为进程中的32位代码标记有效的调用目标。它的预留大小是32MB,因为它只覆盖较低的4GB地址空间(32位代码可以在此运行)。显然,它总是位于4GB边界以下,通常在主图像旁边。

因为WoW64进程有两个CFG位图和两个版本的NTDLL加载到它们中,所以很自然地,还有两个版本的验证函数。该函数的32位版本根据WoW64位图检查提供的地址,而64位版本根据本机位图检查地址。


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

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

图10 当内存被映射到进程时,部分调用堆栈显示对MiSelectCfgBitMap()MiSelectBitMapForImage()的调用
这两个函数的伪代码如下:

图11.1 Windows 10 x64 RS3下MiSelectCfgBitMap()的伪代码。


图11.2 MiSelectBitMapForImage()的伪代码(同Windows 10 x64 RS3下)
从这两个函数可以得出一些结论:

  1. 可以预料到所有32位模块都在WoW64位图中标记。
  2. 所有64位模块都在本机位图中标记,包括那些映射到较低的4GB地址空间的模块。这很重要,因为否则本机NTDLL将无法与包含WoW64环境的本机dll进行互操作。例如,NTDLL甚至不能加载这些模块,因为对其入口点的调用将导致CFG验证逻辑失败。
  3. 在4GB以下的所有私有内存分配都在WoW64位图中标记,不管谁分配它们或出于什么目的。正如机敏的读者可能已经注意到的那样,在图8所示的示例中,4GB边界以上的所有地址空间都被保留了(本地NTDLL和本地CFG位图除外)。由于内存不能从保留区域分配,这实际上意味着所有私有内存分配都将被单独标记在WoW64位图中。
    现在很清楚为什么前面展示的使用APC的DLL注入技术注定会失败:尽管“适配器thunk”包含64位代码,但它是一个私有内存分配,因此它将填充WoW64位图。但是,负责APCs初始调度的函数是KiUserApcDispatcher()的64位版本,它将尝试根据本机位图验证thunk的地址,但没有成功。
    因此,如果我们希望维护APC注入功能,我们必须以某种方式修改我们的技术以克服CFG验证问题。
    ## APC注入回顾
    对于CFG实现的细节有一些预先的知识,可以建议使用VmCfgCallTargetInformation类调用NtSetInformationVirtualMemory(),将适配器thunk简单地标记为有效的调用目标。尽管这个选择很有前途,但实际上并不能解决问题。原因是在内部,NtSetInformationVirtualMemory()依赖于MiSelectCfgBitMap()来帮助决定哪一个位图应该受到影响。出于与前面描述的相同的原因,当与适配器thunk的地址一起提供时,MiSelectCfgBitmap()仍将返回WoW64位图,从而保持原生位图不变。

    因为NtSetInformationVirtualMemory()内部依赖MiSelectCfgBitMap()故其只影响WoW64的版本。

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

“一个WoW64进程

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

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

图12 在Windows 10 RS3中看到的EPROCESS结构的部分视图。注意,Wow64Process指针位于偏移量0x428处。
考虑到这一点,我们可以应用基于DKOM-based的解决方案,其中wow64进程在为适配器thunk分配内存之前被归零,然后恢复到初始值。

图13 使适配器“咚”一声占据本地位图的伪代码
附录B中给出的这个解决方案使我们的APC注入在支持cfg的WoW64进程中获得成功,并在Windows 10 RS3上进行了测试。
这种方法虽然简单,但也有一些明显的缺点。首先,需要修改的EPROCESS结构在很大程度上是没有文档记录的,并且在Windows版本之间 经常更改。因此,结构内部的wow64进程的偏移量不能依赖于保持不变,必须在运行时进行试探性搜索。其次,清除WoW64Process成员可能会有一些意想不到的副作用和危险,尤其是在进程包含多个线程的情况下。
综上所述,这是使APC注入器在cfg敏感过程中工作的一个有效选择,但是它相当不稳定和不可靠,应该非常谨慎地使用。考虑到这些缺点,我们希望找到一个更可靠的问题解决方案,最好不依赖于私有的可执行内存分配。

Thunkless APC注入

在初始化APC时,可以设置APC例程来指向我们选择的任何函数,无论是现有的函数还是我们专门为此目的创建的函数。这意味着——至少在理论上——我们可以通过创建一个APC来注入DLL,这个APC将直接调用本机LdrLoadDll(),而不需要通过适配器的“砰砰”声。显然,LdrLoadDll()是64位代码的有效调用目标,因此它可以充当APC目标,而不会触发CFG冲突。
但是,在二进制级别上似乎存在一个问题:LdrLoadDll()KNORMAL_ROUTINE的原型不匹配。LdrLoadDll()需要4个参数,而KNORMAL_ROUTINE类型的函数似乎只接收3个参数:


图14.1 LdrLoadDll()的原型

图14.2 KNORMAL_ROUTINE的原型
不过,每个人都应该考虑__fastcall调用协定使用依照x64 ABI:每个函数的前四个参数传递给它通过寄存器RCX、RDX、R8 R9机型,所以当LdrLoadDll()将由KiUserApcDispatcher()无论值目前持有的R9机型将解释为第四个参数。根据上述原型,LdrLoadDll()接收到的第四个参数被声明为“_out_phandle ModuleHandle”。这意味着要使LdrLoadDll()成功,R9必须包含一个指向可写内存位置的有效指针,该位置能够保存指针大小的数据。
不幸的是,由于标准的APC过程只需要三个参数,显然无法在APC初始化期间为第四个参数指定值。因此,R9在进入APC例程时所持有的值基本上是未知的。因此问题就出现了:我们能否以某种方式保证R9将持有一个有效的指针,以满足所有LdrLoadDll()需求?令人惊讶的是,这个问题的答案是肯定的,但是我们怎么能确定呢?



图15 KeInitializeApcKeInsertQueueApc的原型。用户模式APC例程(NormalRoutine)的用户控制参数以红色突出显示。
在探索APC调度的一些内部方面的文章中, Skywing演示了64位的KiUserApcDispatcher()实际上向APC例程发送了第四个“隐藏”参数,指向一个上下文结构。这个结构保存了在APC调度进程完成时通过NtContinue()恢复的CPU状态。尽管这篇文章相当陈旧,但在Windows 10等较新的系统中查看KiUserApcDispatcher()的实现可以看出,这仍然适用:

图16 Windows 10 RS3中本机NTDLL KiUserApcDispatcher()实现的一部分。注意,指向上下文结构的RSP被移动到R9中。
因此,我们可以得出这样的结论:在这个场景中,LdrLoadDll()作为ModuleHandle接收的值总是指向一个可写的内存块,它包含一个上下文结构,从而允许成功的注入。然而,覆盖上下文结构的成员可能会有风险;如果任何重要信息被销毁,那么在调用NtContinue()之后,当试图恢复执行时,线程可能会崩溃。正如我们之前看到的,LdrLoadDll()只向ModuleHandle所指向的内存位置写入8字节(x64上的指针大小),因此它只会覆盖上下文结构的第一个成员,它恰好是P1Home:

图17 -上下文结构的前0x34字节。传递给APC例程的参数存储在该上下文的偏移量0、0x8和0x10中,而APC例程的地址存储在偏移量0x18中。
幸运的是,上下文结构的前四个成员实际上用于KiUserApcDispatcher()的 储存参数,并且在APC例程本身执行之后不再需要这些参数。为了确保覆盖P1Home确实是安全的,只需查看KiUserApcDispatcher()的prolog,如图16所示。通过仔细检查它的prolog,我们可以看到KiUserApcDispatcher()具有某种独特的调用约定。堆栈的顶部指向前面提到的上下文结构,除了CPU状态之外,这个结构还封装了APC例程的地址和传递给它的其他三个参数的值。
通过将图17所示的这个结构的偏移量与图16所示参数的偏移量相关联,我们可以得出这样的结论:

  • P1Home持有NormalContext
  • P2Home持有sysarg1
  • P3Home持有sysarg2
  • P4Home持有NormalRoutine,这是将从KiUserCallForwarder()调用的APC例程的地址。

    图18 -调用APC例程时上下文结构的前0x30字节
    由于成员P1Home到P4Home从未用于保存任何与cpu相关的数据,因此NtContinue()不会使用它们来恢复上下文。知道了这一点,我们可以假设从APC例程中重写P1Home没有什么害处。现在,我们可以重新创建注入器(如附录C所示),通过排队一个直接调用LdrLoadDll()的APC,将本机模块注入任何WoW64进程,而不会导致臭名昭著的CFG违规错误。
    ## 小结
    这将结束本系列的第二部分。在前两篇文章中,我们演示了使用几种不同的方法将64位dll注入WoW64进程的能力。显然,有更多的方法可以这样做,但是找到它们是留给感兴趣的读者作为练习。
    接下来:使用x64挂钩引擎来支持挂接本机NTDLL。
    ## 附录
    ### 附录B WoW64进程安装

附录C 无针APC注射

原文:https://www.sentinelone.com/blog/deep-hooks-monitoring-native-execution-wow64-applications-part-2

Part 3

前情回顾

前面(第一部分第二部分)我们演示了将64位模块注入WoW64进程的几种不同方法。这篇文章将继续我们之前的话题,另外还会描述如何在这样的进程中利用执行64位代码的能力来钩住本地的x64 api。为了完成这项任务,注入的DLL必须拥有一个能够在WoW64进程的本机区域中运行的挂钩引擎。不幸的是,我们检查过的所有挂钩引擎都无法做到开箱即用,因此我们不得不修改其中一个引擎,以使其满足我们的需求。

创建被注入的DLL

选择合适的Hook引擎

Hook技术是计算机安全领域中一种成熟的技术,被防御者和攻击者广泛使用。自从1999年一篇开创性的文章《绕道而行:Win32函数的二进制拦截》发表以来,已经开发了许多不同的挂钩库。它们中的大多数与本文中介绍的概念相似,但在其他方面有所不同,比如它们对各种CPU架构的支持、对事务的支持等等。在这些库中,我们必须选择一个最适合我们需求的库:
1.支持x64函数的内联挂钩功能。
2.开源和免费许可——以便我们可以合法地修改它。
3.最好的情况是,挂钩引擎应该是相对最小的,以便需要尽可能少的修改。
在考虑了所有这些需求之后,我们选择将MinHook作为我们的首选引擎。最终有利于它的是它的小代码基,这使得它在PoC中相对容易使用。后面介绍的所有修改都是在它之上完成的,如果使用另一个挂钩引擎,可能会略有不同
我们的修改挂钩引擎的完整源代码可以在这里

没有依赖性

在第1部分中,我们简要地提到了没有任何64位模块可以轻松地加载到WoW64进程中。大多数dll倾向于使用(隐式和显式)常见的Win32子系统dll中的各种函数,例如kernel32.dll, user32。但是,这些模块的64位版本默认不加载到WoW64进程中,因为WoW64子系统不需要这些64位版本来操作。此外,由于地址空间布局的一些限制,强迫进程加载其中任何一个都有些困难和不可靠。
为了避免不必要的麻烦,我们选择修改挂钩引擎和托管它的DLL,以便它们只依赖通常在WoW64进程中找到的本地64位模块。基本上,这只剩下了本地的NTDLL,因为包含WoW64环境的dll通常不包含对我们有益的函数。
在更实际的意义上,为了强制构建环境只链接NTDLL,我们在链接器设置中指定/NODEFAULTLIB标记,并显式地添加“NTDLL”。额外依赖的列表:

图19 主机DLL的链接器配置

API重新实现

这一变化带来的第一个也是最值得注意的影响是,更高级别的Win32 API函数不能供我们使用,必须使用NTDLL对应的函数重新实现。如图20所示,对于MinHook使用的每个Win32 API,我们引入了一个替换函数,它具有相同的公共接口并实现相同的核心功能,而在内部只使用NTDLL工具。
大多数时候,这些“转换”相当简单(例如,对VirtualProtect()的调用几乎可以直接替换为对NtProtectVirtualMemory()的调用))。在其他更复杂的情况下,Win32 API函数与本地函数之间的映射并不清楚,因此我们不得不求助于一些反向工程或在反应物源内部.进行窥探。

图20 VirtualProtect()的私有实现

* 项目配置

在MinHook中重新实现了所有Win32 API调用之后,我们仍然有很多错误:

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

  • 禁用基本运行时检查(从命令行删除/RTC标志)
  • 禁用缓冲区安全检查(/GS-标志)
  • 入口点必须显式指定为DllMain,因为DllMainCRTStartup没有链接。
  • 另外,memcpy()和memset()必须手动实现,或者替换为从NTDLL导出的对RtlCopyMemory()和RtlFillMemory()的调用。

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

图22 -一个极简的DLL,只有一个导入描述符(NTDLL)

连接本机NTDLL

一旦我们修改了挂钩引擎以匹配上述所有限制,我们就可以更深入地了解挂钩机制本身。MinHook以及大多数此类库所使用的挂钩技术被称为“[内联Hook]"(https://www.malwaretech.com/2015/01/inline-hooking-for-programmers-part-1.html)”。这种技术的内部工作是相当详细的记录,但这里是这个方法包括的步骤的简化描述:

1. 在进程的地址空间中分配一个“蹦床”,并将最终被钩住的函数的序言复制到其中。
2. 将JMP指令放在蹦床中,就在复制的prolog之后。这个JMP应该指向原函数序言后面的指令。
3. 在蹦床中放置另一条JMP指令,就在复制的prolog之前。这个JMP应该指向一个detour函数(通常在我们之前注入到进程中的DLL中)。
4. 用指向蹦床的JMP指令覆盖钩子函数prolog。


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

图23.2 蹦床的视图。用红色标记的是跳转到detour函数,用绿色标记的是从钩子函数复制的指令,然后跳转回该函数。
这个挂钩方法通过修改钩子函数的序言来工作,因此每当应用程序调用它时,都会调用detour函数。然后,detour函数可以执行任何代码之前、之后或替代原始函数。
在64位模式下,大多数挂钩引擎使用两种不同类型的跳转来实现挂钩功能和蹦床:

  1. 从钩形函数到蹦床的跳转是一个编码为“E9 <4字节偏移>”的相对跳转。由于该指令在dword大小的操作数上运行,因此蹦床距离钩形函数的距离必须不超过2GB。这种形式的跳转通常被选择用于此步骤,因为它只占用5个字节,因此它足够紧凑,可以整齐地放入函数的prolog中。
  2. 从蹦床跳到detour函数再跳到钩子函数,如图23.2所示,是被编码为“FF25 <4字节偏移>”(助记形式:JMP qword ptr [rip+偏移])的间接的、与rip相关的跳转。这条指令将跳转到一个64位的绝对地址,存储在RIP指向的位置加上偏移量。

在本地64位进程中运行时,使用这种技术的挂钩引擎工作得很好。可以预料,蹦床与目标函数的距离很短(高达2GB),因此允许成功的二进制插装。
然而,最近对WoW64进程内存布局的一些更改保证了,如果没有一些额外的更改,这种技术就不能应用于本机NTDLL。约内斯库亚历克斯在他的博客,最近Windows版本(从Windows 8.1更新3),本机NTDLL已经搬迁:而不是被加载到低4 gb的地址空间与其他过程的模块,现在加载到一个更高的地址。

图24 Windows 10(左)和Windows 7(右)64位NTDLL的基本地址。
4GB边界上的其余地址空间(本机NTDLL和本机CFG位图除外)由SEC_NO_CHANGE VAD保护,因此任何人都不能访问、分配或释放地址空间。这意味着蹦床总是被分配在地址空间的4GB下面。由于64位系统中总的用户模式地址空间是128TB,所以本机NTDLL和蹦床之间的距离肯定会远远大于2GB。这使得大多数挂接引擎释放的JMP都不够用。

图25 说明WoW64进程在Windows 8.1和更高版本上的内联钩子需要的控制传输。注意,在Windows 10 RS4预览版(build 17115)中,SEC_NO_CHANGE VADs似乎已经不存在了,内存可以在进程地址空间的任何地方分配。

* JMP的另一种形式

为了克服这个问题,我们必须用不同的指令替换相对的JMP,该指令能够通过128TB的距离。在寻找替代品时,我们偶然发现了Gil Dabah列出了一些可能的选项的帖子。在取消所有“玷污”注册表的选项之后,我们只剩下几个可行的选项。最初,我们试图用一种类似于蹦床使用的间接的、与rip相关的JMP替换相对JMP:

这条指令在Windows 10上运行良好,为我们提供了一种在WoW64进程中测试各种本机API函数的方法。但是,当在Windows 8.1和Windows 7等早期Windows版本上测试修改后的代码时,它并没有完全创建钩子。事实证明,这些Windows版本中的NTDLL函数 比Windows 10的版本短](http://blog.amossys.fr/windows10_TH2_int2E_mystery.html)中的要短,并且通常不包含足够的空间来容纳我们选择的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时,无限递归
对于这个问题,通常有一个简单的解决方案:声明一个线程局部变量,它计算我们所处的递归的“深度”,并且第一次只在detour函数内部执行代码(counter == 1):

图29 -计算递归深度的线程局部变量
不幸的是,我们不能在DLL中使用线程局部变量,原因有两个:
1.隐式TLS (__declspec(thread))很大程度上依赖于CRT,我们无法使用它。
2.显式TLS api (TlsAlloc() / TlsFree()等)完全在kernel32中实现。它的64位版本没有加载到WoW64进程中。
尽管有这些限制,wow64.dll确实使用TLS存储,可以通过查看“!wow64exts.info”命令的输出来验证:

图30 WoW64 dll使用的TLS变量
结果是,Wow64。dll不会在运行时动态分配TLS插槽,而是在tlsslot数组中直接从TEB访问的硬编码位置(已经在每个线程的基础上实例化了)。

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

经过一些经验测试,我们发现WoW64.dll从未使用过64位TEB中的大多数TLS插槽,因此对于这个PoC,我们可以预先分配其中一个来存储计数器。不能保证这个插槽在未来的Windows版本中不会使用,所以产品级别的解决方案可能会查看TEB的其他可用成员。

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

总结

这是我们“深钩”系列的第三部分也是最后一部分。在这三篇文章中,我们介绍了几种不同的方法,将64位DLL注入到WoW64进程中,然后使用它在64位NTDLL中挂钩API函数。希望这个选项能够让安全产品更好地了解WoW64进程,并使它们对“天堂之门”之类的绕过更有弹性。

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

原文:https://www.sentinelone.com/blog/deep-hooks-monitoring-native-execution-wow64-applications-part-3

点击收藏 | 0 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖