VMProtect 3.3.1虚拟机&代码混淆机制入门

本帖最后由 Joduska 于 2019-7-24 00:16 编辑

0x00 写在前面
VMProtect其实已经被前辈们扒得体无完肤了,本来没有什么好写的,但由于最近要把VMP拿出来学习,花了两天时间从1.x -> 2.x -> 3.x,一直到最新的3.3.1顺着分析了一次。本文只是对其虚拟机和代码混淆机制做个笔记,没有太多的技术含量。

(本文的行文思路和前面的原理部分大量抄了“ 穆恩”的3.0.9的分析文章,请大神谅解,有错误也请大家指出。)
0x01 分析目标
写一份最简单的汇编代码:

[C] 纯文本查看 复制代码

; 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打开,是不是跟看源代码似的?



用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 into),再选菜单View->Run trace,可以看到运行的指令数:

版本 运行指令数 1.8 3896 2.13.8 11283 3.3.1 1500

然后在Run trace的窗口点右键选Profile module,按照每条指令的运行次数(Count)排序,各个版本的结果是这样的:

1.8:
[Asm] 纯文本查看 复制代码

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:

[Asm] 纯文本查看 复制代码

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:

[Asm] 纯文本查看 复制代码

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.x和2.x中,有一个统一的VMDispatcher 作为所有字节码(VM ByteCode)的调度者,以寄存器al作为索引进行跳转,所以最大可以有256个指令的Handler。每个Handler执行完后,会跳转回VMDispatcher,通过al取下一条指令的索引并跳转到它的Handler,再周而复始地执行下去;
  • 在3.x中,已经没有这个统一的VMDispatcher了,每条指令的Handler几乎都是零散分布的,在上一条指令的Handler执行完后,可能会通过某种类型的跳转跳到下一条指令的Handler去,也就是说每条指令都可能会有一个Handler,哪怕这两条指令是执行相同的功能,因此代码会膨胀得厉害(但不是非常确定,也有可能是Handler-Table变大了);
  • 由于没有了这个统一的主循环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 初始化刚开始的通用寄存器和标志寄存器:[Asm] 纯文本查看 复制代码

EAX  CF1028BC
ECX  00401000
EDX  00401000
EBX  002AD000
ESP  0019FF78
EBP  0019FF94
ESI  00401000
EDI  00401000
EFLAGS 00000246

在EntryPoint入口,按几下F7就到保存通用寄存器和标志寄存器的地方了。在早期版本中执行一条pushad和pushfd就完事了,这里用了很多条,还穿插了很多垃圾指令:[Asm] 纯文本查看 复制代码

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顺序,保存了通用寄存器和标志寄存器:[Asm] 纯文本查看 复制代码

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的地址空间:

[Asm] 纯文本查看 复制代码

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的地址:

[Asm] 纯文本查看 复制代码

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保存项的索引:

[Asm] 纯文本查看 复制代码

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:

[Asm] 纯文本查看 复制代码

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]:

[Asm] 纯文本查看 复制代码

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


看到这里,想必聪明的读者已经找到规律了,还记得最前面入口处的指令是在干什么吗?当时是按照以下的顺序保存通用寄存器和标志位寄存器:

[Asm] 纯文本查看 复制代码

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是这样的:

[C] 纯文本查看 复制代码

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:

[Asm] 纯文本查看 复制代码

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


到这里,第一条真正的代码终于执行完了。


接下来的源代码是:

[C] 纯文本查看 复制代码

invoke MessageBox, NULL, offset szMsg, offset szTitle, MB_OK


编译之后就是:

[Asm] 纯文本查看 复制代码

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们就是执行上面的代码:

[Asm] 纯文本查看 复制代码

0048A7AF    8DAD FCFFFFFF   lea     ebp, dword ptr [ebp-4]
0048A7B5    894425 00       mov     dword ptr [ebp], eax             ; eax=0, PUSH 0 -> PUSH MB_OK


0x0304 判断虚拟机栈空间是否够用
由于VMP是基于堆栈的虚拟机架构(Stack-based Virtual Machine),所以真实世界中的每个压栈操作执行后,VMP都会判断栈空间是否足够,一旦不够就要重新分配空间并且把堆栈复制过去,所以上面的PUSH 0、PUSH 00403009等指令执行完后,都会进入类似下面的Handler处理栈空间:


第一步,判断栈空间是否足够:

[Asm] 纯文本查看 复制代码

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次。



如果栈空间不够用:

[Asm] 纯文本查看 复制代码

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空间是跟这里相互对应的)

[Asm] 纯文本查看 复制代码

00432C3E 81EC C0000000 sub esp, 0C0 ; 分配VMContext的空间,大小0xC0个字节,此时esp指向VMContext,虚拟机栈顶仍为ebp


用伪代码总结一下:

[C] 纯文本查看 复制代码

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个标志位寄存器就派上用场了。

关键的代码块形如:

[Asm] 纯文本查看 复制代码

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指令,恢复现场:

[Asm] 纯文本查看 复制代码

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跟一次。


以上那么多文字看完麻烦给个评分呗?

THE END
喜欢就支持以下吧
点赞0
分享
评论 抢沙发
  • 管埋员

    昵称

  • 取消
    昵称