跨位数注入的技术原理来源于heaven’s gate,这是一项非常古老的技术(至少出现十多年了),它允许64位操作系统上运行32位代码的进程直接切换至64位模式下执行代码,技术的关键就是通过 retf 或 jmp fword ptr 完成CPU短长模式的模式切换。
其实这个主题(跨位数注入)我很早就想学习了,但当时技术水平不行,接着忙于工作学习的事就将它搁置,后面又忙于C2框架的开发而逐渐忘记,当我翻阅计划表时惊讶地发现这个idea躺在我的计划表里有一年了,刚好我将学习的方向转向二进制免杀,于是乎就有了这篇文章。
假设一个场景:当你用msf x86的shellcode完成上线,你想使用meterpreter的 creds_all 命令导出目标的所有凭据,这时你会尴尬的发现并没有任何凭据导出,这是因为内核把lsass.exe还有一些凭据的注册表当成64位进程保护,WOW64模式下的Mimikatz是无法dump出凭据的,当然还有其他场景需要在64位进程空间中进行,只是我水平有限说不出来……
dump不出来,难道要就此止步吗?办法总比困难多,就有效的方式就是使用 migrate <PID> 命令迁移到x64程序,问题就迎刃而解。


CS也有inject命令注入到x64的进程中
图形界面

命令形式

上述的演示只停留在应用层的表面,而接下来我将探究其背后的技术原理和实现。
一、x32→x64
正常来说32位只能注入到32位中,64位只能注入到64位中,即同位数注入。这是因为WOW64子系统的存在。WOW64子系统是Windows64位系统为兼容32位应用程序而设计的一个子系统。它允许32位进程在64位Windows系统上运行,使得旧版的32位应用程序可以在新的64位系统上继续使用
但“兼容”只停留在用户态,一旦32位代码想使用 CreateRemoteThread 或 RtlCreateUserThread 或NtCreateThreadEx主动创建64位线程,就会立刻踩到红线: 64位线程入口地址高32位可能全为1,超出32位内存地址空间的限制(超过4GB)所以WOW64内核明确禁止32 → 64 的跨位宽线程创建。于是经典 CreateRemoteThread 注入思路在 WOW64 场景下直接失效。
怎么实现跨位数注入呢?我们可以参照msf和cs的代码,总结出下面的几个步骤
- 32位的inject程序使用
VirtualAllocEx在64位远程进程分配内存(小于4GB的范围); - 使用
WriteProcessMemory将shellcode写入到刚刚分配的内存中; - inject使用
VirtualAlloc分配两段内存给两个stub使用,并将两个stub复制到这个到分配好的内存中; - 执行第一个stub,完成32->64的转换并将执行流转到第二个stub;
- 第二个stub根据找到
RtlCreateUserThread的地址,并调用RtlCreateUserThread在64位进程中创建一个线程,并设置为挂起状态,最后将线程句柄返回; - 第一个stub完成收尾:将CPU从64转换为32,并返回到inject中,执行后续代码;
- inject使用
ResumeThread恢复线程,执行shellcode。为了观察shellcode是否被执行,我们可以使用弹窗shellcode或者弹计算器的shellcode。

代码的流程与远程线程注入大致相似,其核心的差异就是使用到了两个特别的stub shellcode,上文大致说了两个stub的作用,下面我们再介绍一些细节,进一步理解两个stub的作用:
- 第一个stub,migrate_executex64:由32位和64位机器码混合组成,主要负责
- 切换堆栈、保存非易失性寄存器和参数传递;
- CPU短模式切换成长模式,执行需要在x64模式下执行的机器码,这个x64机器码的作用是跳转到
migrate_wownativex; - 执行完
migrate_wownativex后将CPU长模式切换成短模式; - 恢复堆栈和非易失性寄存器,最后返回到inject的代码中,执行inject的后续流程。
- 第二个stub,migrate_wownativex:全部由64位机器码组成,主要负责
- 根据PEB→Ldr→ntdll.dll(64位的ntdll)→导出表找到
RtlCreateUserThread的地址,这个操作非常的经典,我的Window Shellcode开发系列文章已经详细介绍过了。 - 调用
RtlCreateUserThread在目标进程中创建一个线程 - 返回线程句柄,后续在inject要用到
- 64位机器码要关注栈对齐,这个我不过多介绍了
- 根据PEB→Ldr→ntdll.dll(64位的ntdll)→导出表找到
两个stub shellcode是一大串的机器码,而且原shellcode并不免杀,这意味着我们需要将其转换成汇编代码,才能进行二次开发(本文没有涉及二开,各位师傅动手做一样吧不要想着我给出来了>.<)。
除了编写上述提到的两个stub外,还需要注意在inject中使用VirtualAllocEx给需要在远程线程执行的payload(目标代码)分配内存时一定要小于4GB的范围。
既然两个stub是跨位数注入的核心,而且还是汇编代码,想必对于部分师傅来说必起来有那么一点点困难,那么下文将详细的介绍如何两段构造stub shellcode代码以及部分设计思路和它们之间的关系,但由于本人水平实在有限,必定存在很多错误的地方,也请各位师傅们批判指正!
两个stub的汇编代码和机器码的来源
- metasploit-framework/external/source/shellcode/windows/x86/src/migrate/executex64.asm at master · rapid7/metasploit-framework
- metasploit-framework/external/source/shellcode/windows/x64/src/migrate/remotethread.asm at master · rapid7/metasploit-framework

1.1 migrate_executex64
CS的C代码中,将两个stub看作函数指针的形式,这样做的好处是在调用函数之前,调用方会按照 WINAPI 的调用规范传递参数。
第一个stub作为 EXECUTEX64 函数,第二个stub作为 X64FUNCTION,它们的函数声明如下。不过X64FUNCTION函数并没有在代码中使用到,可能是为了直观的看出参数列表和传参方式吧。
1 | typedef DWORD(WINAPI* EXECUTEX64)(X64FUNCTION pFunction, DWORD ctx); |
在上面的函数声明中出现了ctx整数(其实我们看作是指针),其上下文结构体包含了4个字段总大小为32位,刚好是DWORD大小,其中前3个字段都是给 RtlCreateUserThread 使用,而hThread用于接收 RtlCreateUserThread 创建的线程句柄
1 | typedef struct _WOW64CONTEXT { |
先放完整的stub1的shellcode,我将根据代码的执行顺序以此讲解

(一)切换堆栈、保存非易失性寄存器和参数传递

①切换堆栈,这个不用我多说了把?大部分函数的开头都是这几个指令。

②保存非易失性寄存器,因为用到了esi、edi这两个非易失性寄存器,所以需要将这两个寄存器的值压栈保存

③参数传递

执行到mov esi, [ebp+8]此处时,栈的情况

mov esi, [ebp+8]:获取第一个参数,即pFunction,第二个stub的地址mov ecx, [ebp+0Ch]:获取第二个参数,即ctx,在migrate_executex64中还用不到ctx,这是留给RtlCreateUserThread使用的。call delta:跳转到下一条指令,并在栈上留下下一条指令的地址,这是为了规避地址随机化的一种非常好用的手段
(二)CPU短模式切换成长模式,执行需要在x64模式下执行的机器码

pop eax:弹出存放在栈上的地址,即本条指令pop eax的地址;add eax, 37:pop eax加上37(0x25)即为native_x64标签所在指令的地址;sub esp, 8:分配8字节的栈空间,用于存放“远跳转结构”mov edx, esp:此时edx = esp;mov [edx+4], 0x33:段选择子(64 位用户代码段)。什么是段选择子解释起来非常困难,简单地说:CPU通过它瞬间切换 32/64位模式,0x23表示32位模式,0x33表示64位模式。mov [edx], eax:将native_x64标签所在的指令地址作为偏移,与段选择子构成0x33:offset的远跳结构体。call go_all_native:跳转到go_all_native处的代码,并在栈上留下下一条指令的地址。这里的“下一条指令”指下图所示的第一条指令。

程序的执行流到了下图所示的代码

mov edi, [esp]:取WoW64返回地址返回地址,这个返回地址是从64位转回32位后,需要按32位执行的代码地址,即“恢复堆栈和非易失性寄存器……”的那段代码。jmp fword ptr [edx]:从所给的地址取6个字节的远跳结构体,切成64位模式,远跳转到x64代码段。我们在jmp fword ptr [edx]处下一个断点,观察edx所指向的原跳结构体,如下图所示。

我们去看看偏移所指向的内容是什么,正是 jmp fword ptr [edx] 的下一条指令。

因为我是按32位来调试,当CPU从32->64时,调试器也就失效了。听了AI的解释说是跨位数注入可以用来反沙箱和反调试,这也是有道理的,没办法跟踪到切换成64位长模式的代码。
由于技术水平有限,我没办法通过windbg断点在x64代码区域验证真的CPU切换到长模式,可以说这篇文章的汇编代码是目前为止我写过的最难调试的,涉及到CPU短长模式转换和跨进程调式。因为不能调式的原因,本文章大部分时间都是干巴巴的解释,没有调式验证,所以各位一定要保持半信半疑的态度!
我没招了,再次看看msf的代码就行,这两个代码都是由Stephen Fewer编写,没错又是这位神一般的大佬。
- metasploit-framework/external/source/shellcode/windows/x86/src/migrate/executex64.asm at master · rapid7/metasploit-framework
- metasploit-framework/external/source/shellcode/windows/x64/src/migrate/remotethread.asm at master · rapid7/metasploit-framework
jmp fword ptr [edx] 就是跳到了下面的代码执行,正确的机器码和汇编指令对应关系应该为如下图所示的代码。

xor rax, rax:有没有这条指令都无影响,即可以去掉。push rdi:保存RDI 到栈上(包含WoW64返回地址),因为接下来要跳转到stub2(看作X64FUNCTION函数)中执行代码了。call rsi:跳转到stub2执行代码。pop rdi:执行成功后,弹出栈上的WoW64返回地址push rax:stub2的返回值,如果为1表示成功;0表示失败,返回值没什么用。其实这里的push rax指令等价于sub esp, 8,即分配8字节的栈空间,前者机器码占1一个字节,后者占3个字节。mov DWORD PTR [rsp+4], 0x23:设置段选择子为0x23 (WoW64模式)mov DWORD PTR [rsp], edi:设置偏移地址为WoW64返回地址。jmp dword PTR [rsp]:CPU从64->32,并跳转到stub收尾的代码。
(三)恢复堆栈和非易失性寄存器,最后返回到inject的代码中,执行inject的后续流程
书接上文,其实就是跳转到了下图所示的代码

执行 add esp, 20前,此时栈布局如下:

所以为什么要清理这20个字节(8字节远跳转结构1 + 4字节wow64返回地址 + 8字节的远跳结构体2)的空间,这些空间是怎么产生的呢?看下图我框选的3条指令。

恢复完寄存器的值,执行 ret 8 即可返回到inject代码中执行后续逻辑,至此stub1的代码就是构造完了。
在这里补充一点:不知道各位是否明白 ret 8 的作用?在函数声明的时候我们不是用WINAPI来定义函数的调用约定。

而 WINAPI 就是 __stdcall。__stdcall 要求被调用方法清理堆栈(包括存放在栈上的参数)

所以需要使用 ret 8,返回并清理8个字节的参数(4字节pFunction函数指针 + 4字节ctx指针)
(四)stub1在AMD CPU和Intel CPU上的细微差异
作为一个同时拥有AMD CPU和intel CPU的双持玩家,补充一下AMD CPU 在跨位数注入时的问题。我注意到Stephen Fewer给出代码中与CS多出了两个指令。起初我并不在意,连CS都没有在源码(4.5)中实现这两个指令,这意味两段stub在AMD和intel应该一致的。打脸来得太突然,我在AMD CPU上进行测试,居然报错了,涉及到CPU短长模式转换和跨进程调式,调试起来无比困难,只能进行浅浅地排查,最终我回想起MSF中在代码中给出的两条奇怪的指令

可以看到注释说这个bug是由 rewolf-wow64ext 项目的作者修复,我们跟着网址去看看 wow64ext v1.0.0.8 – ReWolf 的博客是怎么发现并修复bug。
rewolf怀疑是 RETF 指令之后出现了竞态条件(race condition),导致某些内存页突然“失效”。这两条指令的作用是重新加载段寄存器SS,并屏蔽中断。根据Intel手册,mov ss, ax 会阻止中断直到下一条指令执行完毕,从而稳定CPU状态。rewolf也不完全确定为什么这样能修复问题,但实测有效。
解释看完了,在实际代码编写中又遇到了
几个问题,具体来说我们需要在stub1中修改三处地方:
第一处:补充这两个指令

因为补充了这两个指令,所以需要修改两处偏移。
第二处:偏移从0x25->0x2b
第三处:偏移从0x9->0x0f

增加这两条指令后,AMD CPU和intel CPU都能够正常执行。
在这里不得不感概Stephen Fewer和Rewolf登峰造极的技术,或许我追求一辈子也很难达到这种高度。
1.2 migrate_wownativex
由于stub2 shellcode 比stub1大了很多,其中很大一部份代码是获取目标函数地址,我称之为 GetProcAddressByHash。
由于CS的stub2逆向成汇编指令实在太多,我就截屏msf的实现,挑重点讲, 至于 GetProcAddressByHash 的代码解释就略过了。

(一)初始化与栈对齐

这开头的几条指令可是大有说法的,且听我慢慢道来。
cld:清除方向标志位,DF = 0,字符串操作从低地址向高地址进行,即每次操作后 SI/DI 寄存器递增。常见于movsb和lodsb。当然你不加这条指令也能够正常运行,因为大多数情况下DF=0,但我建议还是加上好。mov rsi, rcx:rcx(ecx)在stub1中一直没被破坏,还是存放着ctx指针,然而在64位rcx要作为参数寄存器频繁使用,所以将ctx指针存放到rsi中。mov rdi, rsp:保存原始rsp的值。and rsp, 0xfffffffffffffff0:栈按16字节对齐。call start:跳转到start标签处的代码,并在栈上留下GetProcAddressByHash地址以备后续使用。
mov rdi, rsp 和 and rsp, 0xfffffffffffffff0 必须好好讲解一下,这是对我以往松散知识的一次总结:
我们都知道,esp按4字节增减,那么esp末尾最后一位可能为0,可能为4,可能为8,可能为0xc。
如果是64位的程序,执行到 and rsp, 0xfffffffffffffff0 之前,rsp很大概率为8结尾,这是我观察得到的,也是微软 x64 调用约定(Windows x64 ABI)保证“call 之前RSP必须16字节对齐”。
当Cpu从32->64位,则esp = rsp,rsp也可能有4种情形,为了符合x64调用约定(在这里体现为调用call GetProcessAddrByHash)时rsp一定要以0结尾,所以要使用and rsp, 0xfffffffffffffff0使rsp以16字节对齐。
但这时又出现了一个问题,此时rsp指向返回地址,使用 and rsp, 0xfffffffffffffff0 指令之后,会丢失返回地址,导致无法返回,为了解决这个问题,就需要用到一个非易失性寄存器去保存原始rsp,该寄存器在保存原始rsp的值之后就不在后续过程中使用,如需要使用必须保存到栈上之后再恢复,这就是为什么要 mov rdi, rsp 保存原始rsp的原因。
(二)根据HASH动态获取目标函数地址
GetProcessAddrByHash这个函数我不想多说了,在我前几篇文章就详细介绍过了,特别是在这篇文章 Windows Shellcode开发(x64 stager)-先知社区中明确介绍过了,可能存在细微的差异,但思路和最终达成的目的都是一样的,即给定函数名hash,根据PEB→Ldr→InMemoryOrderModuleList->ntdll.dll→导出表找到目标函数地址 ,并调用。
⚠注意:本文中的 GetProcessAddrByHash 是根据CS shellcode逆向得来,我在代码中给的注释是AI给,不保证正确性。
(三)参数准备、调用GetProcessAddrByHash

上图所示的代码就是为RtlCreateUserThread准备参数,并调用GetProcessAddrByHash寻找到RtlCreateUserThread的地址并调用。
RtlCreateUserThread是未公开函数(Undocumented Functions),MSDN文档中并未提供其完整定义或使用说明,以下是从这个参考网站中获取其函数定义:NTAPI Undocumented Functions

在这里我有两个疑问:
第一个疑问:这里我有一个疑问为什么不用NtCreateThreadEx,而是用RtlCreateUserThread?可能是RtlCreateUserThread是CreateRemoteThread的低一级的实现。
RtlCreateUserThread和CreateRemoteThread的最底层实现都是NtCreateThreadEx,按道理来说NtCreateThreadEx也能够实现创建远程线程,至少我在突破seesion 0远程线程注入中成功过,不过在这里不过我并未测试,只是提供了一个思考而已。

在注释我中留了第二个疑问:CreateSuspended = True,一定是要暂停,为什么要暂停?
我尝试将True改位False,即将0x01修改为0x00,代码也还是成功执行


这让我百思不得其解,有知道的师傅可以跟我说一声嘛,真心求教!
(四)返回结果并清理栈

test rax,rax:按位相与,只影响标志位,常用于条件跳转。jz sucess:如果rax为0,表明我们成功创建线程,返回TRUE(1)mov rax,0:如果rax为1,表明创建线程失败,返回FALSE(0)
sucess:mov rax,0
cleanup:and rsp,(32+6*8):调用一次GetProcAddressByHash产生32B的影子空间,由调用方清理;push 6个参数到栈上,因此使用了6 × 8B = 48B的空间。总共使用了32+6 * 8 字节的栈空间,在返回到stub1前要清理掉。mov rsp,rdi:恢复原始rsp,恢复之后rsp指向返回地址。ret:返回到stub1。
至此两段stub shellcode的编写就介绍完了,有没有感觉精彩绝伦呢?这简直是艺术品,技术的魅力令人陶醉,让人沉迷……
在这里多提一嘴文章写的很繁琐,这是我写文的风格,看不惯我也没办法。
1.3 inject
1 | // main.cpp |
1.4 测试
win11 intel CPU

win11 AMD CPU

win10

win 7

二、x64->x32
参考 xia0ji233 师傅这篇的文章:关于64位进程注入32位进程的分析 | xia0ji233’s blog
不愧是我github上第一个关注的大佬,当时就是看了他的这篇文章之后就在心里种下了一颗“一定实现 跨位数注入”的种子,如今本文如愿实现,真的很感谢 xia0ji233 师傅,给了我很多启发!
在64位程序向64位远程进程注入dll时,我们的经典操作是
- 使用
OpenProcess打开进程。 - 使用
VirtualAllocEx在远程进程中为DLL路径申请空间。 - 使用
WriteProcessMemory将dll路径写入到指定进程中的内存区域。 - 使用
GetModuleHandleA+GetProcAddress获取LoadLibraryA在kernel32.dll的虚拟地址 - 使用
CreateRemoteThread创建在另一个进程的虚拟地址空间中运行的线程,LoadLibraryA或者LoadLibraryW作为lpStartAddress,已写入DLL路径的内存地址作为lpParameter。 - 使用
WaitForSingleObject等待远程线程结束。
对于第4步的原理是:同架构的进程是共享同一套共享库系统,即同架构的进程的 kernel32.dll 和 ntdll.dll 的句柄(基址)在不同进程的虚拟内存空间是一致的,进而LoadLibrary在同架构的不同进程里的kernel32.dll的虚拟地址也是一致的,这样我们就可以将LoadLibrary的地址作为线程启动的起点。其实我在我的早期文章的时候就研究过这个知识的,没想到这里又用上了。
例如:两个64位的进程其 ntdll.dll 的基址是一样的

同理32位进程的kernel32.dll也是一样

当我们使用64->32 注入dll与上述同架构注入最关键的差异是:当我们在64向32进程注入32位的dll时,需要用到32进程里的 kernel32.dll 的模块基址,而64位和32位 kernel32.dll 的模块基址是不一致的,毕竟他们并不是同一个 kernel32,一个是System32目录(64位程序使用),而另一个是SysWOW64目录(32位程序使用),导致常规的远程进程注入方法失效!
一个程序使用 GetModuleHandleA+GetProcAddress 获取到的 LoadLibraryW 的地址是基于程序的架构的,比如说64位程序获取到的就是64位的 LoadLibraryW ,64位程序使用上述的方法是没办法获取到32位的LoadLibraryW,所以为了实现64->32 注入dll,需要自己实现类似 GetModuleHandleA+GetProcAddress 的功能。
在下文给的示例代码中,一共有两个关键的自定义函数:
GetLoadLibraryW就是在 指定的32位目标进程 里,把kernel32.dll的32位LoadLibraryW入口地址给你找出来并返回。GetRemoteProcAddress就是经典的从DOS->NT->导出表寻找目标函数地址的过程,这里不过多叙述。
可以将两个函数合并,大致流程总结如下:
EnumProcessModulesEx指定LIST_MODULES_32BIT参数,表明要枚举32位目标进程的所有模块,存放到模块数组中;- 遍历模块数组,使用
GetModuleBaseNameW获取模块文件名(非绝对路径名) - 使用
_wcsicmp忽略大小写比较模块文件名与目标模块名,在本文中,目标模块名为KERNEL32.DLL或kernel32.dll或者任意组合都可以。 - 如果匹配成功,则使用
GetRemoteProcAddress解析模块的导出表,获得目标函数地址。 - 最后将寻找到的目标函数(
LoadLibraryW)地址返回,并在后续的CreateRemoteThread中使用。
1 | #include <windows.h> |

注意⚠:当你成功注入DLL后,想要再一次对同一个目标进程注入dll时没有出现弹窗,这是因为DLL已经加载到目标进程的内存空间了,所以不会触发DLL里 DLL_PROCESS_ATTACH 里的执行逻辑。

64->32 注入shellcode就是普通的远程线程注入,不在这里赘述。
三、总结
32->64跨位数注入的原理非常简单,难就难在两个stub的构造,对于不熟悉汇编的师傅来说是有一定的挑战性的。stub1负责CPU短长模式切换,stub2负责寻找到 RtlCreateUserThread 的地址并调用。
64->32的原理就更简单了,与普通的远程线程注入的核心差异就是需要获取32目标进程的 kernel32.dll 模块,进而获取 LoadLibraryW 函数地址,这样 CreateRemoteThread 才不会出错。
这种比较接近系统底层,需要编写汇编的技术,对于大部分人来说学起来挺吃力的,我也不例外,希望本文能给帮助到各位师傅!
我开头说过,跨位数注入的原理来源heaven’s gate,但其只是heaven’s gate的冰山一角,heaven’s gate里面的内容还有很多值得学习的,至少能水1到2篇文章(认真脸),不过这都是以后的事情了。
至于下篇文章的主题应该是与Beacon有关,还没想好只是有一个大概的方向。太多人写过的主题我不太想发先知上,我对发在先知上的文章有一定的要求:与众不同,有一点技术性含量,不过对于只有一年学习经验的我来说还是非常具有挑战性的,毕竟“太阳底下没有新鲜事”,我写的这些文章都是前人早已提出并实现,我只是总结而已。除了先知之外,我也会在博客上分享一些碎碎念和简短的技术分享,感兴趣的师傅可以去看看。
最后求一下三连啊!师傅的点赞、收藏和关注真的对我很重要呜呜呜呜呜呜>.<
参考资料
- metasploit-framework/external/source/shellcode/windows/x86/src/migrate/executex64.asm at master · rapid7/metasploit-framework
- metasploit-framework/external/source/shellcode/windows/x64/src/migrate/remotethread.asm at master · rapid7/metasploit-framework
- wow64ext v1.0.0.8 – ReWolf 的博客
- NTAPI Undocumented Functions
- 关于64位进程注入32位进程的分析 | xia0ji233’s blog