天堂之门:WoW64下的跨架构魔法

admin 2026-01-26 14:48:14 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文阐述Windows天堂之门技术,即32位程序利用WoW64机制通过CS段选择子切换至64位模式,绕过32位监控直接调用64位API。文章解析CPU分段原理,提供含Shellcode构造、PEB遍历及x64调用约定的实现方案,有效对抗沙箱与调试器。 综合评分: 90 文章分类: 二进制安全,免杀,逆向分析


cover_image

天堂之门:WoW64 下的跨架构魔法

二进制磨剑 二进制磨剑

二进制磨剑

2026年1月26日 08:03 四川

天堂之门:WoW64 下的跨架构魔法

说到 Windows 下的对抗技术,名字最酷炫的非”天堂之门”(Heaven’s Gate)莫属了。

很多刚接触二进制安全的同学听到这个名字,可能会觉得它高深莫测。实际上,它的核心原理并没有那么复杂,甚至可以说充满了一种”简单之美”。但要真正稳定地实现它,却需要对 Windows 底层机制有极深的理解。

今天,小编就基于 WoW64 子系统和 CPU 的分段机制,带大家由浅入深地推导”天堂之门”的原理和其 C 语言实现细节。

32 位程序是怎么在 64 位系统上跑的?

要理解天堂之门,小编先带大家思考一个问题:为什么我们可以在 64 位的 Windows 10 或 Windows 11 上,顺滑地运行十几年前的 32 位老游戏或老软件?

这就得归功于 Windows 的 WoW64(Windows on Windows 64)子系统。

内核永远是 64 位的

对于操作系统内核(ntoskrnl.exe)来说,它是彻头彻尾的 64 位程序,只认 64 位的系统调用(syscall)。那 32 位程序发出的指令,内核是看不懂的。WoW64 的作用,就是充当一个”翻译官”。

当一个 32 位程序运行起来时,系统不仅加载了 32 位的系统 DLL(如 32 位的 ntdll.dll),还在幕后加载了一套 64 位的 DLL。

WoW64 的核心三剑客:

  • • Wow64.dll:核心转换模块,负责管理文件系统和注册表重定向。
  • • Wow64win.dll:GUI 支持模块。
  • • Wow64cpu.dll:这是最关键的,负责CPU 模式切换

切换的瞬间:正常流程 vs 天堂之门

让我们对比一下两种调用路径,以调用 ZwOpenProcess 为例:

正常 32 位程序调用过程(WoW64):

  1. 1. 32位 ntdll.dll:程序调用 ZwOpenProcess
  2. 2. Wow64cpu.dll:ntdll 不会直接 syscall,而是跳转到 Wow64cpu.dll 的 X86SwitchTo64BitMode
  3. 3. 模式切换:执行 jmp far (如 jmp 0x33:Offset),CPU 从 32 位兼容模式切到 64 位长模式。
  4. 4. Wow64.dll:进入 64 位世界,调用 Wow64SystemServiceEx 进行参数转换(32位转64位)。
  5. 5. 64位 ntdll.dll:调用真正的 ZwOpenProcess
  6. 6. 内核:执行 syscall 进入 Ring0。

天堂之门调用过程:

  1. 1. 手动切换:不经过 32 位 ntdll,直接用 retf 将 CS 寄存器设为 0x33
  2. 2. 获取环境:从 gs:[0x60] 读取 64 位 PEB,找到 64 位 ntdll 基址。
  3. 3. 手动调用:直接调用 64 位 ntdll 中的函数。
  4. 4. 内核:执行 syscall 进入 Ring0。

区别在哪? 天堂之门完全跳过了 ntdll.dll (32位) 和 wow64.dll 的监控。如果沙箱只 Hook 了 32 位的 API,那它对我们的行为将一无所知。

调试器的噩梦

这也是为什么传统的 32 位调试器(如 OllyDbg)经常在处理 WoW64 程序时会失效。

因为调试器通常是架构绑定的,32 位调试器只能理解 32 位上下文。当程序走到跨架构指令(天堂之门)时,调试器会遇到指令集误读(把 64 位指令当 32 位解),或者出现指针丢失(32 位 EIP 与 64 位 RIP 不同步),最终导致调试器直接崩溃或程序跑飞。

段选择子

那么,CPU 是怎么知道自己该用 32 位模式跑,还是用 64 位模式跑呢?

答案藏在 CS 寄存器(代码段寄存器)里。

在 x86 保护模式下,内存是分段管理的。GDT(全局描述符表)里存放着各种段的描述符。CS 寄存器里存的不是物理地址,而是一个”索引号”,我们称之为选择子(Selector)。

Windows 操作系统对此有固定的约定:

  • • 0x23:CPU 处于 32 位兼容模式
  • • 0x33:CPU 处于 64 位长模式

为什么是 0x33?把它拆成二进制看:0000 0000 0011 0011

  • • 最低 2 位 (RPL)11 (二进制) = 3,代表 Ring 3 权限(用户态)。
  • • 第 3 位 (TI)0,代表查 GDT 表。
  • • 高 13 位 (Index)6,代表 GDT 表的第 6 项。

所以,0x33 的意思是:使用 GDT 表第 6 项描述符,以 Ring 3 权限运行。而操作系统恰好把 64 位代码段描述符放在了第 6 项。

指令级原理

修改 CS 寄存器并不像修改通用寄存器(如 EAX)那么简单。我们不能直接 mov cs, 0x33

Intel 指令集提供了 jmp far 和 retf 等指令来修改 CS。

1. jmp far (远跳转)

指令格式:jmp <选择子>:<偏移地址> 比如 jmp 0x33:0x12345678,对应的机器码可能是 EA 78 56 34 12 33 00

  • • EA:jmp far 操作码。
  • • 33 00:新的 CS 值。
  • • 78 56 34 12:新的 RIP 值。

2. retf (远返回)

这是天堂之门最常用的方式。普通的 ret 指令只弹出一个 IP,而 retf 会先弹出 IP,再弹出 CS

; 32 位模式下的汇编示意
push 0x33 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;; 压入 64 位段选择子 (CS)
push target_addr &nbsp; ; 压入目标 64 位代码地址 (RIP)
retf &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ; 远返回!此时 CS=0x33,CPU 跳转到 target_addr 进入 64 位模式

一旦 retf 执行完毕,CPU 就进入了 64 位模式。

实战:代码实现

进入 64 位模式后,我们面临的最大挑战是:原本的 32 位 C 代码编译器生成的指令是 32 位的。要想在 C 语言项目中执行 64 位逻辑,我们需要手动构造”机器码”(Shellcode),并动态分配可执行内存来运行。

环境准备

为了防止编译器自作聪明,建议进行如下配置(以 VS2019 为例):

  1. 1. 关闭优化:防止指令重排或栈帧指针被优化(Release 模式下尤其注意)。
  2. 2. 静态编译:C/C++ -> 代码生成 -> 运行库 -> 多线程 (/MT)
  3. 3. 关闭安全检查:视情况关闭 /GS 等检查。

1. 跨架构内存拷贝 (memcpy64)

在 64 位模式下,我们需要用 64 位寄存器来操作数据。我们可以利用 Python 的 keystone 库生成 Shellcode,然后在 C 代码中硬编码。

void&nbsp;memcpy64(uint64_t&nbsp;dst,&nbsp;uint64_t&nbsp;src,&nbsp;uint64_t&nbsp;sz)&nbsp;{
&nbsp; &nbsp; // 这是一个包含 32位->64位->拷贝->32位 完整流程的 Shellcode
&nbsp; &nbsp; static&nbsp;uint8_t&nbsp;code[] = {
&nbsp; &nbsp; &nbsp; &nbsp; /* [bits 32] 切换到 64 位 */
&nbsp; &nbsp; &nbsp; &nbsp; // push 0x33
&nbsp; &nbsp; &nbsp; &nbsp; // push _next_x64_code (后续动态修正)
&nbsp; &nbsp; &nbsp; &nbsp; // retf
&nbsp; &nbsp; &nbsp; &nbsp; 0x6A,&nbsp;0x33,&nbsp;0x68,&nbsp;0x78,&nbsp;0x56,&nbsp;0x34,&nbsp;0x12,&nbsp;0xCB,

&nbsp; &nbsp; &nbsp; &nbsp; /* [bits 64] 执行拷贝 */
&nbsp; &nbsp; &nbsp; &nbsp; // push rsi; push rdi
&nbsp; &nbsp; &nbsp; &nbsp; // mov rsi, src; mov rdi, dst; mov rcx, sz
&nbsp; &nbsp; &nbsp; &nbsp; // rep movsb (核心拷贝指令)
&nbsp; &nbsp; &nbsp; &nbsp; // pop rdi; pop rsi
&nbsp; &nbsp; &nbsp; &nbsp; 0x56,&nbsp;0x57,
&nbsp; &nbsp; &nbsp; &nbsp; 0x48,&nbsp;0xBE,&nbsp;0x88,&nbsp;0x77,&nbsp;0x66,&nbsp;0x55,&nbsp;0x44,&nbsp;0x33,&nbsp;0x22,&nbsp;0x11,&nbsp;// 占位符 src
&nbsp; &nbsp; &nbsp; &nbsp; 0x48,&nbsp;0xBF,&nbsp;0x88,&nbsp;0x77,&nbsp;0x66,&nbsp;0x55,&nbsp;0x44,&nbsp;0x33,&nbsp;0x22,&nbsp;0x11,&nbsp;// 占位符 dst
&nbsp; &nbsp; &nbsp; &nbsp; 0x48,&nbsp;0xB9,&nbsp;0x88,&nbsp;0x77,&nbsp;0x66,&nbsp;0x55,&nbsp;0x44,&nbsp;0x33,&nbsp;0x22,&nbsp;0x11,&nbsp;// 占位符 sz
&nbsp; &nbsp; &nbsp; &nbsp; 0xF3,&nbsp;0xA4,
&nbsp; &nbsp; &nbsp; &nbsp; 0x5E,&nbsp;0x5F,

&nbsp; &nbsp; &nbsp; &nbsp; /* [bits 64] 切回 32 位 */
&nbsp; &nbsp; &nbsp; &nbsp; // push 0x23
&nbsp; &nbsp; &nbsp; &nbsp; // push _next_x86_code
&nbsp; &nbsp; &nbsp; &nbsp; // retfq (注意这里是 64 位的远返回)
&nbsp; &nbsp; &nbsp; &nbsp; 0x6A,&nbsp;0x23,&nbsp;0x68,&nbsp;0x78,&nbsp;0x56,&nbsp;0x34,&nbsp;0x12,&nbsp;0x48,&nbsp;0xCB,

&nbsp; &nbsp; &nbsp; &nbsp; /* [bits 32] 返回 */
&nbsp; &nbsp; &nbsp; &nbsp; 0xC3
&nbsp; &nbsp; };

&nbsp; &nbsp; // 申请可读可写可执行 (RWX) 的内存
&nbsp; &nbsp; static&nbsp;uint32_t&nbsp;ptr =&nbsp;0;
&nbsp; &nbsp; if&nbsp;(!ptr) {
&nbsp; &nbsp; &nbsp; &nbsp; ptr = (uint32_t)VirtualAlloc(NULL,&nbsp;sizeof(code), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
&nbsp; &nbsp; &nbsp; &nbsp; for&nbsp;(int&nbsp;i =&nbsp;0; i <&nbsp;sizeof(code); i++) ((uint8_t*)ptr)[i] = code[i];
&nbsp; &nbsp; }

&nbsp; &nbsp; // 动态修正 Shellcode 中的占位符 (地址和参数)
&nbsp; &nbsp; *(uint32_t*)(ptr +&nbsp;3) = ptr +&nbsp;8;&nbsp; &nbsp; // 修正跳转到 64 位代码的地址
&nbsp; &nbsp; *(uint64_t*)(ptr +&nbsp;12) = src;&nbsp; &nbsp; &nbsp; &nbsp;// 修正 src
&nbsp; &nbsp; *(uint64_t*)(ptr +&nbsp;22) = dst;&nbsp; &nbsp; &nbsp; &nbsp;// 修正 dst
&nbsp; &nbsp; *(uint64_t*)(ptr +&nbsp;32) = sz;&nbsp; &nbsp; &nbsp; &nbsp; // 修正 sz
&nbsp; &nbsp; *(uint32_t*)(ptr +&nbsp;47) = ptr +&nbsp;53;&nbsp; // 修正跳回 32 位代码的地址

&nbsp; &nbsp; // 强转为函数指针并执行
&nbsp; &nbsp; ((void(*)())ptr)();
}

关键点VirtualAlloc 是必须的,因为普通的栈或堆内存默认开启了 DEP(数据执行保护),无法执行代码。

2. 获取 64 位环境信息 (GetPEB64)

WoW64 环境下其实存在两个世界:

  • • FS 寄存器:指向 32 位 TEB
  • • GS 寄存器:指向 64 位 TEB

要获取 64 位系统的核心信息,我们需要访问 64 位的 PEB(进程环境块),它位于 GS:[0x60]

// 伪代码逻辑
mov rax, gs:[0x60] &nbsp;; 获取 PEB64 地址
mov [esi], rax &nbsp; &nbsp; &nbsp;; 将结果存回&nbsp;32&nbsp;位程序能访问的内存 (esi 在&nbsp;64&nbsp;位下依然可用)

这里有个小技巧:虽然切到了 64 位,但我们传入的指针还是 32 位的地址,ESI 的高 32 位通常是零,正好可以用作基址寄存器。

3. 解析模块链表 (GetModuleHandle64)

拿到了 PEB64,我们就可以找到 64 位的 Ldr 链表。 这里最大的难点在于:我们不能直接使用 C 语言定义的结构体,因为 32 位编译器里的指针是 4 字节,而我们要读取的目标结构体里全是 8 字节指针。

需要像外科手术一样,精确计算每一个偏移量。以下是 _LDR_DATA_TABLE_ENTRY 在 64 位下的关键偏移:

| 偏移 (Hex) | 成员名称 | 说明 | | — | — | — | | 0x00 | InLoadOrderLinks | 链表节点 (16字节) | | 0x30 | DllBase | 模块基址 (8字节) | | 0x58 | BaseDllName | 模块名 (UNICODE_STRING, 16字节) | | 0x60 | BaseDllName.Buffer | 字符串指针 (在 UNICODE_STRING 偏移 8 处) |

通过 memcpy64 读取 PEB->Ldr->InLoadOrderModuleList,然后遍历链表,比对 BaseDllName,就能找到隐藏在幕后的 64 位 ntdll.dll

4. 手动解析导出表 (MyGetProcAddress)

拿到 64 位模块基址后,标准的 GetProcAddress 还是不能用(因为它是 32 位的)。我们需要手动解析 PE 文件结构来查找函数地址。

PE 解析流程:

  1. 1. DOS 头:读取 e_lfanew 获取 NT 头偏移。
  2. 2. NT 头:读取 OptionalHeader.DataDirectory[0] 获取导出表(Export Directory)的 RVA。
  3. 3. 导出表:读取三个关键数组的 RVA:
  • • AddressOfNames (函数名表)
  • • AddressOfNameOrdinals (序号表)
  • • AddressOfFunctions (函数地址表)
  1. 4. 遍历查找
  • • 遍历 AddressOfNames,读取函数名并与目标字符串(如 “LdrLoadDll”)比较。
  • • 匹配成功后,获取对应的序号。
  • • 用序号去 AddressOfFunctions 查表,得到函数的 RVA。
  • • 基址 + RVA = 64 位函数绝对地址

这个过程就像是在图书馆里查索引卡片一样,虽然繁琐,但非常有效。

5. 加载 Kernel32 与 API 调用

这里有一个坑:默认情况下,WoW64 进程可能只加载了 32 位的 Kernel32.dll,而没有加载 64 位的版本。

如果我们想调用 CreateFile 等高级 API,光有 ntdll 是不够的。我们需要手动调用 LdrLoadDll(ntdll 的导出函数)来显式加载 64 位的 kernel32.dll

S为了调用 LdrLoadDll,我们需要构造一个 UNICODE_STRING 结构体:

// 伪代码:MakeUTFStr
struct&nbsp;UNICODE_STRING&nbsp;{
&nbsp; &nbsp; uint16_t&nbsp;Length;
&nbsp; &nbsp; uint16_t&nbsp;MaximumLength;
&nbsp; &nbsp; uint64_t&nbsp;Buffer;&nbsp;// 64位指针!
};

流程总结:

  1. 1. GetModuleHandle64 找到 64 位 ntdll。
  2. 2. MyGetProcAddress 找到 LdrLoadDll 地址。
  3. 3. MakeUTFStr 构造 “kernel32.dll” 字符串。
  4. 4. X64Call 调用 LdrLoadDll,加载 64 位 kernel32。
  5. 5. 再次使用 MyGetProcAddress 在 64 位 kernel32 中查找 GetProcAddress
  6. 6. 大功告成!现在你可以用这个 64 位的 GetProcAddress 去获取任意 64 位 API 的地址了。

6. 发起 64 位调用 (X64Call)

最后是重头戏:我们要模拟 x64 的调用约定来执行函数。

x64 调用约定

  • • 前四个参数依次存入 RCX, RDX, R8, R9
  • • 剩余参数压栈(从右向左)。
  • • 调用者必须在栈上预留 32 字节(0x20)的 Shadow Space
  • • 栈指针 RSP 必须 16 字节对齐。
// X64Call 的 Shellcode 核心片段
// [bits 64]
// 假设参数都在栈上或者通过寄存器传入的 args 数组中
mov rcx, [rsi] &nbsp; &nbsp; &nbsp;; 第1个参数
mov rdx, [rsi+8] &nbsp; &nbsp;; 第2个参数
mov r8, &nbsp;[rsi+16] &nbsp; ; 第3个参数
mov r9, &nbsp;[rsi+24] &nbsp; ; 第4个参数

// 处理栈参数... (略)

sub rsp,&nbsp;0x20&nbsp; &nbsp; &nbsp; &nbsp;; 预留 Shadow&nbsp;Space&nbsp;(非常重要!)
call rax &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;; 调用&nbsp;64&nbsp;位函数
add rsp,&nbsp;0x20&nbsp; &nbsp; &nbsp; &nbsp;; 恢复栈

天堂之门 vs 地狱之门

细心的读者可能还听说过”地狱之门”(Hell’s Gate)。这两个名字听起来像是一对,它们有什么区别呢?

  • • 天堂之门 (Heaven’s Gate)

  • • 场景:32 位程序运行在 64 位系统上。

  • • 手段:利用 retf 切换 CS 段选择子 (0x23 -> 0x33)。

  • • 目的:绕过对 32 位 ntdll 的 Hook,直接调用 64 位系统函数。

  • • 地狱之门 (Hell’s Gate)

  • • 场景:本身就是 64 位程序。

  • • 手段:不直接调用 ntdll 中的 API,而是去读取 ntdll 的内存,解析出系统调用号(SSN),然后在自己的代码段里直接执行 syscall 指令。

  • • 目的:绕过对 64 位 ntdll 的 User-Land Hook(EDR 常用手段)。

简单来说,天堂之门是”跨界打击”,利用架构切换来隐形;地狱之门是”自力更生”,自己重写 syscall 发起过程。

总结

“天堂之门”技术的魅力,在于它利用了操作系统最底层的分段机制,打破了 32 位程序只能看到 32 位世界的幻觉。

通过本文的拆解,我们不仅看到了 retf 指令的神奇魔力,还深入了解了 PEB 结构、调用约定以及 Shellcode 编写技巧。如果你对具体的代码实现感兴趣,可以参考开源仓库 Heavens-Gate 进行深入研究。


免责声明:

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

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

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

本文转载自:二进制磨剑 二进制磨剑 二进制磨剑《天堂之门:WoW64 下的跨架构魔法》

评论:0   参与:  0