天堂之门 (Heaven’s Gate) 是一种在32位WoW64进程中执行64位代码,以及直接调用64位WIN32 API函数的技术。从安全角度看,天堂之门可以作为一种软件保护技术,用于防止静态分析以及跨进程的API Hook;从恶意代码角度看,该技术可以绕过沙盒对WIN32 API调用的检测。本文介绍了天堂之门技术的原理及C语言实现。
目录
[TOC]
0x00. 原理分析
天堂之门技术的最早应用已经不可考究,我找到的最早的一篇详解天堂之门的文章是2012年的Knockin’ on Heaven’s Gate – Dynamic Processor Mode Switching,内容非常详细,目前能找到的有关天堂之门的文章大多都引用了这篇。比较新的一个是Rebuild The Heaven’s Gate: from 32-bit Hell back to 64-bit Wonderland,貌似是一个台湾人(《惡意程式前線戰術指南》作者馬聖豪)的讲座PPT,比较简短。
什么是WoW64?
简单来说WoW64是Windows x64提供的一种兼容机制,可以认为WoW64是64位Windows系统创建的一个32位的模拟环境,使得32位可执行程序能够在64位的操作系统上正常运行。
推荐一篇讲解WoW64的深度好文WoW64 internals,这里就不再赘述了。
32位进程的API调用过程(WoW64)
下图(来自Rebuild The Heaven’s Gate: from 32-bit Hell back to 64-bit Wonderland)展示了正常情况下32位进程通过WoW64机制调用WIN32 API的过程。以ZwOpenProcess
函数的调用为例:
- a.exe首先调用32位
ntdll.dll
(以下简称ntdll32)中的ZwOpenProcess
函数 - ntdll32调用
wow64cpu.dll
中的X86SwitchTo64BitMode
,顾名思义,调用该函数后进程从32位模式切换到64位模式 - 由
wow64.dll
将32位的系统调用转化为64位 - 调用64位
ntdll.dll
的ZwOpenProcess
函数 - 切换到内核态(Ring0)执行系统调用
32位进程的API调用过程(天堂之门)
下图展示了通过天堂之门技术调用WIN32 API的过程。这里我们通过一些操作绕过了WoW64机制,手动切换到64位模式并调用64位下的ZwOpenProcess函数,大致流程如下(和图中不太一样):
- 将cs段寄存器设为0x33,切换到64位模式
- 从gs:0x60读取64位PEB
- 从64位PEB中定位64位ntdll基址
- 遍历ntdll64导出表,读取
ZwOpenProcess
函数地址 - 构造64位函数调用
如果需要调用的是ntdll之外的函数,以kernel32.dll
中的CreateFile
函数为例,还需要:
- 遍历ntdll64导出表,读取
LdrLoadDll
函数地址 - 调用
LdrLoadDll("kernel32.dll")
加载64位kernel32.dll
- 从64位的kernel32中读取
GetProcAddress
等函数,获取CreateFile
函数地址 - 调用
CreateFile
函数
从上述过程中我们可以发现通过天堂之门的API调用并没有调用ntdll32中的函数,而目前大多数沙箱在检测32位程序时仅仅会对32位函数进行Hook,通过天堂之门,我们成功绕过了沙箱的API检测:
0x01. 代码实现
GitHub仓库:bluesadi/Heavens-Gate
实现天堂之门大概需要实现到以下几个函数:
-
memcpy64
:在64位地址之间拷贝数据 -
GetPEB64
:获取64位的PEB地址 -
GetModuleHandle64
:获取64位的模块基址 -
GetProcAddress64
:获取64位模块中的函数地址 -
X64Call
:调用64位函数 -
MakeUTFStr
:构造UNICODE_STRING
结构体 -
GetKernel32
:加载64位kernel32.dll
及其依赖kernelbase.dll
-
LoadLibrary64
:在加载kernel32.dll
后用于加载user32.dll
接下来我们将一一讲解这些函数的实现。
环境准备
- Visual Studio 2019
- WinDbg(x64)
- Windows 10 x64 20H2
VS项目属性中选择"Release", “Win32”,切记关闭优化(不关优化会出现玄学错误!!!):
C/C+±>代码生成->运行库改为“多线程(/MT)”,即静态编译:
memcpy64
函数声明:
1 | void memcpy64(uint64_t dst, uint64_t src, uint64_t sz); |
该函数的作用是将64位地址src
的内容拷贝到dst
,拷贝sz
个字节,因为我们操作的地址是64位的,所以我们必须切换到64位模式用64位的汇编实现。
由32位切换到64位的代码如下,[bits 32]
表示接下来的汇编要以32位模式编译,_next_x64_code
为64位汇编代码的地址:
1 | [bits 32] |
retf
表示远返回,该指令会从栈顶取出一个返回地址,再取出一个cs段选择子,在上述代码中,retf指令会跳转到0x33:_next_x64_code
,并将cs段寄存器置为0x33,此时程序切换到64位模式(Windows下cs段寄存器为0x23则以32位模式执行指令,为0x33则以64位模式执行指令)。
随后执行64位汇编指令,将src
的数据拷贝到dst
中,这里不再做解释:
1 | [bits 64] |
执行完64位代码后,我们需要切回32位模式并返回。retfq
中的q表示qword,即返回到64位的地址:
1 | [bits 64] |
汇编的编译我们可以用Python的keystone
模块实现:
1 | from keystone import * |
输出得到shellcode,其中0x12345678我们要替换成_next_x64_code
,也就是下一段64位汇编指令的地址:
1 | push 0x33 |
完整的shellcode:
1 | static uint8_t code[] = { |
要执行这段shellcode,我们需要在堆中开辟新的空间,属性为PAGE_EXECUTE_READWRITE
,即可读可写可执行,将shellcode拷贝到这块区域,替换_next_x64_code
、src
、dst
、_next_x86_code
的地址后执行:
1 | static uint32_t ptr = NULL; |
完整代码:
1 | void memcpy64(uint64_t dst, uint64_t src, uint64_t sz) { |
GetPEB64
函数声明:
1 | void GetPEB64(void* peb64); |
该函数的作用是获取PEB64的地址。
64位中gs:[0x30]
指向TEB,gs:[0x60]
指向PEB,获取PEB64的地址很简单,只需要将gs:[0x60]
拷贝到rax作为返回值即可:
1 | [bits 64] |
完整代码:
1 | void GetPEB64(void *peb64) { |
GetModuleHandle64
函数声明:
1 | uint64_t GetModuleHandle64(const WCHAR *moduleName); |
该函数的作用是获取名为moduleName
的模块的基址。
实现步骤如下:
- 从
PEB+0x18
获取Ldr的地址 - 从
Ldr+0x10
获取InLoadOrderModuleList地址 - 遍历InLoadOrderModuleList获取模块基址
- 通过模块基址获取模块名,并与
moduleName
比对,比对成功则返回该模块基址
在WinDbg中使用dt
指令查看结构体,可以看到Ldr的地址在PEB中的偏移为0x018:
1 | 0:000> dt _PEB |
用之前实现的memcpy64
函数拷贝Ldr的地址:
1 | uint64_t peb64; |
打印_PEB_LDR_DATA
结构体,可以看到InLoadOrderModuleList
在Ldr中的偏移为0x10:
1 | 0:000> dt _PEB_LDR_DATA |
拷贝InLoadOrderModuleList
的地址:
1 | uint64_t head; |
InLoadOrderModuleList
的实际类型为_LDR_DATA_TABLE_ENTRY
,BaseDllName
中存储了DLL的名称,类型为_UNICODE_STRING
:
1 | 0:000> dt _LDR_DATA_TABLE_ENTRY |
所以Buffer的偏移量在_LDR_DATA_TABLE_ENTRY
中的偏移为0x58+0x08
,即96。
遍历链表的代码如下:
1 | while (pNode != head) { |
完整代码如下:
1 | uint64_t GetModuleHandle64(const WCHAR *moduleName) { |
MyGetProcAddress
函数声明:
1 | uint64_t MyGetProcAddress64(uint64_t hModule, const char* func); |
通过GetModuleHandle64
获取模块地址后,此时还无法通过kernel32.dll
中的GetProcAddress
函数获取模块中函数的地址,可以通过遍历模块的导出表获取函数地址作为过渡方案。
首先获取导出表地址,这部分涉及PE文件结构,不再赘述了:
1 | IMAGE_DOS_HEADER dos; |
随后遍历导出表,从导出表中读取函数的名称和地址,将函数名称与func
进行比对,比对成功则返回函数地址:
1 | for (uint64_t i = 0; i < expo.NumberOfNames; i++) { |
完整代码如下:
1 | uint64_t MyGetProcAddress(uint64_t hModule, const char* func) { |
X64Call
函数声明:
1 | uint64_t X64Call(uint64_t proc, uint32_t argc, ...); |
由于32位与64位的函数调用的传参方式不同,以及在上一步中我们通过MyGetProcAddress
函数获取的函数地址为64位,肯定不能直接转化为函数指针调用,所以我们需要用64位汇编实现一个64位函数的调用。
首先来简单了解一下64位中WINAPI调用的传参方式:
- 前四个参数从左往右依次存放到
rcx
,rdx
,r8
,r9
寄存器中 - 后面的参数从右往左依次入栈
- rsp与最后一个参数之间直接需要保留大小为20字节的空间,被调函数可能会使用
构造shellcode,因为接下来我们需要对栈指针进行操作,所以首先将esp
保存到ebx
中,shellcode执行完毕后需要复原。and esp, 0xFFFFFFF8
的作用是使rsp
与8对齐,这是64位汇编的栈对齐要求,否则在执行某些系统调用时可能会出错:
1 | [bits 32] |
切换到64位后按照64位WINAPI调用协定传参,调用函数之前保留32字节的空间,将函数返回值保存到rax。在shellcode执行前后保存和复原rsi
和rdi
:
1 | [bits 64] |
最后切换回32位模式,并还原esp
和ebx
:
1 | [bits 64] |
完整代码如下:
1 | uint64_t X64Call(uint64_t proc, uint32_t argc, ...) { |
MakeUTFStr
函数原型如下:
1 | char* MakeUTFStr(const char* str); |
构造一个_UNICODE_STRING
结构体并返回64位的地址。代码实现如下:
1 | char* MakeUTFStr(const char* str) { |
GetKernel32
函数声明:
1 | uint64_t GetKernel32(); |
加载kernel32.dll
以及kernelbase.dll
在上面提到的Knockin’ on Heaven’s Gate – Dynamic Processor Mode Switching这篇文章中对这一部分有很复杂的叙述。kernel32.dll
在Windows中的加载地址是固定的,并且只能被加载到那个地址。在该作者测试的环境下64位kernel32.dll
的加载地址所在的空间已经被分配并且被映射为私有的了,会导致调用LdrLoadDll
函数加载kernel32.dll
时失败并返回0xC0000018 ( STATUS_CONFLICTING_ADDRESSES )。
Any attempts to load kernel32.dll using the LdrLoadDll function would result to the error code 0xC0000018 ( STATUS_CONFLICTING_ADDRESSES ). This is due to the fact that the default memory location of kernel32 is already mapped as private.
解决的思路非常简单:即调用NtFreeVirtualMemory
函数将这块已经分配的空间释放掉,再用LdrLoadDll
重新加载。但代码写起来非常复杂,可以参考dadas190/Heavens-Gate-2.0的实现。
但是在我的操作系统上(Windows 10 x64 20H2),并没有找到作者提到的分配和映射过程,并且直接调用LdrLoadDll
函数也能正常加载kernel32.dll
,可能是在某个Windows版本中被移除了吧。
所以加载kernel64这部分的代码就变得非常简单了:
1 | uint64_t GetKernel32() { |
GetProcAddress64
函数原型:
1 | uint64_t GetProcAddress64(uint64_t hModule, const char* func); |
获取了kernel64的地址后我们就能直接通过GetProcAddress
函数获取模块中函数的地址了。代码实现如下:
1 | uint64_t GetProcAddress64(uint64_t module, const char* func) { |
LoadLibrary64
函数原型:
1 | uint64_t LoadLibrary64(const char* name) |
调用kernel64的LoadLibraryA
函数加载其他DLL,如user32.dll
等等。
代码实现如下:
1 | uint64_t LoadLibrary64(const char* name) { |
MessageBox测试
测试一下典中典之MessageBox弹窗:
1 | void Test() { |
运行效果:
0x02. 沙箱&火绒剑测试
微步云沙箱测试
首先测试一段正常的文件读写代码:
1 | void TestNormal() { |
检测到了文件释放:
再测试一下用天堂之门实现的同样功能的代码:
1 | void TestHeavensGate() { |
没有检测到文件释放:
魔盾测试
直接崩了,无语:
火绒剑测试
最终没能逃出火绒剑的魔爪:
0x03. 玄学Bug
目前还有一些玄学Bug,原因不明:
- 只能生成后双击运行。在VS中运行或在cmd中运行会导致无法加载
kernel32.dll
,LdrLoadDll("kernel32.dll")
返回0xC0000142 (STATUS_DLL_INIT_FAILED) - 在DLL中无法加载user32.dll。至少在我的环境下,
LoadLibrary64("user32.dll")
直接导致整个程序崩溃
最后,由于我在Windows和恶意代码这块还是新手,难免有理解不当的地方,如果文章内容有什么问题欢迎各位师傅指正!