我把 Hell's Gate 拆开以后,才发现真正难的不是 SSN

从 PE 导出表、x86/x64 桩到 x32dbg 对照实验

我第一次看 Hell's Gate 的资料时,注意力几乎全在“系统调用号”上。那时我的理解很简单:查一张表,找到 NtOpenProcess 的 SSN,写一段 mov eax, ssn,再执行 syscall,事情就结束了。

真正打开 PE_Hell's Gate.exe 和源码以后,我才发现这个理解漏掉了太多东西。磁盘上的 PE 文件不是按内存布局存放的;System32 和 SysWOW64 里的 ntdll.dll 不是同一个架构;x86 和 x64 的桩函数甚至不是同一种执行方式;最后,代码虽然生成了新的“动态桩”,但它复制的是内存里的字节,而不是自己重新拼装指令。

这篇文章记录的就是这次拆解过程。我不打算把它写成“看完就懂”的教程,而更想还原一个初学者从概念、PE 结构、源码、IDA 静态分析到 x32dbg 动态验证的探索路径。文中出现的地址和字节都来自 2026 年 11 月 9 日的一次本机实验,Windows 版本为 Windows 11 Professional 10.0.26300,ntdll.dll 文件版本为 10.0.26100.9549。

一、先补课:系统调用和 SSN 到底是什么

Windows 的用户态程序不能直接执行内核里的对象管理逻辑。正常调用 OpenProcess 时,大致会经过这样一条链:

应用程序
  -> kernel32!OpenProcess
  -> ntdll!NtOpenProcess
  -> syscall / sysenter
  -> 内核 nt!NtOpenProcess

NtOpenProcess 是 Native API。它的入口位于用户态的 ntdll.dll,真正完成进程对象打开动作的则是内核中的对应例程。两者名字很像,但并不是同一个地址,也不是同一层代码。

系统调用号,也就是 SSN(System Service Number),可以理解成内核服务分发表里的索引。用户态桩函数会把 SSN 放入 EAX 或 RAX,然后使用当前架构约定的方式进入内核。x86 和 x64 的进入方式不同,后面会具体对照。

这里有一个很重要的认识:SSN 不是 Win32 API 的序号,也不是一成不变的常量。它会随着 Windows 版本、构建号以及架构变化。J00ru 的系统调用表适合用来学习和交叉验证,但不应把某个版本的 0x26 直接写死在程序里。

参考资料:

EDR 通常会在用户态 API 或 ntdll 函数入口安装钩子,例如把前几个字节改成跳转,先进入自己的检测逻辑。Hell's Gate 想解决的是这一类用户态入口监控:读取磁盘上的原始 ntdll,找到正确的 SSN,再准备一份不经过被挂钩入口的系统调用桩。

但这里不能说得过头。直接系统调用绕开的只是特定的用户态挂钩点,并不等于从系统中消失。内核回调、ETW、对象访问审计、调用栈特征、可执行内存和异常行为仍然可能存在。把这门技术理解成“理解调用边界的一种方法”,比理解成“万能隐身”更接近事实。

二、第一次对照:System32 和 SysWOW64

我先把 C:\Windows\System32\ntdll.dll 和 C:\Windows\SysWOW64\ntdll.dll 分别拿出来看。在 64 位 Windows 上,32 位进程访问 System32 时会发生文件系统重定向,通常会被带到 SysWOW64。所以同一份源码在 x86 和 x64 调试环境里读到的文件,可能完全不同。

在当前机器上,NtOpenProcess 的导出 RVA 和文件偏移并不是一个数字:

SysWOW64 ntdll:
  RVA        = 0x000785B0
  file offset= 0x000779B0
  runtime    = 0x777585B0
​
System32 ntdll:
  RVA        = 0x00166890
  file offset= 0x00166890

x86 的桩函数前 15 个字节是:

B8 26 00 00 00    mov eax, 26h
BA 20 A2 79 77    mov edx, 7779A220h
FF D2             call edx
C2 10 00          ret 10h

x64 的桩函数则是另一种形态:

4C 8B D1                   mov r10, rcx
B8 26 00 00 00             mov eax, 26h
F6 04 25 08 03 FE 7F 01    test byte ptr [7FFE0308h], 1
75 03                      jne +3
0F 05                      syscall
C3                         ret
CD 2E                      int 2Eh

看到这里,我第一次真正意识到“x86 和 x64 不能共用一个桩”不是一句提醒,而是字节级别的现实。x86 版本通过 KiFastSystemCall 一类的共享跳板进入内核;x64 版本则把原本在 RCX 里的第一个参数搬到 R10,然后执行 syscall。如果只记住 B8 SSN,漏掉后面的参数寄存器和跳转指令,代码在真实调用时就会出问题。

三、PE 解析:函数名怎么变成文件偏移

在文件里查找 NtOpenProcess 时,最容易犯的错误是把 RVA 当成文件偏移。PE 文件按 FileAlignment 存储,加载到内存后按 SectionAlignment 映射,两者并不相同。

源码的解析路径可以整理为:

IMAGE_DOS_HEADER
  -> e_lfanew
  -> IMAGE_NT_HEADERS
  -> DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]
  -> IMAGE_EXPORT_DIRECTORY
  -> AddressOfNames
  -> AddressOfNameOrdinals
  -> AddressOfFunctions

这里三个数组的关系很值得初学者动手画一遍:

  1. AddressOfNames[i] 给出第 i 个导出名称字符串的 RVA。

  2. AddressOfNameOrdinals[i] 给出这个名称对应的函数序号。

  3. AddressOfFunctions[ordinal] 给出函数真正的 RVA。

  4. RvaToFileOffset 把函数 RVA 转换成文件中的字节位置。

源码中的核心逻辑是:

DWORD functionRva =
    AddressOfFunctions[AddressOfNameOrdinals[i]];
​
*functionAddress =
    RvaToFileOffset(ntdllBase, functionRva, fileSize);

转换公式是:

fileOffset = rva
           - SectionHeader.VirtualAddress
           + SectionHeader.PointerToRawData;

如果 RVA 落在 SizeOfHeaders 之内,可以直接使用 ntdllBase + rva。如果在某个节里,就遍历节表,找到包含它的节,再做上面的减法。

在实验机上,SysWOW64\ntdll.dll 的 NtOpenProcess 导出了 RVA 0x785B0,转换后得到文件偏移 0x779B0。运行时模块基址是 0x776E0000,所以函数地址是 0x776E0000 + 0x785B0 = 0x777585B0。这三个数字对应起来以后,PE 地址空间第一次在我脑子里连成了一条线。

四、源码拆解:四段数据流

当前源码的主流程可以概括成四步:

ReadNtdllFromDisk
  -> FindExportFunction
  -> ExtractSysCallNumber
  -> GetMemoryNtOpenProcessStub
  -> CreateDynamicNtOpenProcess

1. 从磁盘读取 ntdll

程序先通过 GetSystemDirectoryA 拼出 ntdll.dll 路径,再用 CreateFileA 和 ReadFile 读入缓冲区。这里的目标是拿到一份通常未被 EDR 改写的磁盘副本。

2. 解析文件并拒绝非 x86

DetectArch 检查 MZ 头、PE 签名和 FileHeader.Machine。但主流程随后明确要求:

if (peArch != ARCH_X86) {
    VirtualFree(ntdllBuffer, 0, MEM_RELEASE);
    return ERROR_CODE;
}

这意味着当前项目的 x64 编译配置并不能真正完成 x64 Hell's Gate。枚举里虽然有 ARCH_X64,业务逻辑没有继续支持它。

3. 提取 SSN

x86 逻辑只检查第一个字节是否为 0xB8:

if (funcBytes[0] != x86_MOV_EAX_OPCODE) {
    return ERROR_CODE;
}
​
*syscallNumber =
    *(PDWORD)(&funcBytes[x86_SYSCALL_NUMBER_OFFSET]);

这就是为什么 0x26 能从 B8 26 00 00 00 中被读出来。x64 情况不同,B8 前面还有 4C 8B D1,所以 SSN 在函数首地址 +4,不是 +1。

4. 复制内存桩并创建动态函数

GetMemoryNtOpenProcessStub 通过 GetProcAddress 找到已加载的 NtOpenProcess,读取 15 个字节,保存到全局结构:

memcpy(g_ntOpenProcessStub.opCodes, funcBytes, 15);
g_ntOpenProcessStub.opCodeSize = 15;
g_ntOpenProcessStub.syscallNumber =
    *(PDWORD)(&funcBytes[1]);

CreateDynamicNtOpenProcess 分配一块 PAGE_EXECUTE_READWRITE 内存,复制这些字节,再把地址保存到 g_NtOpenProcess:

PVOID execMemory = VirtualAlloc(
    NULL,
    g_ntOpenProcessStub.opCodeSize,
    MEM_COMMIT | MEM_RESERVE,
    PAGE_EXECUTE_READWRITE);
​
memcpy(execMemory,
       g_ntOpenProcessStub.opCodes,
       g_ntOpenProcessStub.opCodeSize);
​
g_NtOpenProcess = execMemory;

这里有一个容易被忽略的事实:当前实现不是“从 SSN 重新拼装一份桩”,而是把已加载 ntdll 里的 15 个字节整段复制到新内存。磁盘文件主要用来提供 SSN 和做一致性参考,真正被执行的字节来自内存中的 NtOpenProcess。

五、IDA Pro :先看静态,再看边界

我用 IDA Pro 94 加载了这个 32 位可执行文件,并通过 IDA Pro MCP 查询了关键函数的反编译结果。IDA 给出的信息比只看 C 源码更容易暴露编译器实际生成的调用方式。

例如 main 中直接调用 g_NtOpenProcess 的位置,最终对应到:

push edx
call dword ptr ds:[0x001EC2CC]

IDA 显示 g_NtOpenProcess 位于全局区,InitializeHellGate 会先读取磁盘 SSN,再读取内存桩,比较两者,最后创建动态函数。静态视图也让几个工程问题变得很清楚:

  • ExtractSysCallNumber1 的 x64 分支模式识别写了一半,读取 SSN 时仍使用 pBytes + 1。

  • RvaToFileOffset 没有完整检查 RVA 是否落在 SizeOfRawData 内,也没有处理整数溢出。

  • FindExportFunction 没有处理转发导出,也没有验证导出目录数组边界。

  • PAGE_EXECUTE_READWRITE 让新桩同时可写可执行,工程化实现更适合先写后改成只读可执行。

  • 动态桩和文件缓冲区在成功路径上没有统一释放。

六、x32dbg :把“我以为”变成证据

静态分析只能告诉我“代码准备做什么”,不能证明运行时真的发生了什么。于是我把可执行文件复制到独立实验目录,用 x32dbg 打开副本,并在调用点下断点。

IDA 中的调用点 RVA 是 0x1305A。程序的 IDA ImageBase 是 0x400000,但运行时模块基址被加载到了 0x1D0000,所以断点地址换算为:

0x1D0000 + 0x1305A = 0x1E305A

断点命中时,g_NtOpenProcess 的值为 0x01990000。栈上的四个参数依次是:

[ESP + 0x00] = 0x00F3F8B8   // hProcess2 的地址
[ESP + 0x04] = 0x001FFFFF   // PROCESS_ALL_ACCESS
[ESP + 0x08] = 0x00F3F898   // OBJECT_ATTRIBUTES
[ESP + 0x0C] = 0x00F3F888   // CLIENT_ID
[ESP + 0x10] = 0x001E3060   // 返回地址

第一个参数指向的地址在调用前是未写入状态,调用后变成了 0x0000011C。这说明系统调用确实执行了,并且 NtOpenProcess 把新句柄写回了指针。

进入 g_NtOpenProcess 以后,CPU 视图显示了一块全新的动态内存:

01990000  B8 26 00 00 00  mov eax, 26h
01990005  BA 20 A2 79 77  mov edx, 7779A220h
0199000A  FF D2           call edx
0199000C  C2 10 00        ret 10h

这和磁盘、内存中的 NtOpenProcess 字节完全一致。动态内存地址在另一次调试中变成了 0x011E0000,说明 VirtualAlloc 的返回值并不固定,不能把这类地址当成稳定符号使用。

继续单步执行:

执行 mov eax, 26h      -> EAX = 0x00000026
执行 mov edx, ...      -> EDX = 0x7779A220
执行 call edx          -> 返回 EIP = 0x0199000C
执行调用后的检查       -> EAX = 0x00000000

最后 hProcess2 = 0x0000011C,OBJECT_ATTRIBUTES.Length = 0x18,CLIENT_ID.UniqueProcess 的值也等于当前 PID。到这里,“磁盘 SSN、运行时模块地址、动态桩、函数调用、返回句柄”终于形成了一条完整的证据链。

七、第二组对照:只改后段字节会发生什么

第一次实验成功后,我又问了一个问题:如果某个钩子保留了开头的 B8 26 00 00 00,只修改后面的 call edx,当前代码能不能发现?

为了不修改系统文件,我只在调试器的进程内存里构造这个场景:

原始内存桩:
  B8 26 00 00 00
  BA 20 A2 79 77
  FF D2
  C2 10 00
​
实验修改:
  FF D2 -> 90 90

修改发生在 g_ntOpenProcessStub.opCodes + 10,位置是 0x001EC1AE。此时 mov eax, 26h 仍然保留,因此 ExtractSysCallNumber 仍然会得到 0x26。然后我让 CreateDynamicNtOpenProcess 继续执行,结果程序仍然创建了一份新桩:

011E0000  B8 26 00 00 00  mov eax, 26h
011E0005  BA 20 A2 79 77  mov edx, 7779A220h
011E000A  90              nop
011E000B  90              nop
011E000C  C2 10 00        ret 10h

这次调用返回后:

EAX        = 0x00000026
hProcess2  = 0x00000000

EAX 不再是无错误的 STATUS_SUCCESS,而 hProcess2 仍然是空值。原因很直观:call edx 被两条 nop 替代了,内核系统调用根本没有发生。

这个对照实验比读十遍源码更有用。它说明:

  • 只比较磁盘和内存中的 SSN,不能证明整段桩没有被修改。

  • 如果只检查第一个字节是否是 B8,同样不能发现后段跳转、调用目标或尾部的变化。

  • 更稳妥的校验应该比较完整桩字节、关键指令模式或对桩计算哈希,并在不一致时拒绝使用。

  • 动态内存中的“新函数”并不自动等于“干净函数”,它的安全性取决于复制来源和校验方式。

八、Hell's Gate 的优点和代价

优点很直接:

  • 不依赖固定 SSN,而是从当前系统的 ntdll 动态提取,适合理解版本差异。

  • 可以绕开针对 NtOpenProcess 等用户态函数入口的特定挂钩。

  • 代码量不大,能完整串起 PE 解析、导出表遍历、RVA 转换和函数指针调用。

代价也很明确:

  • 它只避开用户态挂钩,不会让内核回调、ETW 或对象访问记录消失。

  • 动态分配可执行内存、读取 ntdll、非常规调用栈都可能成为检测特征。

  • 当前源码只支持 x86,x64 的 Syscall 桩、寄存器约定和分支逻辑没有完成。

  • 手工解析 PE 如果缺少边界检查,遇到异常文件容易越界或崩溃。

  • 复制内存桩如果缺少完整校验,可能把被修改的代码复制到新地址。

  • PAGE_EXECUTE_READWRITE 不是好习惯,工程化实现应尽量缩短可写窗口并使用 W^X。

所以 Hell's Gate 更适合作为理解 Windows 用户态、Native API 和内核调用边界的实验,而不是“加上就隐形”的通用方案。

九、初学者可以怎样复现

我不建议一上来就在真实系统目录里打补丁,也不建议直接修改系统 ntdll.dll。更安全的顺序是:

  1. 先阅读微软 PE 格式文档,理解 DOS 头、NT 头、节表和数据目录。

  2. 用只读脚本解析 ntdll.dll,把导出名称、序号、函数 RVA 和文件偏移都打印出来。

  3. 在 IDA 里看 FindExportFunction 和 RvaToFileOffset 的反编译,确认每条地址转换。

  4. 在 x32dbg 里断在 call dword ptr [g_NtOpenProcess],先观察参数,再进入动态桩。

  5. 用调试器的进程内存做对照实验,不要修改磁盘上的系统文件。

  6. 每次动态分配地址变化时,都重新从全局变量读取函数指针,不要复制上一次实验的绝对地址。

如果只做一次练习,我建议先完成“静态字节一致、运行时 SSN 一致、调用返回成功”这三件事,再去尝试修改后段字节,观察校验逻辑是否能发现问题。

十、学习链接

我的源码

#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h>
#include<stdlib.h>
#include<windows.h>



#define SUCCESS_CODE 0
#define ERROR_CODE 1

#define x86_MOV_EAX_OPCODE  0xB8
#define x86_SYSCALL_NUMBER_OFFSET 1
#define x86_SYSCALL_STUB_SIZE 15
typedef enum _SystemArch {
	ARCH_UnKnow = 1,
	ARCH_X86,
	ARCH_X64,

}SystemArch;

typedef struct  
{
	DWORD syscallNumber;//系统调用号
	BYTE opCodes[30];//
	DWORD opCodeSize;
	SystemArch arch;
	char functionName[0xFF];

}SysCallStub;
typedef struct _UNICODE_STRING
{
	USHORT Length;
	USHORT MaximumLength;
	PWSTR Buffer;
} UNICODE_STRING, * PUNICODE_STRING;

typedef struct _OBJECT_ATTRIBUTES
{
	ULONG Length;
	HANDLE RootDirectory;
	PUNICODE_STRING ObjectName;
	ULONG Attributes;
	PVOID SecurityDescriptor;
	PVOID SecurityQualityOfService;
} OBJECT_ATTRIBUTES, * POBJECT_ATTRIBUTES;

typedef struct _CLIENT_ID
{
	HANDLE UniqueProcess;
	HANDLE UniqueThread;
} CLIENT_ID, * PCLIENT_ID;

typedef NTSTATUS(NTAPI* PFN_NtOpenProcess)(
	PHANDLE ProcessHandle,
	ACCESS_MASK DesiredAccess,
	POBJECT_ATTRIBUTES ObjectAttributes,
	PCLIENT_ID ClientId
	);

//全局变量
BOOL g_isInitialized = FALSE;
HMODULE g_hNtdll = NULL;
SysCallStub g_ntOpenProcessStub = {0};
PFN_NtOpenProcess g_NtOpenProcess = NULL;

//定义函数
int InitializeHellGate(VOID);
BOOL InitializeNtdllMoudule(VOID);



PVOID ReadNtdllFromDisk(const char* filePath, PDWORD fileSize);
SystemArch DetectArch(PVOID fileBuffer);
int FindExportFunction(PVOID ntdllBase, const char* functionName, PVOID* functionAddress, DWORD fileSize);
PVOID RvaToFileOffset(PVOID ntdllBase, DWORD rva, DWORD fileSize);
int ExtractSysCallNumber(PVOID functionAddress,PDWORD syscallNumber);
DWORD ExtractSysCallNumber1(PVOID functionAddress);
BOOL IsValidSSN(DWORD ssn);
int GetMemoryNtOpenProcessStub(void);
int CreateDynamicNtOpenProcess(void);
DWORD GetFunctionSize(PVOID functionAddress);
int main() {
	/*
	8B F4                mov         esi,esp
	6A 00                push        0
	6A 00                push        0
	6A 00                push        0
	FF 15 00 B0 0D 00    call        dword ptr [__imp__OpenProcess@12 (0DB000h)]
	3B F4                cmp         esi,esp
	E8 8D FA FF FF       call        __RTC_CheckEsp (0D1244h)
	90                   nop


	INT表(OpenProcess -kernel32.dll)       IAT (FunAddr- 替换[MyFunction])
	
	IAT HOOK

	Kernel.32.dll    OpenProcess
	kernelBase32.dll OpenProcess
	ntdll.dll        ZwOpenProcess  上面为应用层 SYS CALL NUMBER
	-------------------------------
	ntoskrnl.exe     NtOpenProcess  下面为内核层

	无视应用层的hook
	
	*/

	

	//初始化Hell Gate
	if (InitializeHellGate()!= SUCCESS_CODE)
	{

		printf("[-] Hell's Gate 系统初始化失败\r\n");
		system("pause");
		return ERROR_CODE;
	}
	DWORD pid = GetCurrentProcessId();

	//系统调用
	HANDLE hProcess1 = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);

	//直接调用
	HANDLE hProcess2 = NULL;
	OBJECT_ATTRIBUTES objAttr = { 0 };
	objAttr.Length = sizeof(objAttr);
	CLIENT_ID clientId = { 0 };
	clientId.UniqueProcess = (HANDLE)(ULONG_PTR)pid;

	NTSTATUS status = g_NtOpenProcess(&hProcess2, PROCESS_ALL_ACCESS, &objAttr, &clientId);


	//句柄测试
	DWORD dwBuffer = 0;
	ReadProcessMemory(hProcess2, 0x400000, &dwBuffer, 4, NULL);


	return 0;

}

int InitializeHellGate(VOID) {
	//检查是否已经初始化

	if (g_isInitialized)
	{
		printf("[!] Hell's 系统已经初始化\r\n");
		return SUCCESS_CODE;

	}
	printf("[+] Hell'Gate 系统开始初始化\r\n");
	//Image Ntdll Info
	if (!InitializeNtdllMoudule())
	{

		return ERROR_CODE;
	}

	//File Ntdll Info
	char ntdllPath[MAX_PATH];

	//获取系统目录路径
	GetSystemDirectoryA(ntdllPath, sizeof(ntdllPath));
	strcat(ntdllPath, "\\ntdll.dll");
	//磁盘读取模块数据
	DWORD ntdllSize = 0;
	PVOID ntdllBuffer = NULL;

	ntdllBuffer = ReadNtdllFromDisk(ntdllPath, &ntdllSize);
	if (!ntdllBuffer)
	{

		return ERROR_CODE;
	}
	//判断当前项目架构
	SystemArch peArch = DetectArch(ntdllBuffer);
	if (peArch!=ARCH_X86)
	{
		printf("[-] 不支持当前文件架构/r/n");
		VirtualFree(ntdllBuffer, 0, MEM_RELEASE);

		return ERROR_CODE;
	}


	//获取导出函数地址
	PVOID ntOpenProcessAddr = NULL;
	if (FindExportFunction(ntdllBuffer,"NtOpenProcess",
		&ntOpenProcessAddr,ntdllSize) != SUCCESS_CODE)
	{

		VirtualFree(ntdllBuffer, 0, MEM_RELEASE);

		return ERROR_CODE;

	}

	//提取系统调用号
	DWORD diskSyscallNumber = 0;
	if (ExtractSysCallNumber(ntOpenProcessAddr, &diskSyscallNumber))
	{
		VirtualFree(ntdllBuffer, 0, MEM_RELEASE);

		return ERROR_CODE;
	}

	//ExtractSysCallNumber1(ntOpenProcessAddr);
	//模块映像获取存根

	if (GetMemoryNtOpenProcessStub())
	{

		VirtualFree(ntdllBuffer, 0, MEM_RELEASE);

		return ERROR_CODE;
	}

	//系统调用数据校验
	if (g_ntOpenProcessStub.syscallNumber!=diskSyscallNumber)
	{
		printf("[!] 系统调用号对比 磁盘(0x%08lX)与内存(0x%08lX)不一致!\r\n", (unsigned long)diskSyscallNumber, (unsigned long)g_ntOpenProcessStub.syscallNumber);
		VirtualFree(ntdllBuffer, 0, MEM_RELEASE);
		return ERROR_CODE;
	}
	//创建动态调用函数

	
	if (CreateDynamicNtOpenProcess())
	{

		VirtualFree(ntdllBuffer, 0, MEM_RELEASE);
		return ERROR_CODE;
	}
	g_isInitialized = TRUE;
	printf("[+] Hell's Gate 系统初始化完成\r\n");





	return SUCCESS_CODE;

}

BOOL InitializeNtdllMoudule(VOID) {
	//动态获取ntdll.dll内存基址
	g_hNtdll = LoadLibraryA("ntdll.dll");
	if (!g_hNtdll)
	{  //输出错误信息与错误码
		printf("[-] 加载ntdll.dll 失败 错误码 %d \r\n",GetLastError());
		return FALSE;
	}
	//输出成功信息与模块基址
	printf("[+] 加载ntdll.dll 成功 基址 0x%p \r\n",g_hNtdll);



	return TRUE;
}

PVOID ReadNtdllFromDisk(const char* filePath, PDWORD fileSize)
{
	//参数验证
	if (!filePath||!fileSize)
	{
		printf("[-] 传入参数无效");
		return NULL;
	}
	//打开文件
	HANDLE fileHandle = CreateFileA(filePath,GENERIC_READ,
		FILE_SHARE_READ,NULL,OPEN_EXISTING,FILE_ATTRIBUTE_NORMAL,NULL);

	if (fileHandle==INVALID_HANDLE_VALUE)
	{
		printf("[-] 打开NTdll.dll文件失败");
		return NULL;

	}
	//文件大小
	*fileSize = GetFileSize(fileHandle, NULL);
	if (*fileSize==INVALID_FILE_SIZE)
	{
		printf("[-] 获取  %s文件大小失败\r\n", filePath);
		CloseHandle(fileHandle);
		return NULL;

	}
	//申请内存

	PVOID filebuffer = VirtualAlloc(NULL, *fileSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);

	if (!filebuffer)
	{
		printf("[-] 申请  %s的内存失败\r\n", filePath);
		CloseHandle(fileHandle);
		return NULL;
	}
	//文件数据
	DWORD bytesRead = 0;
	if (!ReadFile(fileHandle,filebuffer,*fileSize,&bytesRead,NULL)||bytesRead!=*fileSize)
	{
		printf("[-] 文件数据读取失败");
		VirtualFree(filebuffer, 0, MEM_RELEASE);
		CloseHandle(fileHandle);
		return NULL;

	}
	//释放资源
	CloseHandle(fileHandle);
	//用户信息
	printf("[+] 磁盘读取文件 %s 数据成功 大小 %d \r\n", filePath, *fileSize);



	return filebuffer;
}

SystemArch DetectArch(PVOID fileBuffer)
{
	PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)fileBuffer;

	// 检查 MZ
	if (dos->e_magic != IMAGE_DOS_SIGNATURE)
		return ARCH_UnKnow;

	// 通过 e_lfanew 定位 PE 头
	PIMAGE_NT_HEADERS32 nt =
		(PIMAGE_NT_HEADERS32)((PBYTE)fileBuffer + dos->e_lfanew);

	// 检查 PE\0\0
	if (nt->Signature != IMAGE_NT_SIGNATURE)
		return ARCH_UnKnow;

	// 读取 PE FileHeader 中的 Machine
	switch (nt->FileHeader.Machine) {
	case IMAGE_FILE_MACHINE_I386:
		return ARCH_X86;
	case IMAGE_FILE_MACHINE_AMD64:
		return ARCH_X64;
	default:
		return ARCH_UnKnow;
}

}

int FindExportFunction(PVOID ntdllBase, const char* functionName, PVOID* functionAddress, DWORD fileSize)
{//参数校验
	if (!ntdllBase||!functionName||!functionAddress)
	{

		return ERROR_CODE;
	}

	//DOS
	PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)ntdllBase;
	if (dosHeader->e_magic!=IMAGE_DOS_SIGNATURE)
	{
		printf("dos头签名验证失效\r\n");

		return ERROR_CODE;


	}

	//NT
	PIMAGE_NT_HEADERS32 ntHeader = (PIMAGE_NT_HEADERS32)((PBYTE)ntdllBase + dosHeader->e_lfanew);
	if (ntHeader->Signature!=IMAGE_NT_SIGNATURE)
	{
		printf("NT头签名验证失效\r\n");

		return ERROR_CODE;

	}

	//DATA DIR
	PIMAGE_DATA_DIRECTORY exportDataDir = &ntHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];
	if (exportDataDir->VirtualAddress==0)
	{

		printf("导出表不存在\r\n");

	}
	//EXPRO
	PIMAGE_EXPORT_DIRECTORY exportDir = (PIMAGE_EXPORT_DIRECTORY)RvaToFileOffset(ntdllBase, exportDataDir->VirtualAddress, fileSize);
	if (!exportDir)
	{
		printf("[-] 导出表地址转换失败\r\n");

		return ERROR_CODE;
	}
	//address table
	PDWORD functionRva = (PDWORD)RvaToFileOffset(ntdllBase, exportDir->AddressOfFunctions,fileSize);
	PDWORD nameRva = (PDWORD)RvaToFileOffset(ntdllBase, exportDir->AddressOfNames,fileSize);
	PWORD indexRva = (PWORD)RvaToFileOffset(ntdllBase, exportDir->AddressOfNameOrdinals,fileSize);

	for (size_t i=0;i<exportDir->NumberOfNames;i++)
	{
		char* currentName = (char*)RvaToFileOffset(ntdllBase, nameRva[i], fileSize);
		if (!currentName)
		{
			continue;
		}
		if (strcmp(functionName,currentName)==0)
		{
			DWORD functionAddr = functionRva[indexRva[i]];
			*functionAddress = RvaToFileOffset(ntdllBase, functionAddr, fileSize);
			if (!*functionAddress)
			{
				printf("[-] 目标函数地址获取失败\r\n");
				return ERROR_CODE;

			}
			else {

				printf("[+] 目标函数地址获取成功\r\n");
				return SUCCESS_CODE;
			}
		}


	}


	return ERROR_CODE;
}

PVOID RvaToFileOffset(PVOID ntdllBase, DWORD rva, DWORD fileSize) {

	//参数验证
	if (!ntdllBase||!rva)
	{
		return NULL;
	}
	//结构定位
	PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)ntdllBase;
	PIMAGE_NT_HEADERS32 ntHeader = (PIMAGE_NT_HEADERS32)((PBYTE)ntdllBase + dosHeader->e_lfanew);
	PIMAGE_SECTION_HEADER sectionHeader = IMAGE_FIRST_SECTION(ntHeader);

	//区域判断
	if (rva<ntHeader->OptionalHeader.SizeOfHeaders)
	{
		return (PVOID)((PBYTE)ntdllBase + rva);
	}
	//遍历节区

	for (size_t i=0;i<ntHeader->FileHeader.NumberOfSections;i++)
	{
		DWORD sectionStart = sectionHeader[i].VirtualAddress;
		DWORD sectionEnd = sectionStart + sectionHeader[i].Misc.VirtualSize;
		if (rva>=sectionStart&&rva<=sectionEnd)
		{
			DWORD offset = rva - sectionStart;
			DWORD fileOffset = offset + sectionHeader[i].PointerToRawData;
			return (PVOID)((PBYTE)ntdllBase + fileOffset);



		}


	}



	return NULL;

}

int ExtractSysCallNumber(PVOID functionAddress, PDWORD syscallNumber)
{/*

 kernel32.dll -api  一个普通表
 user32.dll -api   1个GUI的表

 x86[0]=0xB8
773585B0 B8 26 00 00 00       mov         eax,26h
773585B5 BA 20 A2 39 77       mov         edx,7739A220h
773585BA FF D2                call        edx
773585BC C2 10 00             ret         10h




00007FFC83B56890 4C 8B D1             mov         r10,rcx
00007FFC83B56893 B8 26 00 00 00       mov         eax,26h
00007FFC83B56898 F6 04 25 08 03 FE 7F 01 test        byte ptr [7FFE0308h],1
00007FFC83B568A0 75 03                jne         00007FFC83B568A5
00007FFC83B568A2 0F 05                syscall
00007FFC83B568A4 C3                   ret
00007FFC83B568A5 CD 2E                int         2Eh
_KUSER_SHARED_DATA 用户态与内核态共享的地方

*/

	if (!functionAddress||!syscallNumber)
	{

		return ERROR_CODE;
	}
	PBYTE funcBytes = (PBYTE)functionAddress;
	if (funcBytes[0]!=x86_MOV_EAX_OPCODE)
	{
		printf("[-] 磁盘文件中的系统调用字节码错误\r\n");
		return ERROR_CODE;


	}

	*syscallNumber = *(PDWORD)(&funcBytes[x86_SYSCALL_NUMBER_OFFSET]);
	printf("[+] 磁盘文件中的系统调用号为 0x%08X\r\n",*syscallNumber);

	return SUCCESS_CODE;

}

DWORD ExtractSysCallNumber1(PVOID functionAddress)
{
	BYTE* pBytes = (PBYTE)functionAddress;

	__try {
		//x86
		if (pBytes[0]==0xB8)
		{
			DWORD ssn = *(PDWORD)(pBytes + 1);
			if (IsValidSSN(ssn))
			{
				printf("[+] 模式1获取到系统调用号\r\n");
				return ssn;
			}
		}

		//x64
		if (pBytes[0]==0x4C && pBytes[1] ==0x8B && pBytes[2]==0xD1)
		{
			if (pBytes[3]==0xB8)
			{

				DWORD ssn = *(PDWORD)(pBytes + 1);
				
				if (IsValidSSN(ssn))
				{
					printf("[+] 模式2获取到系统调用号\r\n");
					return ssn;
				}
				
			}
			

		}
		for (size_t i=0;i<20;i++)
		{
			if (pBytes[i]==0xB8)
			{

				DWORD ssn = *(PDWORD)(pBytes + 1+i);
				if (IsValidSSN(ssn))
				{

					printf("[+] 模式3获取到系统调用服务号");
				}
			}


		}



	}
	__except(EXCEPTION_EXECUTE_HANDLER)
	{


		printf("[-] 系统调用号提取过程中发生异常\r\n");
		return 0;
	}





	return 0;
}

BOOL IsValidSSN(DWORD ssn)
{
	if (ssn ==0)
	{
		return FALSE;
	}

	if (ssn>0xFFF)
	{

		return FALSE;
	}


	return TRUE;


}

int GetMemoryNtOpenProcessStub(void)
{  //获取模块句柄
	HMODULE ntdllMoudule = GetModuleHandleA("ntdll.dll");

	if (!ntdllMoudule)
	{
		printf("[-] 无法从内存中获取ntdll.dll模块句柄\r\n");
		return ERROR_CODE;
	}
	//获取函数地址
	PVOID ntOpenProcessFunc = GetProcAddress(ntdllMoudule, "NtOpenProcess");
	if (!ntOpenProcessFunc)
	{
		printf("[-] 无法从内存中获取ntdll.dll模块导出函数地址\r\n");
		return ERROR_CODE;
	}

	//函数基本信息
	PBYTE funcBytes = ntOpenProcessFunc;
	printf("[+] 模块句柄 0x%p 函数地址 0x%p\r\n",ntdllMoudule,ntOpenProcessFunc);
	printf("[+] 函数数据 ");

	for (size_t i=0;i<x86_SYSCALL_STUB_SIZE;i++)
	{
		printf("%02X ", funcBytes[i]);
	}

	printf("\r\n");

	//函数状态校验
	if (funcBytes[0]!=x86_MOV_EAX_OPCODE)
	{
		printf("[*] 导出函数地址内存与文件数据不一致 - %02X\r\n\r\n",funcBytes[0]);
		return ERROR_CODE;

	}

	//函数数据填充

	strcpy(g_ntOpenProcessStub.functionName, "NtOpenProcess");
	memcpy(g_ntOpenProcessStub.opCodes, funcBytes, x86_SYSCALL_STUB_SIZE);
	g_ntOpenProcessStub.opCodeSize = x86_SYSCALL_STUB_SIZE;
	g_ntOpenProcessStub.syscallNumber = *(PDWORD)(&funcBytes[x86_SYSCALL_NUMBER_OFFSET]);
	g_ntOpenProcessStub.arch = ARCH_X86;

	printf("[+] 内存映射中的系统调用号 0x%08x\r\n",g_ntOpenProcessStub.syscallNumber);





	return SUCCESS_CODE;
}

int CreateDynamicNtOpenProcess(void)
{
	if (g_ntOpenProcessStub.opCodeSize==0)
	{

		return ERROR_CODE;
	}

 PVOID execMemory=	VirtualAlloc(NULL,g_ntOpenProcessStub.opCodeSize,MEM_COMMIT|MEM_RESERVE,PAGE_EXECUTE_READWRITE);

 if (!execMemory)
 {
	 return  ERROR_CODE;

 }


 memcpy(execMemory, g_ntOpenProcessStub.opCodes, g_ntOpenProcessStub.opCodeSize);

 g_NtOpenProcess = execMemory;
 printf("[+] 直接系统调用函数创建完成\r\n");




	return SUCCESS_CODE;
}

DWORD GetFunctionSize(PVOID functionAddress)
{
	// 函数地址为空,直接返回 0
	if (!functionAddress)
		return 0;

	// 把函数地址转换成字节指针,方便逐字节读取
	PBYTE pBytes = (PBYTE)functionAddress;

	// 最多向下扫描 0x100 字节,防止异常情况下扫描过远
	DWORD maxScanSize = 0x100;

	// 用于接收 VirtualQuery 返回的内存区域信息
	MEMORY_BASIC_INFORMATION mbi = { 0 };

	// 查询函数地址所在的连续内存区域
	if (!VirtualQuery(pBytes, &mbi, sizeof(mbi)))
		return 0;

	// 内存必须已经提交,否则不能读取
	if (mbi.State != MEM_COMMIT)
		return 0;

	// PAGE_GUARD 表示保护页,访问会触发异常
	if (mbi.Protect & PAGE_GUARD)
		return 0;

	// PAGE_NOACCESS 表示完全不可访问
	if (mbi.Protect & PAGE_NOACCESS)
		return 0;

	// 计算当前内存区域的结束地址:区域基址 + 区域大小
	PBYTE regionEnd =
		(PBYTE)mbi.BaseAddress + mbi.RegionSize;

	// 计算从函数地址到当前区域末尾还剩多少字节
	SIZE_T regionRemaining =
		(SIZE_T)(regionEnd - pBytes);

	// 默认扫描 maxScanSize 字节
	DWORD scanSize = maxScanSize;

	// 如果区域剩余空间更小,就只扫描剩余部分,避免越界
	if (regionRemaining < scanSize)
		scanSize = (DWORD)regionRemaining;

	// 使用 SEH 捕获读取内存时可能发生的访问异常
	__try
	{
		// 从函数开始地址逐字节扫描
		for (DWORD i = 0; i < scanSize; i++)
		{
			// C3: ret,普通返回指令,大小 1 字节
			// CB: retf,远返回指令,大小 1 字节
			if (pBytes[i] == 0xC3 || pBytes[i] == 0xCB)
			{
				// 从函数起始位置到 ret 的总长度
				return i + 1;
			}

			// C2 xx xx: ret imm16,带一个 16 位立即数,总大小 3 字节
			// CA xx xx: retf imm16,带一个 16 位立即数,总大小 3 字节
			if ((pBytes[i] == 0xC2 || pBytes[i] == 0xCA) &&
				i + 2 < scanSize)
			{
				// ret 指令本身 1 字节,加上两个立即数字节
				return i + 3;
			}
		}
	}
	__except (EXCEPTION_EXECUTE_HANDLER)
	{
		// 读取过程中发生访问异常,返回 0
		return 0;
	}

	// 扫描范围内没有找到返回指令
	return 0;
}

PE 与系统调用基础:

API 与内存:

工具与延伸阅读:

结语

这次拆解让我改变了一个看法:Hell's Gate 的难点不在于背出某个 SSN,也不在于写出一条 syscall,而在于把几个看起来无关的细节对齐起来。

磁盘上的 PE 布局、导出表索引、RVA 到文件偏移、x86/x64 的参数约定、动态内存权限、桩字节的完整性,这些环节只要有一个没对上,程序就可能在“看起来调用成功”的假象里失败。

x32dbg 的第二组实验尤其提醒我:一个函数被复制到了新地址,不代表它仍然是一段可信的代码。真正应该问的问题是:这份代码从哪里来,是否完整,是否被修改,以及调用者有没有检查返回值。

如果把这些问题都走一遍,Hell's Gate 就不再只是一个“绕过 EDR 的名词”,而会变成一条可以逐步验证的 Windows 低层调用链。