文章总结: 本文阐述Windows天堂之门技术,即32位程序利用WoW64机制通过CS段选择子切换至64位模式,绕过32位监控直接调用64位API。文章解析CPU分段原理,提供含Shellcode构造、PEB遍历及x64调用约定的实现方案,有效对抗沙箱与调试器。 综合评分: 90 文章分类: 二进制安全,免杀,逆向分析
天堂之门: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. 32位 ntdll.dll:程序调用
ZwOpenProcess。 - 2. Wow64cpu.dll:ntdll 不会直接 syscall,而是跳转到
Wow64cpu.dll的X86SwitchTo64BitMode。 - 3. 模式切换:执行
jmp far(如jmp 0x33:Offset),CPU 从 32 位兼容模式切到 64 位长模式。 - 4. Wow64.dll:进入 64 位世界,调用
Wow64SystemServiceEx进行参数转换(32位转64位)。 - 5. 64位 ntdll.dll:调用真正的
ZwOpenProcess。 - 6. 内核:执行
syscall进入 Ring0。
天堂之门调用过程:
- 1. 手动切换:不经过 32 位 ntdll,直接用
retf将 CS 寄存器设为0x33。 - 2. 获取环境:从
gs:[0x60]读取 64 位 PEB,找到 64 位 ntdll 基址。 - 3. 手动调用:直接调用 64 位 ntdll 中的函数。
- 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 ; 压入 64 位段选择子 (CS)
push target_addr ; 压入目标 64 位代码地址 (RIP)
retf ; 远返回!此时 CS=0x33,CPU 跳转到 target_addr 进入 64 位模式
一旦 retf 执行完毕,CPU 就进入了 64 位模式。
实战:代码实现
进入 64 位模式后,我们面临的最大挑战是:原本的 32 位 C 代码编译器生成的指令是 32 位的。要想在 C 语言项目中执行 64 位逻辑,我们需要手动构造”机器码”(Shellcode),并动态分配可执行内存来运行。
环境准备
为了防止编译器自作聪明,建议进行如下配置(以 VS2019 为例):
- 1. 关闭优化:防止指令重排或栈帧指针被优化(Release 模式下尤其注意)。
- 2. 静态编译:C/C++ -> 代码生成 -> 运行库 -> 多线程 (/MT)。
- 3. 关闭安全检查:视情况关闭
/GS等检查。
1. 跨架构内存拷贝 (memcpy64)
在 64 位模式下,我们需要用 64 位寄存器来操作数据。我们可以利用 Python 的 keystone 库生成 Shellcode,然后在 C 代码中硬编码。
void memcpy64(uint64_t dst, uint64_t src, uint64_t sz) {
// 这是一个包含 32位->64位->拷贝->32位 完整流程的 Shellcode
static uint8_t code[] = {
/* [bits 32] 切换到 64 位 */
// push 0x33
// push _next_x64_code (后续动态修正)
// retf
0x6A, 0x33, 0x68, 0x78, 0x56, 0x34, 0x12, 0xCB,
/* [bits 64] 执行拷贝 */
// push rsi; push rdi
// mov rsi, src; mov rdi, dst; mov rcx, sz
// rep movsb (核心拷贝指令)
// pop rdi; pop rsi
0x56, 0x57,
0x48, 0xBE, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, // 占位符 src
0x48, 0xBF, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, // 占位符 dst
0x48, 0xB9, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, // 占位符 sz
0xF3, 0xA4,
0x5E, 0x5F,
/* [bits 64] 切回 32 位 */
// push 0x23
// push _next_x86_code
// retfq (注意这里是 64 位的远返回)
0x6A, 0x23, 0x68, 0x78, 0x56, 0x34, 0x12, 0x48, 0xCB,
/* [bits 32] 返回 */
0xC3
};
// 申请可读可写可执行 (RWX) 的内存
static uint32_t ptr = 0;
if (!ptr) {
ptr = (uint32_t)VirtualAlloc(NULL, sizeof(code), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
for (int i = 0; i < sizeof(code); i++) ((uint8_t*)ptr)[i] = code[i];
}
// 动态修正 Shellcode 中的占位符 (地址和参数)
*(uint32_t*)(ptr + 3) = ptr + 8; // 修正跳转到 64 位代码的地址
*(uint64_t*)(ptr + 12) = src; // 修正 src
*(uint64_t*)(ptr + 22) = dst; // 修正 dst
*(uint64_t*)(ptr + 32) = sz; // 修正 sz
*(uint32_t*)(ptr + 47) = ptr + 53; // 修正跳回 32 位代码的地址
// 强转为函数指针并执行
((void(*)())ptr)();
}
关键点:VirtualAlloc 是必须的,因为普通的栈或堆内存默认开启了 DEP(数据执行保护),无法执行代码。
2. 获取 64 位环境信息 (GetPEB64)
WoW64 环境下其实存在两个世界:
- • FS 寄存器:指向 32 位 TEB。
- • GS 寄存器:指向 64 位 TEB。
要获取 64 位系统的核心信息,我们需要访问 64 位的 PEB(进程环境块),它位于 GS:[0x60]。
// 伪代码逻辑
mov rax, gs:[0x60] ; 获取 PEB64 地址
mov [esi], rax ; 将结果存回 32 位程序能访问的内存 (esi 在 64 位下依然可用)
这里有个小技巧:虽然切到了 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. DOS 头:读取
e_lfanew获取 NT 头偏移。 - 2. NT 头:读取
OptionalHeader.DataDirectory[0]获取导出表(Export Directory)的 RVA。 - 3. 导出表:读取三个关键数组的 RVA:
- •
AddressOfNames(函数名表) - •
AddressOfNameOrdinals(序号表) - •
AddressOfFunctions(函数地址表)
- 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 UNICODE_STRING {
uint16_t Length;
uint16_t MaximumLength;
uint64_t Buffer; // 64位指针!
};
流程总结:
- 1.
GetModuleHandle64找到 64 位 ntdll。 - 2.
MyGetProcAddress找到LdrLoadDll地址。 - 3.
MakeUTFStr构造 “kernel32.dll” 字符串。 - 4.
X64Call调用LdrLoadDll,加载 64 位 kernel32。 - 5. 再次使用
MyGetProcAddress在 64 位 kernel32 中查找GetProcAddress。 - 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] ; 第1个参数
mov rdx, [rsi+8] ; 第2个参数
mov r8, [rsi+16] ; 第3个参数
mov r9, [rsi+24] ; 第4个参数
// 处理栈参数... (略)
sub rsp, 0x20 ; 预留 Shadow Space (非常重要!)
call rax ; 调用 64 位函数
add rsp, 0x20 ; 恢复栈
天堂之门 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 下的跨架构魔法》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。








评论