VMProtect 软件公司成立于2000年,总部位于俄罗斯叶卡捷琳堡。该公司出品的软件保护软件 VMProtect(目前版本已更新到 3.x,以下简称VMP)可以说是软件破解领域的圣杯,多年来无数逆向分析人员前赴后继,一直试图揭开 VMP 的神秘面纱。
VMP 的特色功能包括虚拟化、混淆、反调试等。本文的测试内容主要针对的是VMP的虚拟化机制,即VMP 可以将受保护的代码放到内置的虚拟机中运行,以防止反编译和破解。
VMP 代码虚拟机是一款栈机,也就是说,VMP 的代码虚拟机与被保护程序的代码处于栈空间中。基于栈的虚拟机指令集主要是通过操作栈顶元素来完成。例如,Java 虚拟机(JVM)就是一个典型的基于栈的虚拟机。与之相对应的还有基于寄存器的虚拟机。指令操作数存储在虚拟寄存器中,指令集直接对寄存器进行操作。Lua 的虚拟机(称为 Lua 虚拟机,LVM)就是基于寄存器的。
而基于栈的 VMP 虚拟机因为要维护出栈入栈,所以执行同样的操作,需要的指令较多,间接使得执行效率较差。 VMP 会让程序变得臃肿、运行速度变慢,但被保护的程序代码膨胀后,也能给逆向分析人员造成很多困扰。
(表格出自《加密与解密(第4版)》)
VMP 虚拟化流程:
系统环境:Windows 7 专业版(32 位)
IDE:Dev-C++ 5.7.1
加壳程序:VMProtect 3.4
编译得到CrackMe.exe
在输入错误密码的情况下,程序仍可打印“successful!”
(测试演示出自B站up主Rairn,文末有提供视频链接)
用 OD 加载CrackMe.exe,找到一个关键且易识别的位置,用 VMP 加密;在 OD 反汇编窗口点击右键,选择中文搜索引擎->智能搜索,搜索源代码中的字符串,例如successful

点进字符串successful对应的地址 0x00401702,分析一下反汇编代码,将加壳位置定为 0x004016B0,这看上去应该是一个函数的起始位置。将004016B0 55 PUSH EBP复制到剪切板,在记事本或其他类似工具记下来。

打开 VMProtect 3.4,点击需保护的进程->添加进程,然后把地址004016B0复制进去,编译类型选择超级(编译+虚拟)


点击选项,除了移除调试信息选“是”,其余都选“否”,因为这次测试只针对虚拟机保护,暂不研究其他内容。

然后点击编译,就可以得到加壳后的文件CrackMe.vmp.exe

用 LordPE 查看,CrackMe.vmp.exe多了一个区段.vmp0

用 OD 加载CrackMe.vmp.exe,做好以下几个准备。
在调试选项中设置,因为记录需要跟踪的信息

打开 RUN 跟踪

点击菜单栏的E,将显示的 DLL 标记为系统 DLL,以免之后调试跟踪( trace) 时被记录

Ctrl + g 进入之前记下的地址004016B0,在此处 F2 下一个断点然后运行。可以看到,对比加壳前的情况,反汇编代码已经发生了很大变化。

Ctrl + F11 跟踪步过,程序也打印出了
Please enter your pwd >>

在程序中随便输入一个密码,例如55,然后点开 OD 菜单栏中的……,点击右键选择 记录到文件,把调试跟踪的结果保存为 trace.txt,记得勾选“附加到已有文件”和“写入采集的数据”


查看trace.txt

在trace.txt搜索00000040,因为十六进制的00000040的二进制是0100 0000,可以被用来表示 ZF 标志位。在 x86 架构中,ZF 标志位的位置正好对应到 EFLAGS 寄存器的第 6 位(从第 0 位开始算起)。也就是说,当执行某些指令后,如果结果为零,则 ZF 标志位会被置 1。
VMP 会使用以下公式来计算 ZF 的值:
其中,0x40 就是 ZF 标志位的掩码值。当 eflags 寄存器的第 6 位为 1 时,and 运算的结果也会为 1,表示 ZF 标志位为 1。反之,当第 6 位为 0 时,and 运算的结果为 0,表示 ZF 标志位为 0。这样做可以非常高效地判断 ZF 标志位的状态,而不需要进行复杂的计算。
本次爆破测试是比较输入的密码是否是正确的密码,所以代码实现逻辑大概率依靠 CMP、JZ(若为 0 则跳转,ZF 需为 1)或者JE(若相等则跳转,ZF 需为 1)。
这类指令影响的符号位正是 ZF,所以需要搜寻00000040。
找到00000040后还需要在附近寻找类似以下特征的汇编指令
寻找具备这种特征的汇编指令,是因为这是与非门(俗称万用门)的运算逻辑,表示为: Nand(a,b) = ~a & ~b,即将两个数分别取反后再进行与运算。汇编里面最基本的4种逻辑运算,都能用与非门表示。所以 VMP 3.x 版本中大量使用了Nand运算来表示其他的逻辑运算,真实地隐藏了原本的各种逻辑运算,有效地加大了逆向分析的难度。
cmp指令本质上是减法,只不过结果不会写回操作数,Nand 门也能实现减法:
简而言之,看到了not a, not b, and a,b这类特征的反汇编指令,就可以合理怀疑此处是 VMP 处理虚拟机指令的 handler 。VMP 会在 handler 入口处大量使用 nand 门运算来隐藏原本的各种逻辑运算。这些"Nand"指令往往会涉及对EFLAGS寄存器的读写操作。通过精细控制EFLAGS标志位的状态,VMP 可以实现对程序控制流的保护。
找到了疑似的指令,记下做 and 运算指令的地址0049B79E、以及两个寄存器的值ECX=00000040、EAX=00000246

(FL 是 Flags标志位的缩写,FL=0 意味着在执行指令and ecx, eax后没有发生任何标志位的改变。因为and ecx, eax执行结果为0x40,也就是说原本的 ZF 标志位就是 1。在后面的操作中,我们只需要将修改 ecx ,就能将 ZF 的标志位改为 0, 就能改变程序原本的跳转逻辑。例如,在输入错误时,原本打印“failed”,就能跳转到 “successful!“)
保存为script.txt
OD 重新加载CrackMe.vmp.exe,在反汇编窗口点击右键,选择运行脚本->script.txt

此时程序会运行,并打印字符串Please enter your pwd >>,随便输入 66 按回车键,会弹出窗口显示“脚本运行完成”。

点击“确定”后,发现 EIP 停留在了0049B79E,因为之前脚本就在这个地址下了断点。注意此时的寄存器窗口,各个寄存器的值

F7 走一下,寄存器的值没有发生任何变化

我们将 ECX 的值从00000040改为00000000

然后再点击运行,破解成功,注意此时的 ZF 标志位为 0;

对比看一下,在输入错误密码且没有修改 ecx 的情况下,ZF 标志位为1

参考链接:
https://bbs.kanxue.com/thread-224732.htm
701K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6%4N6Q4x3X3g2U0L8X3u0D9L8$3N6K6i4K6u0W2j5$3!0E0i4K6u0r3N6r3S2W2M7$3g2$3k6h3&6@1K9s2y4G2L8W2)9J5c8Y4m8Q4x3V1j5I4y4o6t1%4y4o6j5#2x3#2)9J5k6h3S2@1L8h3H3`.
0a3K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6%4N6Q4x3X3g2T1K9h3I4A6j5X3W2D9K9g2)9J5k6h3y4G2L8g2)9J5c8Y4k6A6k6r3g2G2i4K6u0r3b7W2j5I4N6V1D9@1P5e0q4c8y4@1D9%4i4K6u0r3i4K6y4r3M7%4m8E0i4K6g2X3K9h3c8Q4y4h3k6X3M7X3!0E0i4K6y4p5x3K6x3K6i4K6u0W2x3K6x3%4i4K6u0W2M7$3g2S2M7X3y4Z5i4K6u0V1j5$3q4J5k6q4)9J5k6h3q4D9L8q4)9J5k6h3y4D9K9h3y4C8i4K6t1$3j5h3#2H3i4K6y4n7N6X3c8Q4y4h3k6K6L8%4g2J5j5$3g2Q4x3@1c8W2y4h3t1$3y4h3y4X3x3$3u0W2j5e0R3%4x3$3t1H3j5$3k6W2z5o6y4U0y4X3j5K6k6o6x3H3j5e0M7I4x3l9`.`.
《加密与解密(第4版)》
| 基本块名称 |
功能 |
| VStartVM |
从真实环境到虚拟环境的转换,开启新的虚拟空间给虚拟机使用,将除 esp 外的所有寄存器压入堆栈,esp 寄存器原来的值存放在 ebp 寄存器中 |
| VMDispatcher |
虚拟指令调度分流 |
| VHandler1..N |
不同功能的虚拟指令 |
| VCheckESP |
检查堆栈基本块,如果 esp (VMP 使用的堆栈)与 ebp (真实堆栈)即将被覆盖,则跟踪转到下一个基本块来开辟堆栈地址(sub esp, xxx),并将原来的 VMContext 中的内容复制到新的堆栈地址处,否则跳回 VMDispatcher |
| VRet |
这个指令存在于 VHandler 集合中。这个指令将堆栈中压入的寄存器值还原到物理寄存器中,然后退出整个虚拟机环境,并切换回真实的 CPU 环境。注意:这个指令的作用是退出虚拟环境,并不等于汇编语义上的 retn。 |
using namespace std;
int main()
{
printf("Please enter your pwd >> ");
string s;
cin>>s;
if(s=="123456")
printf("successful!\n");
else
printf("failed!\n");
system("pause");
return 0;
}
using namespace std;
int main()
{
printf("Please enter your pwd >> ");
string s;
cin>>s;
if(s=="123456")
printf("successful!\n");
else
printf("failed!\n");
system("pause");
return 0;
}
zf = and(0x40, eflags)
not a
not b
and a,b
//这三行代码通常不是连续出现,中间会有无用的代码隔开
//a, b皆为通用寄存器
not a
not b
and a,b
//这三行代码通常不是连续出现,中间会有无用的代码隔开
//a, b皆为通用寄存器
Not(a) = ~a = ~a & ~a = Nand(a,a)
Or(a,b) = a | b = ~(~a & ~b) = Nand(Nand(a,b),Nand(a,b))
And(a,b) = a & b = ~~a & ~~b = Nand(Nand(a,a),Nand(b,b))
Xor(a,b) = (~a & b) | (a & ~b) = (0 | (a & ~b)) | (0 | (b & ~a)) = (a & (~a | ~b)) | (b & (~a | ~b)) = (~a | ~b) & (a | b) = ~(a & b) | ~(~a & ~b) = Nand(And(a,b),Nand(a,b)) =Nand(Nand(Nand(a,a),Nand(b,b)),Nand(a,b))
Not(a) = ~a = ~a & ~a = Nand(a,a)
Or(a,b) = a | b = ~(~a & ~b) = Nand(Nand(a,b),Nand(a,b))
And(a,b) = a & b = ~~a & ~~b = Nand(Nand(a,a),Nand(b,b))