你好
这是我对VMProtect安全防护机制的探索。VMProtect是一款广为人知的保护工具,具备众多功能,其中主要包括代码变异(Code Mutation)和虚拟化(Virtualization)。与这些功能相比,本文所讨论的部分在VMProtect中属于较为简单的环节。我将在后续的文章中详细探讨上述所有功能,不过当前,我将专注于其加壳(Packing)和导入表混淆(Import Obfuscation)技术。
#脱壳(Unpacking)
加壳指的是对可执行文件的各个节区进行压缩/加密,以此阻碍静态分析。不过,考虑到在程序执行过程中的某个阶段,代码和节区内容必然需要被解密,因此这种保护手段的实际效果其实较为有限。
在此情况下,VMProtect并未在PE文件头中存储真实的“原始文件(RawFile)”节区信息,这倒是个不错的做法。不过……节区的虚拟地址和大小信息还是得存储在文件头里,否则内核就无法为可执行文件分配正确的内存空间。因此,我们仍然能够察觉到节区的大小以及可能的地址(在不考虑地址空间布局随机化(ASLR)的情况下)。
就原始文件中的加壳处理而言,所有内容都被整合到了VMProtect的一个名为.vmp1的节区中(包括加密后的节区、脱壳例程、节区信息等)。
唯一未受“保护”的节区是.rsrc节区,因为Windows需要读取该节区以提取图标和其他信息,进而在属性菜单中显示。针对这一点,VMProtect提供了保护资源的选项,它可以将资源分为两部分:一部分可供Windows正常读取;另一部分则仅包含“程序数据”,这部分数据会被加密,并在程序执行过程中解密。
从截图上我们可以看到,除.data节区外,其他所有节区均不可写。因此,VMProtect必须调用VirtualProtect(或类似函数)来更改节区属性,以便后续修改。实际上,VMProtect过去确实使用过VirtualProtect函数,但从3.x版本开始,它改用了一个更高级的“未公开”内核API——ZwProtectVirtualMemory,该API的作用与前者相同。
所以现在我们可以开始动态脱壳了!考虑到ZwProtectVirtualMemory会被Windows内部大量调用,所以我们只在到达PE入口点后,才对其进行下断操作。
注意,这个入口点看起来像是VMProtect的虚拟化例程,实际上它也确实是!在过去(2.x版本),VMProtect的脱壳例程并未进行虚拟化处理(就像这个视频里展示的那样),所以那时很容易就能快速定位并在跳转到原始入口点(OEP,Original Entry Point)的跳转指令(通常是push和ret组合)处设置断点。
好的,现在我们知道,VMProtect应该会对节区属性进行两次修改:一次是将属性改为可写,以便进行脱壳操作;另一次是恢复节区原有的属性。经过一些调试和断点命中测试后,我们得到了ZwProtectVirtualMemory函数应该被调用的次数。
- n = 不可写节区的数量
- n + 1(针对.vmp0节区):将保护属性更改为可写标志
- 1(次调用):移除COPY(复制)标志
- n + 1(针对.vmp0节区):恢复节区原始标志
复制代码 因此,要完成PE文件的脱壳,总共需要触发 ((n + 1) * 2) + 1 次相关操作(即ZwProtectVirtualMemory调用次数)。以下是实际操作的动态图(注意右侧的节区标志变化):
接下来,我们只需使用任何工具转储该可执行文件,就能得到一个干净的PE文件转储(无需修复导入表)。
根据上述分析(以及我关于VMProtect的第二部分研究),我推测.vmp0节区包含经过虚拟化/变异处理的程序员代码(以及与导入地址表IAT相关的代码),而.vmp1节区则包含与解包过程相关的所有内容(加密的节区、虚拟化脱壳例程、节区信息等)。
#寻找原始入口点(OEP)
由于脱壳例程已被虚拟化处理,因此从VMProtect的代码中直接查找OEP相当困难。为此,我们需要采用一些技巧。在此案例中,我设想通过监控指令指针寄存器(EIP)的值,并在其从.vmp1节区跳转到非.vmp0的其他节区时记录该值,以此找到OEP,结果这一方法奏效了。我曾尝试使用Qiling框架编写脚本实现此功能,但由于Qiling未实现ZwProtectVirtualMemory函数,导致效果有限。因此,我最终使用Unicorn引擎编写了一个Python脚本来完成这一任务。
我们还可以通过在.text节区设置一个关于执行操作的硬件内存断点来找到相同的结果(即定位到OEP)。就我的情况而言,OEP恰好是.text节区顶部的第一个函数,不过这种情况比较少见,所以你大概率不会遇到。
你可以通过查看首个函数栈保护值(栈安全 cookie,类似这篇非常优秀的文章中介绍的方法)来定位 OEP。当使用 VC++ 编译可执行文件时,该值通常为 0x2B992DDFA232。
你仍可通过手动方式查找 OEP,具体做法是尝试遍历栈底,寻找可能的首个返回地址。
#IAT 混淆(IAT Obfuscation)
VMProtect 的 IAT 混淆功能是可选的,默认情况下不会启用。因此,当开发者不了解如何正确使用 VMProtect 时(相信我,这种情况确实存在),就不需要进行 IAT 重构。当然,为了撰写本文,我特意启用了这一功能 
首先需要明确的是,原始的 IAT(导入地址表)仍然被保留在文件中,但程序运行时并不会直接使用它。
所以,你虽然能看到程序调用了哪些API函数,但无法将它们与代码(交叉引用)关联起来,因为导入地址是在运行时“动态计算”得出的。在我看来,如果他们想让IAT保持这种看似“干净”的状态(即不直接暴露真实导入信息),至少应该从DLL中随机导入一个函数,而不是把所有内容都原封不动地留着。或者,干脆完全移除IAT的内容,转而通过LoadLibrary动态加载每个DLL,并在后续手动解析导入函数。
因此,如果我们查看可执行文件中对某个API的调用,会发现幸运的是,这些API调用只是经过了变异处理,而并未被虚拟化!
在.text节区中,每个对IAT(导入地址表)的调用(例如:call dword ptr ds:[<&CreateProcessW>])原本长度为6字节。
而经过VMProtect处理后,每个API调用会被修改为以下形式的调用:
- push random_register ; 随机寄存器压栈(干扰分析),破坏静态分析的指令模式。
- call mutated_api_resolver ; 通过变异后的解析函数在运行时计算真实API地址,再跳转执行。
复制代码 这两条指令同样约为6字节长,目的是保持代码的“对齐”。对于每个API调用,都有一个对应的mutated_api_resolver(变异后的API解析器)函数,这或许能解释为什么VMP生成的输出文件如此庞大(也是因为虚拟化的原因)。所以,VMP使用一个名为random_register(随机寄存器)的寄存器来向API解析器传递某些内容。
以下是变异后的版本(其中edi是random_register):
注意:正如我前面提到的,这段代码位于 .vmp0 节区
- nop
- not di
- bswap di
- jmp ...
- pop edi
- jmp ...
- xchg dword ptr ss:[esp], edi
- push edi
- not edi
- xchg di, di
- jmp ...
- mov edi, 0x401113
- mov edi, dword ptr ds:[edi + 0x2B21E]
- jmp ...
- lea edi, dword ptr ds:[edi + 0x724F2141]
- jmp ...
- xchg dword ptr ss:[esp], edi
- jmp ...
- ret
复制代码 以下是清理(优化/整理)后的版本(其中reg为随机寄存器):
- # reg为被压入的寄存器
- # 获取调用指令的返回地址
- pop reg
- # 交换被压入的“reg”值与调用指令的返回地址
- # 这样,API调用的返回操作将返回到API调用者处
- xchg dword ptr ss:[esp], reg
- # 设置未来要跳转到的返回地址,以跳转到API函数
- push reg
- # 计算函数地址
- # 在VMP计算中,kernel32.dll里CreateProcessW函数的偏移量
- mov reg, 0x401113
- # 根据偏移量计算函数地址
- # 这些值为静态的IAT(导入地址表)和API偏移量
- # 获取VMP中kernel32.dll的IAT偏移量
- mov reg, dword ptr ds:[reg + 0x2B21E]
- # 根据kernel32.dll的IAT偏移量,获取VMP中CreateProcessW函数的地址
- lea reg, dword ptr ds:[reg + 0x724F2141]
- # 利用上面“push reg”指令,将API函数地址放到栈顶
- xchg dword ptr ss:[esp], reg
- # 跳转到API函数
- ret
复制代码 总结来说,VMP通过执行一条压栈(push)指令和一条返回(ret)指令,在栈上设置一个变量,以便跳转到下一个API地址。
这个地址是通过以下方式计算得出的:
- reg = 0x401113 :指向 CreateProcessW 函数在 kernel32.dll 中的偏移量
- ds:[reg + 0x2B21E] :VMP中 kernel32.dll 导入地址表(IAT)的地址
- ds:[reg + 0x724F2141] :CreateProcessW 函数的地址
复制代码 我没足够时间编写一个导入表修复工具,不过通过运行时模拟(比如用Unicorn之类的引擎)是可以实现的 
0xnobody、can1357和mrxodia等人已经开发出一些工具,可用于解包并修复x64架构下的导入表(点击此处查看)。
原文链接
|