理解新的缓解措施:模块篡改保护
几个月前,我在Paranoia会议上讨论了晦涩且未公开的缓解措施。演讲后,一些人问我如何发现这些缓解措施,以及如何弄清楚它们的功能和工作原理。因此,我决定聚焦其中一种缓解措施,展示完整的研究过程,并说明其背后的思路如何用于其他目的。
我选择了模块篡改保护。首先,我会解释它是什么以及它的作用,针对那些只关心结论的读者;然后,我会展示完整的过程,供那些希望复现这项工作或学习一些逆向工程技术的人参考。
简要说明:什么是模块篡改保护?
模块篡改保护是一种缓解措施,用于防止进程主映像的早期修改,例如IAT钩挂或进程镂空。它总共使用三个API:NtQueryVirtualMemory、NtQueryInformationProcess和NtMapViewOfSection。如果启用,加载器会在调用入口点之前检查主映像头和IAT页面的更改。它通过调用NtQueryVirtualMemory并使用信息类MemoryWorkingSetExInformation来实现这一点。返回的结构包含页面的共享状态信息,以及它是否从其原始视图被修改。如果头或IAT从其原始映射中被修改(例如,如果主映像已被取消映射,而另一个映像被映射到其位置),加载器将调用NtQueryInformationProcess并使用类ProcessImageSection来获取主映像节的句柄,然后使用NtMapViewOfSection重新映射它。从那时起,将使用新节,而忽略被篡改的映像副本。
此缓解措施自RS3起可用,并可以使用PROCESS_CREATION_MITIGATION_POLICY2_MODULE_TAMPERING_PROTECTION_MASK在进程创建时启用。
完整分析
对于那些对从一无所知到全面了解这一缓解措施的全过程感兴趣的读者,让我们开始吧。
发现缓解措施
我偶尔会被问到,当微软从未宣布或记录这些类型的缓解措施时,人们如何甚至发现它们的存在。因此,一个值得关注的地方是EPROCESS结构中的各种MitigationFlags字段。目前有三个MitigationFlags字段(MitigationFlags、MitigationFlags2、MitigationFlags3),每个包含32位。前两个的32位已被完全使用,因此最近添加了MitigationFlags3,目前包含三种缓解措施,我相信很快会有更多添加。这些标志表示进程中启用的缓解措施。例如,我们可以使用WinDbg打印当前进程的EPROCESS.MitigationFlags:
|
|
在末尾的第28和29位,我们可以看到值EnableModuleTamperingProtection和EnableModuleTamperingProtectionNoInherit。不幸的是,搜索这些名称并没有得到任何好的结果。有几个网站只显示结构而没有解释,一个模糊的Stack Overflow答案简要提到了EnableModuleTamperingProtectionNoInherit但没有添加细节,还有这条推文:
不出所料,最详细的解释是Alex Ionescu在2017年的一条推文。这并不完全是完整的文档,但这是一个开始。如果你已经了解并理解构成此缓解措施的概念,这一系列推文可能非常清晰,并解释了有关该功能的所有内容。如果你不熟悉底层概念,这可能引发的问题比答案更多。但别担心,我们会逐步分解它。
我们从哪里开始?
第一个要回答的问题是:这个缓解措施在哪里实现?Alex通过函数名给我们一些方向,但如果他没有,或者自2017年以来情况发生了变化(或者你选择不相信他),你会从哪里开始?
寻找进程缓解措施实现的第一个地方通常是内核:ntoskrnl.exe。然而,这是一个巨大的二进制文件,不容易搜索。没有任何函数名似乎与这个缓解措施相关,因此没有明显的起点。
相反,你可以尝试不同的方法,尝试找到对EPROCESS的MitigationFlags字段的引用,并访问这两个标志之一。但除非你可以访问Windows源代码,否则没有简单的方法可以做到这一点。然而,你可以利用EPROCESS是一个大结构的事实,并且MitigationFlags位于其末尾,偏移量0x9D0。一种非常不优雅但有效的方法是使用IDA搜索功能,并搜索所有对9D0h的引用:
(编辑:尝试立即搜索而不是文本搜索。它会运行得更快,同时仍然获得所有相关结果)
这将非常慢,因为它是一个大的二进制文件,并且一些结果与EPROCESS结构无关,因此你必须手动搜索结果。此外,仅仅找到对该字段的引用是不够的——MitigationFlags包含32位,只有两位在当前上下文中相关。因此,你必须在所有结果中搜索出现以下情况的地方:
- 0x9D0被用作EPROCESS结构的偏移量——你必须在这里使用一些直觉,因为没有保证的方法知道每种情况使用的结构类型,尽管对于较大的偏移量,只有少数几个选项可能相关,并且大多可以通过函数名和上下文猜测。
- MitigationFlags字段被比较或设置为0x10000000(EnableModuleTamperingProtection)或0x20000000(EnableModuleTamperingProtectionNoInherit)。或者通过汇编指令如
bt或bts测试或设置第28或29位。
运行搜索后,结果看起来像这样:
你现在可以遍历结果,并感受内核在哪些情况下使用了哪些缓解标志。然后我会告诉你,这个努力完全无用,因为EnableModuleTamperingProtection在内核中只有一个地方被引用:PspApplyMitigationOptions,在创建新进程时调用:
因此,内核跟踪此缓解措施是否启用,但从不测试它。这意味着缓解措施本身在其他地方实现。这个搜索对于这个特定的缓解措施可能无用,但它是发现缓解措施实现位置的几种方法之一,并且可能对其他进程缓解措施有用,所以我想提到它,即使它愚蠢且不起眼。
但回到模块篡改保护——进程缓解措施有时实现的第二个位置是ntdll.dll,每个进程中加载的第一个用户模式映像。这个DLL包含加载器、系统调用存根和所有进程需要的许多其他基本组件。这个缓解措施在这里实现是有道理的,因为名称表明它与模块加载有关,而模块加载通过ntdll.dll中的加载器发生。此外,这个模块包含Alex在他的推文中提到的函数。
即使我们没有这条推文,只需打开ntdll并搜索“tampering”很快就能找到 exactly one result:函数LdrpCheckPagesForTampering。寻找此函数的调用者,我们看到它从一个地方调用,LdrpGetImportDescriptorForSnap:
在截图的第一行,我们可以看到两个检查:第一个验证当前正在处理的条目是主映像,因此正在加载的模块是主映像模块。第二个检查是针对LdrSystemDllInitBlock.MitigationOptionsMap.Map[1]中的两个位。我们只能在这里看到确切的字段,因为我应用了正确的类型到LdrSystemDllInitBlock——如果你在没有应用正确类型的情况下查看此函数,你会看到一些随机的、未命名的内存地址被引用。LdrSystemDllInitBlock是一个数据结构,包含加载器需要的所有全局信息,例如进程缓解选项。它是未文档化的,但具有类型PS_SYSTEM_DLL_INIT_BLOCK,可在符号中使用,因此我们可以在这里使用它(注意,此结构在NTDLL符号中不可用,而是在ole32.dll和combase.dll的符号中找到)。MitigationOptionsMap字段只是一个包含三个ULONG64的数组,包含标记为此进程设置的缓解选项的位。我们可以在WinBase.h中找到所有缓解标志的值。以下是模块篡改保护的值:
|
|
这些值相对于Map[1]的顶部DWORD,因此模块篡改保护位实际上在Map[1]的第44位——与Hex Rays截图(以及之前在PspApplyMitigationOptions中显示的)中检查的相同。
现在我们知道了这个缓解措施在哪里应用和检查,因此我们可以开始查看实现并理解这个缓解措施的作用。
实现细节
再次查看LdrpGetImportDescriptorForSnap:在我们已经看到的两个检查之后,函数获取主映像的NT头,并两次调用LdrpCheckPagesForTampering。第一次,发送的地址是imageNtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]——映像的导入表——和8字节的大小。第二次,函数使用NT头本身的地址和大小调用。如果这些页面之一被认为被篡改,LdrpMapCleanModuleView被调用以(根据名称判断)映射主映像模块的干净视图。
让我们查看LdrpCheckPagesForTampering内部,看看NTDLL如何决定页面是否被篡改:
首先,此函数计算请求字节范围内的页面数(在我们在这里看到的两种情况下,该数字为1)。然后它分配内存并调用ZwQueryVirtualMemory,其中MemoryInformationClass == 4(MemoryWorkingSetExInformation)。这个系统调用和信息类可能是安全人员不常看到的——工作集是一种基于其当前状态管理和优先处理物理内存页面的方式,因此对大多数安全人员来说不常有趣。然而,工作集确实携带一些可能引起我们兴趣的属性。具体来说,“共享”标志。
我不会在这里详细讨论映射和共享内存,因为它们在其他地方有大量解释。但简而言之,系统尝试不复制内存,因为那将意味着物理内存将迅速填满重复的页面, mostly those belonging to images and DLLs——系统DLL如ntdll.dll或kernel32.dll在系统的大多数(如果不是全部)进程中被映射,因此为每个进程在物理内存中拥有单独的副本 simply be wasteful。因此,这些映像页面在所有进程之间共享。也就是说,除非映像以任何方式被修改。映像页面使用一种称为写时复制的特殊保护,它允许页面可写,但如果页面被写入,将在物理内存中创建新副本。这意味着对DLL的本地映射所做的任何更改(例如,用户模式钩子的写入或任何数据更改),将仅影响当前进程中的DLL。
这些设置保存为标志,可以通过NtQueryVirtualMemory查询,使用这里使用的信息类:MemoryWorkingSetExInformation。它将返回关于查询页面的数据,在MEMORY_WORKING_SET_EX_INFORMATION结构中:
|
|
这个结构给你被查询的虚拟地址,以及包含页面状态信息的位,例如:其有效性、保护、是否是大页面,以及其共享状态。有几位与页面的共享状态相关:
- Shared——页面可共享吗?这不一定意味着页面当前与任何其他进程共享,但例如,私有内存不会被共享,除非进程特别请求。
- ShareCount——这个字段告诉你存在多少此页面的映射。对于当前未与任何其他进程共享的页面,这将是1。对于与其他进程共享的页面,这通常更高。
- SharedOriginal——这个标志指示这是否是此页面的原始映射。因此,如果页面被修改,导致在物理内存中创建新副本,这将被设置为零,因为这不是页面的原始映射。
这个SharedOriginal位是LdrpCheckPagesForTampering检查以判断此页面是原始副本还是由于更改创建的新副本的位。如果这不是原始副本,这意味着页面以某种方式被篡改,因此函数将返回TRUE。LdrpCheckPagesForTampering对每个被查询的页面运行此检查,并将返回TRUE如果任何页面被篡改。
如果函数对任何检查的范围返回TRUE,LdrpMapCleanModuleView被调用:
这个函数简短而简单:它调用NtQueryInformationProcess,其中InformationClass == 89(ProcessImageSection