安全攻防
morning
SSN 只是开始:一次 Hell's Gate 源码与动态调试拆解
摘要
我把 Hell's Gate 拆开以后,才发现真正难的不是 SSN 从 PE 导出表、x86/x64 桩到 x32dbg 对照实验 我第一次看 Hell's Gate 的资料时,注意力几乎全在“系统调用号”上。那时我的理解很简单:查一张表,找到 NtOpenProcess 的 SSN,写一段 mov eax, ssn ,再执行 syscall ,事情就结束了。 真正打开 PE_Hell's Gat...
ntdll
SSN
Windows
x86
x64
NtOpenProcess
mov
System32
SysWOW64
dll
2026-11-11
1 阅读
约10分钟阅读
先知社区
字号:
我把 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 这里三个数组的关系很值得初学者动手画一遍: AddressOfNames[i] 给出第 i 个导出名称字符串的 RVA。 AddressOfNameOrdinals[i] 给出这个名称对应的函数序号。 AddressOfFunctions[ordinal] 给出函数真正的 RVA。 RvaToFileOffset 把函数 RVA 转换成文件中的字节位置。 源码中的核心逻辑是: DWORD functionRva = AddressOfFunctions[AddressOfNameOrdinals[i]]; *functionAddress = RvaToFileOffset(ntdllBase, functionRva, fileSize); 转换公式是: fileOffset = rva - SectionHeader.VirtualAddress + SectionHeader.PointerToRawData; 如果 RVA 落在 SizeOfHeaders 之内,可以直接使用 ntdllBase + rva 。如果在某个节里,就遍历节表,找
这篇文章对您有帮助吗?
订阅66必读
每日精选科技资讯,直达你的邮箱