本帖翻译自r0da的博客 (链接地址)[https://whereisr0da.github.io/blog/posts/2021-01-05-vmp-1/]
序
大家好,这是我第一次研究VMProtect。VMP是一款非常有名的加壳保护软件,它具有很多功能,其中最主要的功能是代码变异和虚拟化,但是Part 1只涉及VMP中最简单的保护措施。其他保护措施将会在后面的帖子进行讨论。现在我们先关注壳代码和输入表混淆。
壳代码
壳代码主要是对可执行文件的节区进行压缩和加解密,从而防止逆向人员进行静态分析。考虑到这种方式在程序执行阶段代码和节区会被解密出来,因此保护方法一般不是很有成效。
针对这种情况,VMProtect不会将真实原始文件节区信息存储在PE文件头里。这种保护措施听上去很腻害。但是仍然有一些虚拟地址和大小应该始终要存在PE头里,这样Windows内核才能为可执行文件分配正确大小的空间。因此在不考虑地址空间布局随机化的情况下,我们仍然可以需要留心节区大小和一些可能地址。
关于原始文件的内容,几乎全部被放进了.vmp1
节区,这个节区包括了被加密的节区内容,壳代码,节区信息等。
唯一没有被保护的节区是.rsrc,因为Windows需要读取它来提取程序图标和其他应用信息用以显示在属性菜单上。VMProctect有一个保护资源的选项,他通过把资源分成两部分,一部分是针对Windows需要而创建的;一部分是程序自身运行时所需的资源信息,只在程序执行过程中被加密和解密。
截图中除了.data节区,其他的节区都是不可写入的。所以VMP将会调用VirtualProtect
或者类似的函数来改变节区的属性,然后修改节区内容。VMP 3.x以前,VMP使用VirtulProtect
函数,之后它使用了未文档化的内核API函数ZwProtectVirtualMemory
来达到同样的目的。
因为ZwProtectVirtualMemory
会在PE加载时会被Windows内部被频繁调用,所以我们只在到达PE入口点后,才对其进行下断操作。
可以注意到vmp程序的入口点看起来像是一个VMProtect 虚拟化化后的函数。在VM 2.x以前,VMP的壳代码是未被虚拟化的。我们很容易在单步运行push
,ret
指令后跳转到OEP。
现在我们知道VMP会在解码中会修改两次节区属性,一次用于写入数据,一次是用于恢复节区属性。在一定量的断点命中后,我们应该可以得到ZwProtectVirtualMemory
的调用次数。
n = 不可写入的节区数
n+1 (.vmp0): 改变写保护标志的次数
1: 移除拷贝标志位的次数
n+1 (.vmp0) 恢复原始标志位
因此vmp执行脱壳代码的次数为((n+1)*2)+1
。这里有一张反映这种操作的动态图,注意观察右边的标志位变化。
在壳代码运行完后,我们就可以通过任意工具来dump 转储可执行文件,不过目前还没有修复导入表。
以我对vmp相关研究来看, vmp0节区包含了虚拟化和变异后的程序代码,以及IAT相关的代码。vmp1节区包含了所有与壳机制相关的东西,例如被加密的节区,虚拟化后的壳代码,节区信息等。
寻找OEP
由于壳代码被虚拟化了,因此想通过vmp代码来找到oep相当困难。我们需要学习一些技巧:
- 我们可以监视eip寄存器的值变化,尤其是当eip寄存器的值从
vmp1
切换到其他节区,而不是.vmp0
时。这样应该对于寻找OEP来说能起作用。我尝试过编写Qiling脚本来实现这个功能,但是Qiling脚本并没有实现ZwProtectVirtualMemory
这个函数,这导致我们的工作无法进展下去。后来我写了一个unicorn Python脚本来实现了这个任务。
- 我们也可以通过对
.text
节区下硬件执行断点来达到同样的结果。在我的例子中,oep是.text节区上的第一个函数,但是这种情况比较少见,不可能所有受vmp保护的程序都满足这种情况。
- 你还可以通过查看第一个函数堆栈的cookie来寻找oep。使用vc++编译的程序,他的栈cookie通常是
0x2B992DDFA232
。详细内容请参考这篇优秀文章
4.你也可以手动寻找oep。通过尝试观察栈底来寻找第一个可能是OEP的返回地址。
IAT 混淆
VMP的IAT混淆是一个可选项,并不是默认设置的。有时你也会遇到vmp保护的程序不会发生重建IAT的情况,原因是开发者不擅长使用vmp加壳。当然,我肯定懂加壳,帖子里说的情况也是开了IAT混淆保护的。
首先,我们会看到原始IAT依旧被保存在了PE文件中,但却不会被使用。
虽然你能看到程序使用了哪些API,却不能通过交叉引用来静态分析,原因是导入地址是运行时动态计算的。当然如果是我来实现IAT保护,我应该是通过导入一个dll的随机函数来实现,而不是保留原来的所有东西;或者干脆完全删除IAT的内容,用LoadLibrary加载每个DLL以后,再解决导入表问题。我们通过查看vmp程序调用API的完整流程,可以注意到相关API调用的代码只是进行了变异操作,而没有进行虚拟化保护。
每一个IAT相关函数的调用都在.text
节区,比如说call dword ptr ds:[<&CreateProcessW>]
, 是6个字节长度的指令。VMP将会把这种原始的API调用改变成这种类型的调用:
push 随机寄存器
call 变异后的api解析器
为了保持代码对齐,这两条指令也是6字节大小的。每一个API 调用都有一个变异后的api解析器函数。这也解释了为什么脚本会有大量的输出,当然也有一部分原因是代码虚拟化。VMP使用一个随机寄存器来传递一个参数给API解析器。
这里给出一个变异后的版本例子。随机寄存器选择为edi。
注意:正如我所说,这个代码在.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 是被 push 的寄存器
# 下面的代码是得到要调用的返回地址
pop reg
# reg与返回地址交换值
# 因此返回的时候会调用到相应的API
xchg dword ptr ss:[esp], reg
# 设置未来的返回地址用来跳转到API函数
push reg
# 计算函数地址
# magic number
mov reg, 0x401113
# 可理解为获取VMP针对 kernel32.dll 重构后的IAT地址
mov reg, dword ptr ds:[reg + 0x2B21E]
# 获取CreateProcessW地址
lea reg, dword ptr ds:[reg + 0x724F2141]
# put the API function address on stack top, using the "push reg" above
# 将API函数地址放到栈顶
xchg dword ptr ss:[esp], reg
# 跳转到API函数
ret
做个总结,vmp使用push建立了一个栈变量,然后通过ret跳转到API函数地址。
地址的计算方式如下:
reg = 0x401113 : magic number
ds:[reg + 0x2B21E] : VMP针对kernel32.dll重构后的IAT地址
ds:[reg + 0x724F2141] : CreateProcessW的实际地址
我没有足够的时间去编码实现一个导入表修复器,但是这确实可以通过模拟器来实现,比如说unicorn等。
0xnobody, can1357 和 mrxodia 写了一些工具来脱壳和修复64位程序的导入表。
(工具链接地址)[https://github.com/0xnobody/vmpdump]