1. 原理
1.1 VMProtect Software 公司
VMProtect 软件公司成立于2000年,总部位于俄罗斯叶卡捷琳堡。该公司出品的软件保护软件 VMProtect(目前版本已更新到 3.x,以下简称VMP)可以说是软件破解领域的圣杯,多年来无数逆向分析人员前赴后继,一直试图揭开 VMP 的神秘面纱。 VMP 的特色功能包括虚拟化、混淆、反调试等。本文的测试内容主要针对的是VMP的虚拟化机制,即VMP 可以将受保护的代码放到内置的虚拟机中运行,以防止反编译和破解。
1.2 VMProtect 虚拟化原理
VMP 代码虚拟机是一款栈机,也就是说,VMP 的代码虚拟机与被保护程序的代码处于栈空间中。基于栈的虚拟机指令集主要是通过操作栈顶元素来完成。例如,Java 虚拟机(JVM)就是一个典型的基于栈的虚拟机。与之相对应的还有基于寄存器的虚拟机。指令操作数存储在虚拟寄存器中,指令集直接对寄存器进行操作。Lua 的虚拟机(称为 Lua 虚拟机,LVM)就是基于寄存器的。 而基于栈的 VMP 虚拟机因为要维护出栈入栈,所以执行同样的操作,需要的指令较多,间接使得执行效率较差。 VMP 会让程序变得臃肿、运行速度变慢,但被保护的程序代码膨胀后,也能给逆向分析人员造成很多困扰。
基本块名称
功能
VStartVM
从真实环境到虚拟环境的转换,开启新的虚拟空间给虚拟机使用,将除 esp 外的所有寄存器压入堆栈,esp 寄存器原来的值存放在 ebp 寄存器中
VMDispatcher
虚拟指令调度分流
VHandler1..N
不同功能的虚拟指令
VCheckESP
检查堆栈基本块,如果 esp (VMP 使用的堆栈)与 ebp (真实堆栈)即将被覆盖,则跟踪转到下一个基本块来开辟堆栈地址(sub esp, xxx),并将原来的 VMContext 中的内容复制到新的堆栈地址处,否则跳回 VMDispatcher
VRet
这个指令存在于 VHandler 集合中。这个指令将堆栈中压入的寄存器值还原到物理寄存器中,然后退出整个虚拟机环境,并切换回真实的 CPU 环境。注意:这个指令的作用是退出虚拟环境,并不等于汇编语义上的 retn。
(表格出自《加密与解密(第4版)》) VMP 虚拟化流程:
VMP 将被保护的代码转换为虚拟机指令集(bytecode),并为每个虚拟指令对应一个处理程序(handler),每个 handler 实际上是一个小型的解释器。这些处理程序不是简单的地址,而是具体的处理逻辑代码。在代码转换过程中, VMP 还会对指令进行混淆和加密,以增加逆向工程的难度。
VMP 还会对一些系统对象如文件、注册表等进行虚拟化,在虚拟机内部维护虚拟的系统资源,使得在虚拟机内部的系统交互不会影响到真实系统。 VMP 通常会结合多种反调试技术,例如代码自修改、反跟踪、反模拟执行等,增加调试难度。
受保护程序运行时,会首先执行插入的 VStartVM 代码。 (VStartVM 通常是作为程序入口点被插入到受保护代码中的 )VStartVM 负责将程序从真实环境转换到虚拟环境,并初始化虚拟机的上下文环境,将真实环境中的寄存器状态保存,并准备虚拟机所需的堆栈空间。此外,VStartVM 还会对一些关键的系统API进行hook,以截获系统调用并在虚拟环境中模拟执行。 VMP 允许在受保护代码块中插入特定的指令,用于主动切换回真实环境,例如调用未被 hook 的系统 API。
VMDispatcher 负责从虚拟指令流中读取虚拟指令(bytecode),然后通过解码确定对应的处理程序(handler)。解码后的虚拟指令并不是直接执行真实指令,而是调用相应的 handler 来模拟执行,这些 handler 处理的是虚拟机指令,而不是直接的原始指令。
VMP 使用两个堆栈:虚拟堆栈和真实堆栈。虚拟堆栈供虚拟机内部使用,而真实堆栈是线程原有的堆栈。为了避免两者冲突,VMP 会监控堆栈指针 esp。当虚拟指令需要修改真实寄存器时,真实寄存器的值会被临时保存在真实堆栈中,并将虚拟寄存器的值应用到真实寄存器上。如果 esp 与 ebp 即将发生冲突,VMP 会将当前虚拟机上下文保存到真实堆栈,然后开辟新的虚拟堆栈空间,确保虚拟机正常运行。对于多线程程序, VMP 会为每个线程维护独立的虚拟机上下文(VMContext),不同线程的虚拟机之间相互隔离。
虚拟化指令执行完之后,虚拟机并不会立即退出虚拟环境。只有在整个受保护代码块执行完毕或者发生异常时,才会退出虚拟环境,恢复真实环境中的状态。退出虚拟环境时,虚拟机会恢复原始的寄存器状态,并确保所有的上下文环境都正确恢复。不过在退出之前,还需要将虚拟寄存器的值写回到真实寄存器,并解除之前所做的系统API hook。
2. 测试程序
2.1 测试环境
系统环境:Windows 7 专业版(32 位) IDE:Dev-C++ 5.7.1 加壳程序:VMProtect 3.4
2.2 测试程序源代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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
;
}
编译得到CrackMe.exe
2.3 测试目标
在输入错误密码的情况下,程序仍可打印“successful!” (测试演示出自B站up主Rairn,文末有提供视频链接)
3. VMP 编译测试程序
用 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
4. 调试和脚本编写
4.1 调试准备
用 OD 加载CrackMe.vmp.exe
,做好以下几个准备。
4.2 调试过程
Ctrl + g 进入之前记下的地址004016B0
,在此处 F2 下一个断点然后运行。可以看到,对比加壳前的情况,反汇编代码已经发生了很大变化。 Ctrl + F11 跟踪步过,程序也打印出了
Please enter your pwd >>
4.3 Trace 记录
在程序中随便输入一个密码,例如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
后还需要在附近寻找类似以下特征的汇编指令
1
2
3
4
5
not
a
not
b
and
a,b
/
/
这三行代码通常不是连续出现,中间会有无用的代码隔开
/
/
a, b皆为通用寄存器
寻找具备这种特征的汇编指令,是因为这是与非门(俗称万用门)的运算逻辑,表示为: Nand(a,b) = ~a & ~b,即将两个数分别取反后再进行与运算。汇编里面最基本的4种逻辑运算,都能用与非门表示。所以 VMP 3.x 版本中大量使用了Nand运算来表示其他的逻辑运算,真实地隐藏了原本的各种逻辑运算,有效地加大了逆向分析的难度。
1
2
3
4
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))
cmp指令本质上是减法,只不过结果不会写回操作数,Nand 门也能实现减法:
1
2
3
-
a
=
~a
+
1
=
> ~a
=
-
a
-
1
~(~a
+
b)
=
~(
-
a
-
1
+
b)
=
-
(
-
a
-
1
+
b)
-
1
=
a
-
b
=
> a
-
b
=
Not(Not(a)
+
b)
a
-
b最终可以由Not(Not(a)
+
b)来表示,而Not(a)又可以用Nand(a,a)来表示
简而言之,看到了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!“)
4.4 脚本编写
1
2
3
4
5
6
7
8
9
10
11
12
bp
0049B79E
/
/
设置断点,当程序执行到地址
0049B79E
时暂停执行。
start:
/
/
标签,用于标识后续指令的起始位置。
run
/
/
继续执行程序,直到触发了设置的断点。
cmp
ecx,
00000040
jnz start
cmp
eax,
00000246
jnz start
end:
bc
0049B79E
/
/
清除断点,使得程序不再在地址
0049B79E
处暂停执行。
ret
/
/
返回指令,结束当前脚本的执行。
保存为script.txt
5. 重新调试和破解
5.1 运行脚本
OD 重新加载CrackMe.vmp.exe
,在反汇编窗口点击右键,选择运行脚本->script.txt
此时程序会运行,并打印字符串Please enter your pwd >>
,随便输入 66 按回车键,会弹出窗口显示“脚本运行完成”。
5.2 修改寄存器的值
点击“确定”后,发现 EIP 停留在了0049B79E
,因为之前脚本就在这个地址下了断点。注意此时的寄存器窗口,各个寄存器的值
1
2
3
4
5
6
7
8
9
EAX
00000246
ECX
00000040
EDX
50655CD1
EBX
004D7A92
CrackMe_.
004D7A92
ESP
0022FDE4
EBP
0049B777
CrackMe_.
0049B777
ESI
0022FEC6
EDI
004F9B18
CrackMe_.
004F9B18
EIP
0049B79E
CrackMe_.
0049B79E
F7 走一下,寄存器的值没有发生任何变化 我们将 ECX 的值从00000040
改为00000000
6. 破解成功
然后再点击运行,破解成功,注意此时的 ZF 标志位为 0;
1
2
3
eax
=
0x246
;
ecx
=
0x0
;
运算结果是
0
;
对比看一下,在输入错误密码且没有修改 ecx 的情况下,ZF 标志位为1
参考链接:https://bbs.kanxue.com/thread-224732.htm https://www.cnblogs.com/theseventhson/p/14274653.html https://www.bilibili.com/video/BV1vK4y1Q7K7/?spm_id_from=333.337.search-card.all.click&vd_source=e5b65cf3bea873b0cfe83c6f3d30a710 《加密与解密(第4版)》
最后于 3小时前
被ZyOrca编辑
,原因:
上传的附件: