今天我们探索一个问题: 64位的ntdll是如何被加载到WoW64下的32位进程?今天的旅程将会带领我们进入到Windows内核逻辑中的未知领域,我们将会发现32位进程的内存地址空间是如何被初始化的。
WoW64是什么?
来自MSDN:
WOW64是允许32位Windows应用程序无缝运行在64位Windows的模拟器。
换句话说,随着64位版本Windows的引进,Microsoft需要拿出一种允许在32位时代的Windows程序与64位Windows新的底层组件无缝交互的解决方案。特别是64位内存寻址和与内核直接交流的组件。
两个NT层,一个内核
在32位的Windows系统中,要调用Windows API的应用程序需要经过一系列的动态链接库(DLL)。然而,所有的系统调用最终会定向到ntdll.dll,它是在用户模式下将用户模式API传递给内核的最高层。以调用CreateFileW为例,这个API调用源于用户模式下的kernel32.dl,随后它以NtCreateFile传递给ntdll,随后NtCreateFile通过系统调度程序将控制权传递给内核。
在32位Windows下这是非常简单的,然而,在WoW64下需要额外的步骤。32位的ntdll不可以直接将控制权交给内核,因为内核是64位的,只接受遵循64位ABI的类型(译者注:ABI,Application Binary Interface,应用二进制接口)。正因为如此,一个翻译层以几个标准的命名为wow64.dll,wow64cpu.dll和wow64win.dll的DLL的形式被添加到64位Windows。这几个DLL负责将32位调用转换成64位调用。那些调用最终被定向到映射到每个32位进程中的64位ntdll。许多关于这种从32位系统调用到64位系统调用(1)的神奇转换的信息是可获得的,所以我们不会从这里进入。我们最关注的是内核何时和怎样将64位版本的ntdll映射到一个32位进程。看起来像这样:
我们特别关注倒数第二项。我们能发现ntdll被映射到地址是64位地址范围(7FFFFED40000-7FFFFEF1FFFF),而且它的位置在Windows 64位系统文件所在的System32\路径下。然而,我们知道32位进程不可以访问或者运行在64位内存空间。
为了理解上面输出的内容,我们首先讨论VAD(Virtual Address Descriptor,虚拟地址描述符)是什么和它将如何帮助我们理解加载64位dll到32位进程的机制的。
什么是虚拟地址描述符?
VAD是Windows操作系统跟踪系统中可用物理内存的许多方法之一。VAD专门跟踪每个进程用户模式范围的保留的和提交的地址。任何时候一个进程请求一些内存,一个新的VAD实力被创建用来跟踪内存。
VAD被构造成一个自平衡树,每个节点描述了一段内存范围。每个节点至多包含两个子节点,左边是低地址,右边是高地址。每个进程被分配一个VadRoot,之后通过遍历VadRoot来分辨额外用来描述保留或提交的虚拟地址范围的额外节点。我们需要关注WindDBG中的!vad命令的输出,因为这是我们将大量使用来跟踪64位Windows中32位进程的映射的输出。对于这个练习,不是所有的域对我们来说都是特别有趣的。我们考虑测试程序HelloWorld.exe的输出。通过!process ProcessObject 命令的输出来分辨我们进程的VadRoot。
一旦我们确定了VadRoot,我将地址输入到 !vad 命令。(输出为了容易分析已被截断)
我们看到五列: "VAD", "Level", "Start", "End", 和"Commit".!vad命令 接受VAD实例的地址;在我们的例子中,我们已经为它提供了在此进程中通过使用!process命令获得的VadRoot。
VAD地址是当前VAD结构体或实例的地址:
等级(Level)描述了这个VAD实例(节点)在所在树中的级别。Level 0是从上面!process输出中获得的VadRoot。
开始(Starting)和结束(Ending)地址值用VPN(Virtual Page Numbers,虚拟页数量)表示。这些地址可以通过乘以页面大小(4kb)或者左移3位转化为虚拟地址。结束VPN会添加一个额外的0xFFF来扩展到页面末尾。如我们上面例子中的D20->D20000,DD20->DD2FFF。
提交(Commit)是被此VAD实例描述的范围内提交页面的数量。
分配类型(type of allocation)告诉我们改特定范围是否已经被映射或是进程私有的。
访问类型(Type of access)描述改范围内的允许访问。最后是被映射到当前区域对应的名称。
一个AVD实例可以以多种方式创建。如通过使用映射API(CreateFileMapping/MapViewOfFile)或者内存分配API如VirutalAlloc函数。内存可以是保留或者提交的(或free的),或保留和部分提交的。无论哪一种,一个VAD项被映射到进程的Vad树来让内存管理器知道此进程中当前已提交的内存。我们对VAD 的观察将揭示WoW64下运行的32位进程的初始设置。
映射NT子系统DLL
进程初始化的早期,在主可执行文件被映射和初始化之前,Windows为特殊区域确定和保留一些地址范围。其中包含初始进程地址空间,共享系统空间(_KUSER_SHARED_DATA),控制流守护位图区域,和NT本地子系统(ntdll)。由于进程初始化整体的复杂性,我们只关注最后一块,它包含32位ntdll和64位ntdll加载到32位进程地址空间的逻辑。我们关注一系列的API调用和在每个点的内存区域的虚拟地址描述符(VAD)。为了让内核区分怎样映射一个新进程,它需要知道是否这是一个WoW64进程。当进程对象最初被创建,内核通过读取名为_EPROCESS.Wow64Process的未文档化结构体_EPROCESS结构体的值来实现此操作。
PspAllocateProcess是我们探索开始的地方,但是更具体的说,我们开始在MmInitializeProcessAddressSpace()。
MmInitializeProcessAddressSpace()负责与一个新进程地址空间有关的初始化。它调用MiMapProcessExecutable,该函数创建了定义初始进程可寻址内存空间的VAD项,随后将新创建的进程映射到它的基虚拟地址。
一个特别有趣的函数是PspMapSystemDlls。我们关注在调用PspMapSystemDlls之前的进程地址空间的样子。在WinDBG中确保我们当前处于我们测试应用程序的上下文中(.process),并寻找当前VadRoot(!vad output)。
到目前为止我们可以观察到,我们的进程在32问地址空间中被映射和分配了一个基地址(1200),内核共享内存(0x7FFE0000-0x7FFE0FFF) 和64KB保留内存区域(0x7FFE1000-0x7FFEFFFF) 也已经被映射到他们各自的虚拟地址。
PspMapSystemDlls 通过一个包含多个平台子系统模块的全局指针迭代。对于x86和x64Windows,这些是分别位于 C:\Windows\SysWow64 和C:\Windows\System 目录中的ntdll.dll。
一旦 PspMapSystemDlls 发现要加载的DLL,它调用 PspMapSystemDll 来映射他们(DLLs)到进程的地址空间。该函数非常简短,下面展示了一个片段。为了正确映射本地子系统,需要满足一些条件。
PspMapSystemDll 通过调用 MmMapViewOfSection 实现实际的本地DLL的映射,并保存所占的基地址。在这两个DLL映射完成并且他们的VAD项初始化完成后,我们的32位进程地址空间看起来像这样:
所以现在,我们映射完我们的进程(0xc40000-0xcf2fff),内核共享内存空间(0x7ffe0000-0x7ffe0fff),32位地址空间的有效结束区域(0x7ffe1000-0x7ffeffff),和我们的两个NT子系统DLL。
锁定地址空间
为了完成32位进程的映射,还有最后一步要做。我们知道一个32位进程最多寻址到2GB的虚拟内存,所以Windows需要屏蔽此进程剩余的地址空间。对于32位进程,屏蔽在 0x7FFF0000 - 0x7FFFFFFF 之后;然而,0x7FFeFFFF 之后什么也不可以映射。基于此事实,紧邻64位NTDLL的内存区域需要保留或者屏蔽。要做到这一点,内核标记剩下的64位地址空间为私有。它通过遍历当前进程的VAD树和定位最后可用的虚拟地址来创建此VAD项,然后附加一个新的VAD项。
完成此任务的API是 MiInitializeUserNoAccess。该函数接受当前进程句柄和一个虚拟地址。传递的虚拟地址是 0x7FFF0000,这是32为进程最后可寻址范围的起始。然后,它遍历当前的VAD项并执行一个新范围的插入,该范围覆盖了32位进程剩余的地址空间。在此调用后,我们的进程地址空间看起来像这样:
我们现在可以发现,我们的32位进程已经映射,并且它的合规的内存地址范围已经被内核保留。涵盖 0x7FFF0 - 0x7FFFFED3F 和 0x7FFFFEF20- 0x7FFFFFFEF 范围的VAD实例已经被内核保留为私有。随后任何检索内存的调用仅仅会发生在允许的32为地址空间内。一旦进程完全加载,我们可以看到额外的已提交的内存出现在进程(0xC40000)附近的地址空间。
结束演讲
我们观察到64位Windows下的32位进程的初始映射以及64位ntdll如何被映射到64位区域,随后64位地址空间被锁定,防止用户访问,我们学到了什么?
1、早期初始化逻辑决定我们是否准备映射一个WoW64进程。
2、分配最初的32位地址空间区域;这包括最高可访问的32位地址范围,和进程首选的基虚拟地址。
3、NT子系统DLL被加载到他们各自的地址范围,32位ntdll加载到32位空间,64位ntdll加载到64位地址空间。
4、MmInitializeUserNoAccess 用来创建与64位ntdll范围相邻的西游范围。这具有从32位进程锁定64位可寻址空间的效果。
希望这篇文章提供了一些关于Windows如何允许讲32位进程无缝集成到64位Windows操作系统的透明度。随着WoW64模拟层的添加,对地址空间可用性进行了一些额外的考虑,并且这个过程反映了一些这些考虑和及其实现。