VMProtect 3.3.1虚拟机&代码混淆机制入门
0x00 写在前面
VMProtect 其实已经被前辈们扒得体无完肤了,本来没有什么好写的,但由于最近要把VMP拿出来学习,花了两天时间从 1.x -> 2.x -> 3.x,一直到最新的 3.3.1 顺着分析了一次。本文只是对其虚拟机和代码混淆机制做个笔记,没有太多的技术含量。
本文的行文思路和前面的原理部分大量抄了“ 穆恩”的 3.0.9 的分析文章,请大神谅解,有错误也请大家指出。
0x01 分析目标
写一份最简单的汇编代码:
; Filename: testVM.asm
.386
.model flat, stdcall
option casemap: none
include \masm32\include\windows.inc
include \masm32\include\kernel32.inc
include \masm32\include\user32.inc
includelib \masm32\lib\kernel32.lib
includelib \masm32\lib\user32.lib
.data
szMsg db '我是内容', 0
szTitle db '我是标题', 0
.code
start:
push 2019H
invoke MessageBox, NULL, offset szMsg, offset szTitle, MB_OK
invoke ExitProcess, 0
end start
用 masm32 编译成 testVM.exe 之后再用 OD 1.10 打开,是不是跟看源代码似的?
用 VMP 3.3.1 加壳,去掉所有的反调试、保护等等,目的是只保留最简单纯粹的虚拟机部分,避免不必要的干扰,方便我们分析学习:
0x02 各版本差异
testVMP.exe 原始文件(2560字节)和用 VMP 不同版本加壳后的文件尺寸如下:
版本 | 文件尺寸 |
原始文件 | 2k (2,560字节) |
1.1 | 7k (7,168字节) |
1.8 | 13k (13,312字节) |
2.13.8 | 16k (16,384字节) |
3.0.9 | 515k (515,072字节) |
3.3.1 | 559k (559,104字节) |
可以看到 1.x 和 2.x 都只在原始文件尺寸的基础上增加了一点点,但是从3.x开始其尺寸急剧膨胀,为什么会这样呢?
这里我们要用到 OD 非常棒的 Run trace 功能,打开 1.8、2.13.8 和 3.3.1 的exe,按 Ctrl+F11(或选菜单 Debug->Trace info),再选菜单 View->Run trace,可以看到运行的指令数:
版本 | 运行指令数 |
1.8 | 3896 |
2.13.8 | 11283 |
3.3.1 | 1500 |
然后在 Run trace 的窗口点右键选 Profile module,按照每条指令的运行次数(Count)排序,各个版本的结果是这样的:
1.8:
Profile of testVMP_
Count Address First command Comment
40. 004042C5 mov byte ptr [esp+8], ch
40. 00404BE5 pushfd
40. 00405106 bt cx, 0A
21. 00404405 lea eax, dword ptr [edi+50]
21. 00404D39 lea esp, dword ptr [esp+C]
21. 00405198 pushfd
21. 0040558B inc ah
21. 004059E6 call 00404405
16. 004056E7 sbb dx, di
16. 0040599E mov dword ptr [edi+eax], edx
13. 0040432A push dword ptr [esp]
13. 00404371 lea edx, dword ptr [esp+C1B1
13. 00405D42 adc dh, 64
5. 004041E6 shld ax, cx, cl
5. 0040539A rol eax, 14
5. 00405B14 pushfd
(...省略)
2.13.8:
Profile of testVMP_
Count Address First command Comment
134. 004047FB movsx edx, bl
134. 00404BDC call 004067BB
134. 00405310 push dword ptr [esp]
134. 0040601E shl dx, cl
134. 004067BB jmp 00405310
54. 0040450B mov word ptr [esp], bx
54. 004046A1 pushad
54. 00405C8B pushfd
52. 004042BA pushfd
52. 0040670D cmc
31. 004041C9 dec dh
31. 004043D6 dec esi
31. 0040458A pushad
(...省略)
3.3.1:
Profile of testVMP_
Count Address First command Comment
15. 0040C55B lea edx, dword ptr [esp+60]
15. 0042E47E ja 0043A480
15. 0043A480 push esi
1. 00401000 jmp 0046CC5F
1. 00401026 jmp dword ptr [<&user32.Mess
1. 00407C43 rol eax, 2
1. 00407D78 ror dl, 1
1. 00407E9C sub edi, 4
1. 004082E5 lea edi, dword ptr [edi-1]
1. 004083CD push esi
(...省略)
结合上面几点,我们会发现3.x的文件尺寸远超1.x和2.x,但 Run trace 中的每条指令运行次数反而要远少于 1.x 和 2.x,所以答案就不言而喻了:
1. 在1.x和2.x中,有一个统一的 VMDispatcher 作为所有字节码(VM ByteCode)的调度者,以寄存器al作为索引进行跳转,所以最大可以有256个指令的Handler。每个 Handler 执行完后,会跳转回 VMDispatcher,通过al取下一条指令的索引并跳转到它的 Handler,再周而复始地执行下去;
2. 在3.x中,已经没有这个统一的 VMDispatcher 了,每条指令的 Handler 几乎都是零散分布的,在上一条指令的 Handler 执行完后,可能会通过某种类型的跳转跳到下一条指令的 Handler 去,也就是说每条指令都可能会有一个 Handler,哪怕这两条指令是执行相同的功能,因此代码会膨胀得厉害(但不是非常确定,也有可能是 Handler-Table 变大了);
3. 由于没有了这个统一的主循环 VMDispatcher,进而不能顺藤摸瓜各个Handler,所以fkvmp、VMP分析插件1.4等上古神器都在3.x中失效了。
再来说说高版本的3.x 与低版本的1.x和2.x相比,寄存器和堆栈的变化:
寄存器:
ebp依然是VM_esp,指向虚拟机的栈顶
edi不再指向VMContext
esi不再指向VM_eip,在跳转Handler的方式上,3.0.9是用jmp edi或者push edi, retn实现,3.3.1是用jmp esi或者push esi, retn实现。
堆栈:
1.x~2.x:栈底 -> ebp -> edi(VMContext)
3.x:栈底 -> ebp -> esp(VMContext),也就是edi已经不再指向VMContext,而是直接由[esp+索引寄存器]来定位到VMContext的某一项,注意这里的“索引寄存器”并不确定,有可能是edx,也有可能是别的通用寄存器,谁有空就用谁。
0x03 具体分析
熟悉 1.x 和 2.x 的话,看 3.x 的虚拟机代码不会有太大的问题,只不过混淆的垃圾指令太多,大片大片跳过即可。
0x0301 初始化
刚开始的通用寄存器和标志寄存器:
EAX CF1028BC
ECX 00401000
EDX 00401000
EBX 002AD000
ESP 0019FF78
EBP 0019FF94
ESI 00401000
EDI 00401000
EFLAGS 00000246
在 EntryPoint 入口,按几下 F7 就到保存通用寄存器和标志寄存器的地方了。在早期版本中执行一条 pushad 和 pushfd 就完事了,这里用了很多条,还穿插了很多垃圾指令:
00401000 > $- E9 5ABC0600 jmp 0046CC5F ; 入口第一条指令
0046CC5F 68 A01ABCE0 push E0BC1AA0 ; KEY
0046CC64 E8 99E3FFFF call 0046B002
0046B002 50 push eax ; 保存原始eax
0046B003 ^ E9 1761FBFF jmp 0042111F
0042111F 52 push edx ; 保存原始edx
00421120 B2 2E mov dl, 2E ; // 垃圾指令
00421122 F6D6 not dh ; // 垃圾指令
00421124 87D2 xchg edx, edx ; // 垃圾指令
00421126 57 push edi ; 保存原始edi
00421127 F7D7 not edi ; // 垃圾指令
00421129 51 push ecx ; 保存原始ecx
0042112A 9C pushfd ; 保存eflags
0042112B 87D7 xchg edi, edx ; // 垃圾指令
0042112D 4F dec edi ; // 垃圾指令
0042112E 53 push ebx ; 保存原始ebx
0042112F FECA dec dl ; // 垃圾指令
00421131 0FBFDB movsx ebx, bx ; // 垃圾指令
00421134 C6C6 99 mov dh, 99 ; // 垃圾指令
00421137 56 push esi ; 保存原始esi
00421138 66:0FCB bswap bx ; // 垃圾指令
0042113B F6D6 not dh ; // 垃圾指令
0042113D 55 push ebp ; 保存原始ebp
0042113E 66:8BF5 mov si, bp ; // 垃圾指令
00421141 B9 00000000 mov ecx, 0 ; // 垃圾指令
00421146 E9 C31A0100 jmp 00432C0E
00432C0E 51 push ecx ; ecx=0,跟以前版本的VMP一样,以push 0为寄存器入栈结束的标志
执行完后堆栈是这样的,就是按照上面的各种 push 顺序,保存了通用寄存器和标志寄存器:
Address Value Comment
0019FF58 00000000 0
0019FF5C 0019FF94 ebp
0019FF60 00401000 esi
0019FF64 002AD000 ebx
0019FF68 00000246 eflags
0019FF6C 00401000 ecx
0019FF70 00401000 edi
0019FF74 00401000 edx
0019FF78 CF1028BC eax
0019FF7C 0046CC69 RETURN to testVMP_.0046CC69 from testVMP_.0046B002
0019FF80 E0BC1AA0 前面压栈的key
由于混淆的指令太多,下面我会把垃圾指令删掉,只保留关键指令,所以地址会有点不连续。
0x0302 初始化VMContext
分配 VMContext 的地址空间:
00432C11 8B7C24 28 mov edi, dword ptr [esp+28]
00432C17 47 inc edi
00432C19 C1CF 02 ror edi, 2
00432C1C 81EF A82E2677 sub edi, 77262EA8
00432C2C C1CF 02 ror edi, 2
00432C33 03F9 add edi, ecx ; 解密edi完成,此时edi指向VM_eip,也就是虚拟机的ByteCode的地址
00432C3C 8BEC mov ebp, esp
00432C3E 81EC C0000000 sub esp, 0C0 ; 分配VMContext的空间,大小0xC0个字节,此时esp指向VMContext,虚拟机栈顶仍为ebp
计算第一个 Handler 的地址:
00432C5A 8D35 5A2C4300 lea esi, dword ptr [432C5A] ; esi是第一个Handler的地址,但此时还没计算出正确的地址
00432C65 81EF 04000000 sub edi, 4 ; 指向下一条ByteCode的地址,可以看出虚拟机是倒着走的
00432C71 8B17 mov edx, dword ptr [edi] ; 取得第一条ByteCode地址的offset
00432C73 33D3 xor edx, ebx ; 下面开始解密该offset
00432C76 D1CA ror edx, 1
00432C79 0FCA bswap edx
00432C7B 81C2 6C42870C add edx, 0C87426C
00432C81 0FCA bswap edx
00432C86 03F2 add esi, edx ; edx解密完成。加上解密完的offset后,esi就指向了第一个Handler的正确的地址
00432C88 E9 FE450000 jmp 0043728B
0043728B FFE6 jmp esi ; 此时esi就是VM_eip,跳到第一个Handler
第一个 Handler,实际上就是把虚拟机栈顶的0给 POP 出来,然后赋值到 VMContext[0x38],这里寄存器edx是作为 VMContext 保存项的索引:
00422619 8B4425 00 mov eax, dword ptr [ebp] ; ebp指向VMP的栈顶,所以这里相当于POP eax,就是把0出栈到eax
00422624 8DAD 04000000 lea ebp, dword ptr [ebp+4] ; 栈顶指针+4,结合00422619处的指令其实就是一条标准的POP
0042262D 81EF 01000000 sub edi, 1 ; edi指向下一个ByteCode的地址
0042263A 0FB617 movzx edx, byte ptr [edi]
0042264F E9 9DB50500 jmp 0047DBF1
; 这里还有一大堆对edx的解密计算,省略...
; 最终edx=0x38
0047DBFC 890414 mov dword ptr [esp+edx], eax ; edx=0x38, esp=VMContext, VMContext[0x38]=0
当第一个 Handler 执行完毕后,通过下面的指令序列计算并跳到下一个 Handler:
0047DC26 E9 2D10FEFF jmp 0045EC58
0045EC58 8D80 410B104C lea eax, dword ptr [eax+4C100B41]
0045EC66 03F0 add esi, eax ; esi指向下一个Handler的地址
0045EC68 E9 48960000 jmp 004682B5
004682B5 FFE6 jmp esi ; 真正跳转到下一个Handler
在这里可以看出,并没有一个统一的 VMDispatcher,而是通过一个又一个的 jmp esi,衔接各个 Handler,达到混淆的目的。
接下来的 Handler,实际上是把虚拟机栈顶的 ebp 给 POP 出来,然后赋值到 VMContext[0x1C]:
00472C9B 8B4425 00 mov eax, dword ptr [ebp] ; 这里是把之前压入栈顶的ebp赋值给eax
00472CA2 81C5 04000000 add ebp, 4 ; POP eax
00478281 890414 mov dword ptr [esp+edx], eax ; edx=0x1C, esp=VMContext, VMContext[0x1C]=ebp
00478288 ^ E9 8BC3FAFF jmp 00424618
看到这里,想必聪明的读者已经找到规律了,还记得最前面入口处的指令是在干什么吗?当时是按照以下的顺序保存通用寄存器和标志位寄存器:
PUSH key
PUSH eax
PUSH edx
PUSH edi
PUSH ecx
PUSH eflags
PUSH ebx
PUSH esi
PUSH ebp
PUSH 0
刚才上面的两条 Handler 分别是把栈顶的0和 ebp 给 POP 出来(存在eax中),然后保存到 VMContext 的 0x38 和 0x1C 偏移处(用edx表示偏移)。
所以这里实际上是执行连续 10 条 POP 指令的 Handler,把8个通用寄存器和1个标志位寄存器,以及1个0,还有1个key保存到 VMContext 中。
为了节省篇幅就不把每个 Handler 都列出来了,全部执行完之后 VMContext 是这样的:
struct VMContext
{
+0x38 0
+0x1C ebp
+0x28 esi
+0x24 ebx
+0x04 eflags
+0x08 ecx
+0x14 edi
+0x00 edx
+0x10 eax
+0x34 加密key
};
跑了几百条指令,这才把 VMContext 初始化完成了。
这中间充斥着大量的垃圾指令混淆视听,我们分析的时候不必执着于把每条指令都看懂,只要抓关键点,例如 mov dword ptr [esp+edx], eax 这样的就是在写 VMContext 数组,记下 eax 表示写入的内容,edx 表示写到 VMContext 的第几项就行了。
0x0303 真正开始执行代码
VMContext 初始化完成后,下面的 Handler 就是执行源程序中的每条指令了,略过垃圾指令后,我们会看到这样的 Handler:
0048B642 8B07 mov eax, dword ptr [edi] ; 取源程序中的PUSH 2019H的加密后的2019H
0044091A 33C3 xor eax, ebx ; 解密eax
0044091D F7D8 neg eax
0044091F 35 FC5DA065 xor eax, 65A05DFC
00440927 F7D8 neg eax ; eax = 2018
00440929 40 inc eax ; eax = 2019
00440934 8DAD FCFFFFFF lea ebp, dword ptr [ebp-4] ; 栈顶-4
0044093C 894425 00 mov dword ptr [ebp], eax ; PUSH 2019H
此时 ebp=0019FF80,指向的虚拟机栈顶是 2019H,是不是很熟悉?
翻到本文的前面部分看看源代码,第一条是不是就是 PUSH 2019H?说明这个 Handler 就是执行源程序中的 PUSH xxxxxxxx
到这里,第一条真正的代码终于执行完了。
接下来的源代码是:
invoke MessageBox, NULL, offset szMsg, offset szTitle, MB_OK
编译之后就是:
00401005 6A 00 push 0 ; /Style = MB_OK|MB_APPLMODAL
00401007 68 09304000 push 00403009 ; |Title = "我是标题"
0040100C 68 00304000 push 00403000 ; |Text = "我是内容"
00401011 6A 00 push 0 ; |hOwner = NULL
00401013 E8 0E000000 call <jmp.&user32.MessageBoxA> ; \MessageBoxA
接下来的 Handler 们就是执行上面的代码:
0048A7AF 8DAD FCFFFFFF lea ebp, dword ptr
0048A7B5 894425 00 mov dword ptr
0x0304 判断虚拟机栈空间是否够用
由于 VMP 是基于堆栈的虚拟机架构(Stack-based Virtual Machine),所以真实世界中的每个压栈操作执行后,VMP 都会判断栈空间是否足够,一旦不够就要重新分配空间并且把堆栈复制过去,所以上面的 PUSH 0、PUSH 00403009 等指令执行完后,都会进入类似下面的 Handler 处理栈空间:
第一步,判断栈空间是否足够:
0040C55B 8D5424 60 lea edx, dword ptr [esp+60]
0040C55F 3BEA cmp ebp, edx ; 判断虚拟机的栈空间(ebp)是否够用
0042E47E 0F87 FCBF0000 ja 0043A480 ; 够用的话就跳走,继续执行下一个Handler
如下图,此时edx=0019FEF8,ebp=0019FF7C,ebp-edx=0x84,算上之前第一个PUSH 0用了4个字节,0x84+0x4=0x88,也就是判断栈空间是否已经被PUSH过0x88 / 4 = 34次。
如果栈空间不够用:
0042E484 8BC4 mov eax, esp ; 不够用的话,开辟一块新的空间,并且把原来堆栈的内容复制过去,把旧的栈顶地址赋值给eax
0042E48C B9 40000000 mov ecx, 40
0042E493 8D5425 80 lea edx, dword ptr [ebp-80] ; 栈顶向下走0x80个字节
0042E49C 81E2 FCFFFFFF and edx, FFFFFFFC
0042E4A2 2BD1 sub edx, ecx ; 栈顶再向下走0x40个字节,也就是0x80+0x40=0xC0个字节,跟初始化VMContext时分配的0xC0空间一样大
0042E4A4 8BE2 mov esp, edx ; 新的栈顶
0042E4A6 E9 B80A0100 jmp 0043EF63
0043EF63 57 push edi ; 保存edi
0043EF64 56 push esi ; 保存esi
0043EF6C 9C pushfd ; 保存eflags
0043EF6D 8BF0 mov esi, eax ; eax=旧的栈顶地址,赋值给esi,为下面的copy做准备
0043EF79 8BFA mov edi, edx ; edx=新的栈顶地址,赋值给edi,为下面的copy做准备
0043A471 FC cld
0043A472 F3:A4 rep movs byte ptr es:[edi], byte ptr [esi] ; 复制堆栈内容到新的空间
0043A474 F9 stc
0043A478 9D popfd ; 恢复eflags
0043A479 5E pop esi ; 恢复esi
0043A47F 5F pop edi ; 恢复edi
0043A480 56 push esi ; 原来的esi是指向下一个Handler的地址
0043A481 C3 retn ; 跳到下一个Handler
处理逻辑是:在现在的栈顶地址基础上,再分配一段 0xC0 大小的栈空间,然后把旧的栈空间的内容 copy 到新的栈空间地址去。
(还记得最前面初始化 VMContext 的时候有一段这样的代码吗?重新分配 0xC0 空间是跟这里相互对应的)
00432C3E 81EC C0000000 sub esp, 0C0 ; 分配VMContext的空间,大小0xC0个字节,此时esp指向VMContext,虚拟机栈顶仍为ebp
用伪代码总结一下:
void CheckESP()
{
Push(something);
if (Stack.ESP > Stack.EBP)
{
Stack.ESP = Stack.EBP + alloc_memory[0xC0];
memcpy(Stack.ESP, Stack.EBP, 0x40);
}
else
{
goto Next_Handler;
}
}
0x0305 执行API
虚拟机基本指令的流程已经分析清楚了,接下来就是重复的套路。
再经过无数次按F7之后,我们最终会看到ebp指向的虚拟机堆栈变成这样:
是不是万事俱备只欠东风了呢?
为了回到真实的世界执行 Windows API,虚拟机还要把之前的现场环境恢复,所以之前记录在 VMContext 中的 8 个通用寄存器和1个标志位寄存器就派上用场了。
关键的代码块形如:
004665D9 8B140C mov edx, dword ptr [esp+ecx] ; 从VMContext中取出寄存器的值
004665DC 8DAD FCFFFFFF lea ebp, dword ptr [ebp-4] ; 相当于PUSH register
004665E5 895425 00 mov dword ptr [ebp], edx ; 把寄存器的值复制到虚拟机的栈顶
当从 VMContext 中把之前保存的通用寄存器和标志寄存器的值复制到虚拟机栈顶(ebp)后,执行 mov esp, ebp 把真实世界的栈顶 esp 指向虚拟机的栈顶 ebp,然后执行多条 pop 指令,恢复现场:
004829DE 8BE5 mov esp, ebp
004829E3 5D pop ebp
004829E4 5E pop esi
004829E5 5B pop ebx
004829E9 9D popfd
004829EA 59 pop ecx
004829EE 5F pop edi
004829F7 5A pop edx
004829FA 58 pop eax
004829FB E9 A50CFEFF jmp 004636A5
004636A5 C3 retn ; Welcome to the real world!!!
最后那条retn会跳到esp指向的地址,即00401026 jmp user32.MessageBoxA这里:
在 retn 那里按 F7 一下,看右下角的堆栈,已经把 MessageBoxA 需要的参数都压好栈了:
这样一条完整的 Windows API 就执行完了。接下来就是下一个代码块的执行,跟前面的套路是一样的,不细说了。
另外值得注意的是,由于有寄存器轮转机制的存在,VMContext 内部的偏移每隔一段时间就会被打乱一次,所以在分析中如果发现 VMContext 的某些地方被重复使用了,这是正常的。
0x04 写在最后
其实人肉跟一次 VMProtect 的纯虚拟机部分并不是那么困难,耐心点调试半天也就差不多了。有时间的话最好自己再写一个VM,这样可以更好地加深对虚拟机的理解。
困难的部分是自动化分析工具,因为 VMProtect 把原始的 x86 机器码转译成了自己的 VM bytecode——这就相当于从C语言变成 asm 很容易,但是要从 asm 变回 C语言则很困难。如何在这条路上继续走下去,尤其是在3.x 已经大幅度修改了以前的架构,不再存在统一的 VMDispatcher 后,这将会是一个很有深度的课题。
最后感谢穆恩大牛 3.0.9 的分析文章,给了我很多启发,本文的开头部分和结构基本上就是照着他的文章写的,抄袭的地方请见谅,无法学习,只能膜拜。
加壳后的 exe 放在附件中了,解压密码是 vmp331,有兴趣的话可以用OD跟一次。(请点击阅读原文,进入原帖下载)
- End -
看雪ID:luocong
https://bbs.pediy.com/user-5411.htm
本文由看雪论坛 luocong 原创
转载请注明来自看雪社区
戳
⚠️ 注意
2019 看雪安全开发者峰会门票正在热售中!
长按识别下方二维码,即可享受 2.5折 优惠!
热门文章阅读
公众号ID:ikanxue
官方微博:看雪安全
商务合作:wsc@kanxue.com
↙点击下方“阅读原文”,查看更多干货