Joduska 发表于 2019-7-23 18:29

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 分析目标
写一份最简单的汇编代码:
; 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:
Profile of testVMP_
Count      Address    First command                     Comment
40.      004042C5   mov   byte ptr , ch
40.      00404BE5   pushfd
40.      00405106   bt      cx, 0A
21.      00404405   lea   eax, dword ptr
21.      00404D39   lea   esp, dword ptr
21.      00405198   pushfd
21.      0040558B   inc   ah
21.      004059E6   call    00404405
16.      004056E7   sbb   dx, di
16.      0040599E   mov   dword ptr , edx
13.      0040432A   push    dword ptr
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
134.       0040601E   shl   dx, cl
134.       004067BB   jmp   00405310
54.      0040450B   mov   word ptr , 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
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
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,而是直接由来定位到VMContext的某一项,注意这里的“索引寄存器”并不确定,有可能是edx,也有可能是别的通用寄存器,谁有空就用谁。
0x03 具体分析
熟悉1.x和2.x的话,看3.x的虚拟机代码不会有太大的问题,只不过混淆的垃圾指令太多,大片大片跳过即可。

0x0301 初始化刚开始的通用寄存器和标志寄存器:EAXCF1028BC
ECX00401000
EDX00401000
EBX002AD000
ESP0019FF78
EBP0019FF94
ESI00401000
EDI00401000
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   000000000
0019FF5C   0019FF94ebp
0019FF60   00401000esi
0019FF64   002AD000ebx
0019FF68   00000246eflags
0019FF6C   00401000ecx
0019FF70   00401000edi
0019FF74   00401000edx
0019FF78   CF1028BCeax
0019FF7C   0046CC69RETURN to testVMP_.0046CC69 from testVMP_.0046B002
0019FF80   E0BC1AA0前面压栈的key
由于混淆的指令太多,下面我会把垃圾指令删掉,只保留关键指令,所以地址会有点不连续。
0x0302 初始化VMContext
分配VMContext的地址空间:
00432C11    8B7C24 28       mov   edi, dword ptr
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           ; esi是第一个Handler的地址,但此时还没计算出正确的地址
00432C65    81EF 04000000   sub   edi, 4                           ; 指向下一条ByteCode的地址,可以看出虚拟机是倒着走的
00432C71    8B17            mov   edx, dword ptr              ; 取得第一条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,这里寄存器edx是作为VMContext保存项的索引:
00422619    8B4425 00       mov   eax, dword ptr              ; ebp指向VMP的栈顶,所以这里相当于POP eax,就是把0出栈到eax
00422624    8DAD 04000000   lea   ebp, dword ptr          ; 栈顶指针+4,结合00422619处的指令其实就是一条标准的POP
0042262D    81EF 01000000   sub   edi, 1                           ; edi指向下一个ByteCode的地址
0042263A    0FB617          movzx   edx, byte ptr
0042264F    E9 9DB50500   jmp   0047DBF1
; 这里还有一大堆对edx的解密计算,省略...
; 最终edx=0x38
0047DBFC    890414          mov   dword ptr , eax         ; edx=0x38, esp=VMContext, VMContext=0
当第一个Handler执行完毕后,通过下面的指令序列计算并跳到下一个Handler:
0047DC26    E9 2D10FEFF   jmp   0045EC58
0045EC58    8D80 410B104C   lea   eax, dword ptr
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:
00472C9B    8B4425 00       mov   eax, dword ptr              ; 这里是把之前压入栈顶的ebp赋值给eax
00472CA2    81C5 04000000   add   ebp, 4                           ; POP eax
00478281    890414          mov   dword ptr , eax         ; edx=0x1C, esp=VMContext, VMContext=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 , eax 这样的就是在写VMContext数组,记下eax表示写入的内容,edx表示写到VMContext的第几项就行了。

0x0303 真正开始执行代码

VMContext初始化完成后,下面的Handler就是执行源程序中的每条指令了,略过垃圾指令后,我们会看到这样的Handler:
0048B642    8B07            mov   eax, dword ptr              ; 取源程序中的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          ; 栈顶-4
0044093C    894425 00       mov   dword ptr , eax             ; PUSH 2019H



此时ebp=0019FF80,指向的虚拟机栈顶是2019H,是不是很熟悉?



翻到本文的前面部分看看源代码,第一条是不是就是PUSH 2019H?说明这个Handler就是执行源程序中的PUSH xxxxxxxx

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

接下来的源代码是:
invoke MessageBox, NULL, offset szMsg, offset szTitle, MB_OK
编译之后就是:
004010056A 00         push    0                              ; /Style = MB_OK|MB_APPLMODAL
0040100768 09304000   push    00403009                         ; |Title = "我是标题"
0040100C68 00304000   push    00403000                         ; |Text = "我是内容"
004010116A 00         push    0                              ; |hOwner = NULL
00401013E8 0E000000   call    <jmp.&user32.MessageBoxA>      ; \MessageBoxA
接下来的Handler们就是执行上面的代码:
0048A7AF    8DAD FCFFFFFF   lea   ebp, dword ptr
0048A7B5    894425 00       mov   dword ptr , eax             ; eax=0, PUSH 0 -> PUSH MB_OK
0x0304 判断虚拟机栈空间是否够用
由于VMP是基于堆栈的虚拟机架构(Stack-based Virtual Machine),所以真实世界中的每个压栈操作执行后,VMP都会判断栈空间是否足够,一旦不够就要重新分配空间并且把堆栈复制过去,所以上面的PUSH 0、PUSH 00403009等指令执行完后,都会进入类似下面的Handler处理栈空间:


第一步,判断栈空间是否足够:
0040C55B 8D5424 60 lea edx, dword ptr 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           ; 栈顶向下走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:, byte ptr    ; 复制堆栈内容到新的空间
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;
      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                    ; 从VMContext中取出寄存器的值
004665DC    8DAD FCFFFFFF   lea   ebp, dword ptr                      ; 相当于PUSH register
004665E5    895425 00       mov   dword ptr , 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跟一次。


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

solly 发表于 2019-7-24 10:22

dispatcher感觉象是从 MSVBVM50 升到 MSVBVM60

陨落星辰 发表于 2019-7-23 19:54

转载请注明转载 盗文章有意思?

Joduska 发表于 2019-7-23 23:03

陨落星辰 发表于 2019-7-23 19:54
转载请注明转载 盗文章有意思?

{:1_905:} 是呢我盗文章,快举报我,你这个孩子带点脑子说话可以吗。

Joduska 发表于 2019-7-23 18:30

文件下载地址哦, 有兴趣的同学自己分析!

xianian233 发表于 2019-7-23 18:50

emm楼主图片全是防盗链修一下

Feixiangze 发表于 2019-7-23 20:12

科技含量非常高,学习下

陨落星辰 发表于 2019-7-23 20:13

本帖最后由 陨落星辰 于 2019-7-23 20:17 编辑

感觉 在看雪看过的

ibiza 发表于 2019-7-23 20:43

谢谢分享

少林大虾 发表于 2019-7-24 01:00

没有最好的只有更合适的,为无私者赞

lau121 发表于 2019-7-24 08:41

这个是干啥用的??保护虚拟机?还是检测虚拟机的 防止软件在虚拟机里头运行的。 话说软件不让在虚拟机里头运行太扯。。。
页: [1] 2 3
查看完整版本: VMProtect 3.3.1虚拟机&代码混淆机制入门