找回密码
 立即注册
搜索
查看: 210|回复: 0

[翻译] VMP 3.x 快速概览 - 第一部分:脱壳

[复制链接]

251

主题

0

回帖

2345

积分

管理员

积分
2345
发表于 2025-11-4 15:14:01 | 显示全部楼层 |阅读模式
    你好

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


原文链接
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

快速回复 返回顶部 返回列表