[翻译][译文]VMProtect2-虚拟机架构的详细分析
2022-12-6 23:57:0 Author: bbs.pediy.com(查看原文) 阅读量:29 收藏

https://back.engineering/17/05/2021/

If there is any infringement, please send an email to [email protected] and I will promptly remove the article.

如果有任何侵权行为,请发送邮件到[email protected],我会及时删除该文章。

翻译有问题,请大佬们指正,以下为原文翻译内容:

Download link: VMProtect 2 Reverse Engineering

在深入探讨这篇文章之前,我想就现有的VMProtect 2工作、这篇文章的目的以及我的意图陈述几件事,因为这些似乎有时会被误解和扭曲。

目的

虽然已经有很多关于VMProtect 2的研究,但我觉得仍有一些信息没有被公开讨论,也没有足够的源代码披露给公众。我在这篇文章中所披露的信息旨在超越一般的架构分析,但要低得多。在这个层面上,人们可以在给定的VMProtect二进制文件中编码自己的虚拟机指令,并可以轻松地拦截和改变虚拟指令的结果。本文讨论的动态分析是基于Samuel Chevet的现有工作,我的动态分析研究和Vmtrace项目只是在他的演讲"Inside VMProtect"中展示的工作基础上的扩展。

意图

这篇文章无意对VMProtect 2、该软件的创造者或使用该软件的人提出任何负面意见。我钦佩创造者,他们显然有令人印象深刻的技能来创造这样一个产品。

这篇文章也是在这样的印象下创建的,即这里讨论的一切很可能是由私人实体发现的,我不是第一个发现或记录有关VMProtect 2架构的事情。我并不打算把这些信息当作是突破性的或其他人已经发现的东西,恰恰相反。这只是一个现有信息的集合,加上我自己的研究。

既然如此,我谦虚地向你介绍,"VMProtect 2,虚拟机架构的详细分析"。

VIP - 虚拟指令指针,这相当于 x86-64 的 RIP 寄存器,它包含要执行的下一条指令的地址。VMProtect 2 使用本地寄存器 RSI 来保存下一条虚拟指令指针的地址。因此,RSI等同于VIP。

VSP - 虚拟堆栈指针,这相当于x86-64的RSP寄存器,包含堆栈的地址。VMProtect 2使用本地寄存器RBP来保存虚拟堆栈指针的地址。因此,RBP等同于VSP。

VM Handler - 一个包含执行虚拟指令的本地代码的例程。例如,VADD64指令将堆栈上的两个值加在一起,并将结果以及RFLAGS存储在堆栈上。

Virtual Instruction - 也被称为 "虚拟字节码 "是由虚拟机解释并随后执行的字节。每个虚拟指令至少由一个或多个操作数组成。第一个操作数包含该指令的操作码。

Virtual Opcode - 每个虚拟指令的第一个操作数。这是vm处理程序的索引。VMProtect 2操作码的大小总是一个字节。

IMM / Immediate Value - 编码到虚拟指令中的一个值,通过这个值进行操作,例如将所述值加载到堆栈或虚拟寄存器中。虚拟指令如LREG、SREG和LCONST都有即时值。

Transformations - 本篇文章中使用的术语 "转换 "特指为解密虚拟指令的操作数和vm处理程序表项而进行的操作。这些转换包括add, sub, inc, dec, not, neg, shl, shr, ror, rol, 以及最后的BSWAP。变换的大小为1、2、4和8字节。变换也可以有与之相关的即时/恒定值,如 "xor rax, 0x123456",或 "add rax, 0x123456"。

VMProtect 2是一个基于虚拟机的x86混淆器,它将x86指令转换为RISC、堆栈机的指令集。每个受保护的二进制文件都有一套独特的加密虚拟机指令,并有独特的混淆功能。该项目旨在披露每一个VMProtect 2二进制文件中非常重要的签名,以帮助进一步研究。本文还将简要地讨论不同类型的VMProtect 2混淆。所有去混淆的技术都是专门针对虚拟机例程的,对一般被混淆的例程不起作用,特别是其中有真正的JCC的例程。

VMProtect 2 在大多数情况下使用两种混淆类型,第一种是Deadstore,第二种是不透明的分支。在整个混淆的程序中,你可以看到几条指令后有一个JCC,然后是另一组指令后有另一个JCC。不透明分支的另一个贡献是影响FLAGS寄存器的随机指令。你到处都可以看到这些小家伙。它们大多是位测试指令,无用的比较,以及设置/清除标志的指令。

不透明分支混淆实例

在这个不透明分支混淆实例中,我将介绍 VMProtect 2 不透明分支的外观,其他因素如 rflags 的状态,以及最重要的是,如何确定你看到的是不透明分支还是合法的 JCC。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

.vmp0:00000001400073B4 D0 C8                  ror     al, 1

.vmp0:00000001400073B6 0F CA                  bswap   edx

.vmp0:00000001400073B8 66 0F CA               bswap   dx

.vmp0:00000001400073BB 66 0F BE D2            movsx   dx, dl

.vmp0:00000001400073BF 48 FF C6               inc     rsi

.vmp0:00000001400073C2 48 0F BA FA 0F         btc     rdx, 0Fh

.vmp0:00000001400073C7 F6 D8                  neg     al

.vmp0:00000001400073C9 0F 81 6F D0 FF FF      jno     loc_14000443E

.vmp0:00000001400073CF 66 C1 FA 04            sar     dx, 4

.vmp0:00000001400073D3 81 EA EC 94 CD 47      sub     edx, 47CD94ECh

.vmp0:00000001400073D9 28 C3                  sub     bl, al

.vmp0:00000001400073DB D2 F6                  sal     dh, cl

.vmp0:00000001400073DD 66 0F BA F2 0E         btr     dx, 0Eh

.vmp0:00000001400073E2 8B 14 38               mov     edx, [rax+rdi]

考虑一下上述混淆的代码。注意JNO分支。如果你在ida中跟踪这个分支,并将指令与JNO之后的指令进行比较,你可以看到这个分支是无用的,因为两个路径执行的是同样的有意义的指令。

1

2

3

4

5

6

7

8

9

10

11

loc_14000443E:

.vmp0:000000014000443E F5                     cmc

.vmp0:000000014000443F 0F B3 CA               btr     edx, ecx

.vmp0:0000000140004442 0F BE D3               movsx   edx, bl

.vmp0:0000000140004445 66 21 F2               and     dx, si

.vmp0:0000000140004448 28 C3                  sub     bl, al

.vmp0:000000014000444A 48 81 FA 38 04 AA 4E   cmp     rdx, 4EAA0438h

.vmp0:0000000140004451 48 8D 90 90 50 F5 BB   lea     rdx, [rax-440AAF70h]

.vmp0:0000000140004458 D2 F2                  sal     dl, cl

.vmp0:000000014000445A D2 C2                  rol     dl, cl

.vmp0:000000014000445C 8B 14 38               mov     edx, [rax+rdi]

如果你看得足够仔细,你可以看到有几条指令在两个分支中都有。要确定哪些代码是Deadstore,哪些代码是需要的,可能很困难,但是如果你在ida中选择一个寄存器,并查看它在你所看的指令之前被写入的所有地方,你可以删除所有其他的写入指令,直到有一个对该寄存器的读取。现在,回到这个例子,在这种情况下,以下指令才是重要的。

1

2

.vmp0:0000000140004448 28 C3                  sub     bl, al

.vmp0:000000014000445C 8B 14 38               mov     edx, [rax+rdi]

这些不透明分支的产生使得有重复的指令出现。对于每个代码路径,还有更多的死库混淆以及不透明的条件和其他影响RFLAGS的指令。

Deadstore 混淆示例

除了不透明的位测试和比较,VMProtect 2 deadstore混淆在指令流中增加了最多的垃圾。这些指令没有任何作用,可以很容易地被发现并被手工删除。请考虑以下情况:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

.vmp0:0000000140004149 66 D3 D7               rcl     di, cl

.vmp0:000000014000414C 58                     pop     rax

.vmp0:000000014000414D 66 41 0F A4 DB 01      shld    r11w, bx, 1

.vmp0:0000000140004153 41 5B                  pop     r11

.vmp0:0000000140004155 80 E6 CA               and     dh, 0CAh

.vmp0:0000000140004158 66 F7 D7               not     di

.vmp0:000000014000415B 5F                     pop     rdi

.vmp0:000000014000415C 66 41 C1 C1 0C         rol     r9w, 0Ch

.vmp0:0000000140004161 F9                     stc

.vmp0:0000000140004162 41 58                  pop     r8

.vmp0:0000000140004164 F5                     cmc

.vmp0:0000000140004165 F8                     clc

.vmp0:0000000140004166 66 41 C1 E1 0B         shl     r9w, 0Bh

.vmp0:000000014000416B 5A                     pop     rdx

.vmp0:000000014000416C 66 81 F9 EB D2         cmp     cx, 0D2EBh

.vmp0:0000000140004171 48 0F A3 F1            bt      rcx, rsi

.vmp0:0000000140004175 41 59                  pop     r9

.vmp0:0000000140004177 66 41 21 E2            and     r10w, sp

.vmp0:000000014000417B 41 C1 D2 10            rcl     r10d, 10h

.vmp0:000000014000417F 41 5A                  pop     r10

.vmp0:0000000140004181 66 0F BA F9 0C         btc     cx, 0Ch

.vmp0:0000000140004186 49 0F CC               bswap   r12

.vmp0:0000000140004189 48 3D 97 74 7D C7      cmp     rax, 0FFFFFFFFC77D7497h

.vmp0:000000014000418F 41 5C                  pop     r12

.vmp0:0000000140004191 66 D3 C1               rol     cx, cl

.vmp0:0000000140004194 F5                     cmc

.vmp0:0000000140004195 66 0F BA F5 01         btr     bp, 1

.vmp0:000000014000419A 66 41 D3 FE            sar     r14w, cl

.vmp0:000000014000419E 5D                     pop     rbp

.vmp0:000000014000419F 66 41 29 F6            sub     r14w, si

.vmp0:00000001400041A3 66 09 F6               or      si, si

.vmp0:00000001400041A6 01 C6                  add     esi, eax

.vmp0:00000001400041A8 66 0F C1 CE            xadd    si, cx

.vmp0:00000001400041AC 9D                     popfq

.vmp0:00000001400041AD 0F 9F C1               setnle  cl

.vmp0:00000001400041B0 0F 9E C1               setle   cl

.vmp0:00000001400041B3 4C 0F BE F0            movsx   r14, al

.vmp0:00000001400041B7 59                     pop     rcx

.vmp0:00000001400041B8 F7 D1                  not     ecx

.vmp0:00000001400041BA 59                     pop     rcx

.vmp0:00000001400041BB 4C 8D A8 ED 19 28 C9   lea     r13, [rax-36D7E613h]

.vmp0:00000001400041C2 66 F7 D6               not     si

.vmp0:00000001400041CB 41 5E                  pop     r14

.vmp0:00000001400041CD 66 F7 D6               not     si

.vmp0:00000001400041D0 66 44 0F BE EA         movsx   r13w, dl

.vmp0:00000001400041D5 41 BD B2 6B 48 B7      mov     r13d, 0B7486BB2h

.vmp0:00000001400041DB 5E                     pop     rsi

.vmp0:00000001400041DC 66 41 BD CA 44         mov     r13w, 44CAh

.vmp0:0000000140007AEA 4C 8D AB 31 11 63 14   lea     r13, [rbx+14631131h]

.vmp0:0000000140007AF1 41 0F CD               bswap   r13d

.vmp0:0000000140007AF4 41 5D                  pop     r13

.vmp0:0000000140007AF6 C3                     retn

让我们从头开始,一条一条的指令。在0x140004149的第一条指令是 "RCL - Rotate Left Carry"。这条指令影响到FLAGS寄存器和DI。让我们看看下一次DI被引用的时候。它是读还是写?下一次对DI的引用是位于0x140004158的NOT指令。NOT读和写DI,到目前为止两条指令都是有效的。下一个引用DI的指令是POP指令。这一点很关键,因为在这条POP指令之前对RDI的所有写都可以从指令流中删除。

1

2

3

4

5

.vmp0:000000014000414C 58                     pop     rax

.vmp0:000000014000414D 66 41 0F A4 DB 01      shld    r11w, bx, 1

.vmp0:0000000140004153 41 5B                  pop     r11

.vmp0:0000000140004155 80 E6 CA               and     dh, 0CAh

.vmp0:000000014000415B 5F                     pop     rdi

下一条指令是POP RAX,位于0x14000414C。RAX在整个指令流中从未被写入,只是被读出。因为它有一个读的依赖性,所以这条指令不能被删除。进入下一条指令,SHLD - 双精度左移(double precision shift left),对R11有写依赖,对BX有读依赖。下一条引用R11的指令是0x140004153的POP R11。我们可以删除SHLD指令,因为它是Deadstore。

1

2

3

4

.vmp0:000000014000414C 58                     pop     rax

.vmp0:0000000140004153 41 5B                  pop     r11

.vmp0:0000000140004155 80 E6 CA               and     dh, 0CAh

.vmp0:000000014000415B 5F                     pop     rdi

现在只要对每一条指令重复这个过程。最终的结果应该是这样的:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

.vmp0:000000014000414C 58                                            pop     rax

.vmp0:0000000140004153 41 5B                                         pop     r11

.vmp0:000000014000415B 5F                                            pop     rdi

.vmp0:0000000140004162 41 58                                         pop     r8

.vmp0:000000014000416B 5A                                            pop     rdx

.vmp0:0000000140004175 41 59                                         pop     r9

.vmp0:000000014000417F 41 5A                                         pop     r10

.vmp0:000000014000418F 41 5C                                         pop     r12

.vmp0:000000014000419E 5D                                            pop     rbp

.vmp0:00000001400041AC 9D                                            popfq

.vmp0:00000001400041B7 59                                            pop     rcx

.vmp0:00000001400041B7 59                                            pop     rcx

.vmp0:00000001400041CB 41 5E                                         pop     r14

.vmp0:00000001400041DB 5E                                            pop     rsi

.vmp0:0000000140007AF4 41 5D                                         pop     r13

.vmp0:0000000140007AF6 C3                                            retn

这种方法对于消除Deadstore混淆并不完美,因为有第二个POP RCX在上面这个结果中缺失。POP和PUSH指令是特殊情况,不应该从指令流中发出,因为这些指令也会改变RSP。这种去除死库的方法也只适用于vm_entry和vm handler。这不能应用于一般的混淆程序。同样,这个方法不会对任何被混淆的例程起作用,它是专门为vm_entry和vm handlers定制的,因为这些例程中没有合法的JCC。

虚拟指令由被称为 "vm handlers"的虚拟指令处理程序进行解密和解释。虚拟机是一个基于RISC的堆栈机,带有scratch寄存器。在vm-entries之前,虚拟指令的加密RVA(相对虚拟地址,relative virtual address)被推入堆栈,所有的通用寄存器以及标志被推入堆栈。VIP被解密,计算,并加载到RSI。然后在RBX中启动一个滚动解密密钥,用来解密每一条虚拟指令的每一个操作数。滚动解密密钥通过与解密的操作数值进行转换来更新。

滚动式解密

VMProtect 2使用一个滚动解密密钥。该密钥用于解密虚拟指令操作数,随后防止任何形式的hook,因为如果任何虚拟指令被不按顺序执行,滚动解密密钥将变得无效,导致虚拟操作数的进一步解密也无效。

本地寄存器的使用

在虚拟机内部的执行过程中,一些本地寄存器是专门用于虚拟机机制的,比如虚拟指令指针和虚拟栈。在本节中,我将讨论这些本地寄存器和它们在虚拟机中的用途。

非易失性寄存器--有特定用途的寄存器

首先,RSI总是被用来作为虚拟指令指针。操作数从存储在RSI中的地址获取。装入RSI的初始值由vm_entry完成。

RBP用于虚拟堆栈指针,存储在RBP中的地址实际上是本地堆栈内存。在分配从头开始的寄存器之前,RBP与RSP一起被加载。这给我们带来了包含scratch寄存器的RDI。RDI中的地址在vm_entry中也被初始化,并被设置为本地堆栈内部的一个地址。

R12被加载了vm处理表的线性虚拟地址。这是在vm_entry中完成的,在虚拟机内部执行的整个过程中,R12将包含这个地址。

R13在vm_entry中被加载了模块基址的线性虚拟地址,并且在虚拟机内部的整个执行过程中不会被改变。

RBX是一个非常特殊的寄存器,它包含滚动解密密钥。在每条虚拟指令的每个操作数被解密后,RBX通过对其进行转换来更新其解密的操作数的值。

易失性寄存器 - 临时寄存器

RAX、RCX和RDX被用作虚拟机内部的临时寄存器,但是RAX被用来对其他寄存器进行非常特殊的临时操作。RAX用于解密虚拟指令的操作数,AL具体用于解密虚拟指令的操作码。

vm_entry - 虚拟机的入口

vm_entry是虚拟机架构中一个非常重要的组成部分。在进入虚拟机之前,一个加密的RVA到虚拟指令被推到堆栈中。这个RVA是一个四字节的值。

1

.vmp0:000000014000822C 68 FA 01 00 89         push    0FFFFFFFF890001FAh

在这个值被推入堆栈后,一个jmp指令被执行以开始执行vm_entry。我在上面详细解释过vm_entry会被扁平化混淆处理。然后删除deadstor代码,我们可以得到一个干净的vm_entry的视图。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

> 0x822c :                                    push 0xFFFFFFFF890001FA

> 0x7fc9 :                                    push 0x45D3BF1F

> 0x48e4 :                                    push r13

> 0x4690 :                                    push rsi

> 0x4e53 :                                    push r14

> 0x74fb :                                    push rcx

> 0x607c :                                    push rsp

> 0x4926 :                                    pushfq

> 0x4dc2 :                                    push rbp

> 0x5c8c :                                    push r12

> 0x52ac :                                    push r10

> 0x51a5 :                                    push r9

> 0x5189 :                                    push rdx

> 0x7d5f :                                    push r8

> 0x4505 :                                    push rdi

> 0x4745 :                                    push r11

> 0x478b :                                    push rax

> 0x7a53 :                                    push rbx

> 0x500d :                                    push r15

> 0x6030 :                                    push [0x00000000000018E2]

> 0x593a :                                    mov rax, 0x7FF634270000

> 0x5955 :                                    mov r13, rax

> 0x5965 :                                    push rax

> 0x596f :                                    mov esi, [rsp+0xA0]

> 0x5979 :                                    not esi

> 0x5985 :                                    neg esi

> 0x598d :                                    ror esi, 0x1A

> 0x599e :                                    mov rbp, rsp

> 0x59a8 :                                    sub rsp, 0x140

> 0x59b5 :                                    and rsp, 0xFFFFFFFFFFFFFFF0

> 0x59c1 :                                    mov rdi, rsp

> 0x59cb :                                    lea r12, [0x0000000000000AA8]

> 0x59df :                                    mov rax, 0x100000000

> 0x59ec :                                    add rsi, rax

> 0x59f3 :                                    mov rbx, rsi

> 0x59fa :                                    add rsi, [rbp]

> 0x5a05 :                                    mov al, [rsi]

> 0x5a0a :                                    xor al, bl

> 0x5a11 :                                    neg al

> 0x5a19 :                                    rol al, 0x05

> 0x5a26 :                                    inc al

> 0x5a2f :                                    xor bl, al

> 0x5a34 :                                    movzx rax, al

> 0x5a41 :                                    mov rdx, [r12+rax*8]

> 0x5a49 :                                    xor rdx, 0x7F3D2149

> 0x5507 :                                    inc rsi

> 0x7951 :                                    add rdx, r13

> 0x7954 :                                    jmp rdx

正如预期的那样,所有的寄存器和RFLAGS都被推到了堆栈中。最后一次推送将8个字节的0放在堆栈上,而不是我最初预期的重新定位。这些推送发生的顺序在每个版本中都是独一无二的,但是最后推送的8个0在所有二进制文件中总是相同的。这是一个非常稳定的签名,可以确定一般寄存器推送的结束时间。下面是我在本段中提到的指令的确切序列。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

> 0x48e4 :                                    push r13

> 0x4690 :                                    push rsi

> 0x4e53 :                                    push r14

> 0x74fb :                                    push rcx

> 0x607c :                                    push rsp

> 0x4926 :                                    pushfq

> 0x4dc2 :                                    push rbp

> 0x5c8c :                                    push r12

> 0x52ac :                                    push r10

> 0x51a5 :                                    push r9

> 0x5189 :                                    push rdx

> 0x7d5f :                                    push r8

> 0x4505 :                                    push rdi

> 0x4745 :                                    push r11

> 0x478b :                                    push rax

> 0x7a53 :                                    push rbx

> 0x500d :                                    push r15

> 0x6030 :                                    push [0x00000000000018E2] ; pushes 0’s

在所有的寄存器和RFLAGS被推入堆栈后,模块的基本地址被加载到R13。这发生在每一个二进制文件中,在虚拟机的执行过程中,R13总是包含模块的基地址。模块的基地址也被推入堆栈。

1

2

3

> 0x593a :                                    mov rax, 0x7FF634270000

> 0x5955 :                                    mov r13, rax

> 0x5965 :                                    push rax

接下来,要执行的虚拟指令的相对虚拟地址被解密。这是通过从RSP+0xA0加载32位RVA到ESI来完成的。这是一个非常重要的签名,可以很容易找到。然后对ESI进行三次转换以得到虚拟指令的解密RVA。这三个转换在每个二进制中是唯一的。然而,总是有三个转换。

1

2

3

4

> 0x596f :                                    mov esi, [rsp+0xA0]

> 0x5979 :                                    not esi

> 0x5985 :                                    neg esi

> 0x598d :                                    ror esi, 0x1A

此外,接下来发生的值得注意的操作是在堆栈上为从头寄存器分配空间。RSP总是被移到RBP,然后RSP被减去0x140。然后按16个字节对齐。做完这些后,地址被移入RDI。在虚拟机的执行过程中,RDI总是包含一个指向scratch寄存器的指针。

1

2

3

4

> 0x599e :                                    mov rbp, rsp

> 0x59a8 :                                    sub rsp, 0x140

> 0x59b5 :                                    and rsp, 0xFFFFFFFFFFFFFFF0

> 0x59c1 :                                    mov rdi, rsp

下一个值得注意的操作是将vm处理程序表的地址加载到R12中。这是在每个单一的VMProtect 2二进制文件上进行的。R12总是包含vm处理表的线性虚拟地址。这是另一个重要的签名,可用于找到vm处理程序表的位置,非常简单。

1

> 0x59cb :                                    lea r12, [0x0000000000000AA8]

然后对RSI进行另一个操作来计算VIP。在PE头中,有一个叫做 "可选头 "的头。它包含各种各样的信息。其中一个字段被称为 "ImageBase"。如果在这个字段中有任何高于32的位,这些位就会被加到RSI中。例如,vmptest.vmp.exe ImageBase字段包含值0x140000000。因此,作为计算的一部分,0x100000000被添加到RSI中。如果一个ImageBase字段包含少于32位的值,那么RSI将被添加到0。

1

2

> 0x59df :                                    mov rax, 0x100000000

> 0x59ec :                                    add rsi, rax

在对RSI做了这个加法之后,一个小的、有点不重要的指令被执行。这条指令将虚拟指令的线性虚拟地址加载到RBX中。现在,RBX有一个非常特殊的用途,它包含了 "滚动解密 "的密钥。正如你所看到的,加载到RBX的第一个值将是虚拟指令本身的地址! 不是线性虚拟地址,而只是包括ImageBase字段前32位的RVA。

1

> 0x59f3 :                                    mov rbx, rsi

接下来,vmp模块的基地址被添加到RSI中,计算出虚拟指令的完整线性虚拟地址。记住,RBP包含了在分配scratch空间之前的RSP的地址。这时模块的基地址在堆栈的顶部。

1

> 0x59fa :                                    add rsi, [rbp]

vm_entry的细节到此为止,这个例程的下一部分实际上被称为 "calc_vm_handler",在除了vm_exit指令之外的每一条虚拟指令之后执行。

calc_jmp - 解密Vm Handler的索引

calc_jmp是vm_entry例程的一部分,然而它不仅仅被vm_entry例程所提及。每个vm处理程序最终都会跳转到calc_jmp(除了vm_exit)。这段代码负责解密每条虚拟指令的操作码,以及索引到vm处理程序表,解密vm处理程序表条目并跳转到结果的vm处理程序。

1

2

3

4

5

6

7

8

9

10

11

12

> 0x5a05 :                                    mov al, [rsi]

> 0x5a0a :                                    xor al, bl

> 0x5a11 :                                    neg al

> 0x5a19 :                                    rol al, 0x05

> 0x5a26 :                                    inc al

> 0x5a2f :                                    xor bl, al

> 0x5a34 :                                    movzx rax, al

> 0x5a41 :                                    mov rdx, [r12+rax*8]

> 0x5a49 :                                    xor rdx, 0x7F3D2149

> 0x5507 :                                    inc rsi

> 0x7951 :                                    add rdx, r13

> 0x7954 :                                    jmp rdx

这段代码的第一条指令从RSI中读出一个字节,如你所知,这是VIP。这个字节是一个加密的操作码。换句话说,它是一个进入vm处理程序表的加密索引。总共有5个转换被完成。第一个转换总是应用于加密的操作码和RBX中的值作为源。这就是 "滚动加密 "的作用。值得注意的是,加载到RBX的第一个值是虚拟指令的RVA。因此BL将包含这个RVA的最后一个字节。

1

2

> 0x5a05 :                                    mov al, [rsi]

> 0x5a2f :                                    xor bl, al ; transformation is unique to each build

接下来,三个转换被直接应用于AL。这些转换可以有即时的值,但是从来没有其他寄存器的值加入这些转换中。

1

2

3

> 0x5a11 :                                    neg al

> 0x5a19 :                                    rol al, 0x05

> 0x5a26 :                                    inc al

最后一个转换是应用于存储在RBX中的滚动加密密钥。这个变换和第一个变换是一样的。然而,寄存器的位置互换。最终的结果是解密的vm处理程序索引。然后AL的值被零扩展到RAX的其余部分。

1

2

> 0x5a2f :                                    xor bl, al

> 0x5a34 :                                    movzx rax, al

现在,进入vm handler表的索引已经被解密,vm handler条目本身必须被获取和解密。对这些vm处理程序表条目只进行了一次转换。在这些转换中没有使用任何寄存器的值。加密后的vm表项值被加载到的寄存器总是RCX或RDX。

1

2

> 0x5a41 :                                    mov rdx, [r12+rax*8]

> 0x5a49 :                                    xor rdx, 0x7F3D2149

现在,VIP被推进。VIP可以向前或向后推进,推进操作本身可以是LEA、INC、DEC、ADD或SUB指令。

1

> 0x5507 :                                    inc rsi

最后,模块的基地址被添加到解密后的vm处理程序RVA中,然后执行JMP,开始执行这个vm处理程序。同样,RDX或RCX总是被用于这个ADD和JMP。这是虚拟机中另一个重要的签名。

1

2

> 0x7951 :                                    add rdx, r13

> 0x7954 :                                    jmp rdx

至此,calc_jmp的代码片段规范结束。正如你所看到的,有一些非常重要的签名,使用Zydis就可以找到。特别是对vm handler表项进行的解密,以及对这些加密值的获取。

vm_exit - 离开虚拟机

与vm_entry不同,vm_exit是一个非常简单的程序。这个例程简单地将所有寄存器POP回原位,包括RFLAGS。有一些多余的POP是用来清除堆栈中的模块基数、padding以及RSP的,因为它们不需要了。pops发生的顺序与它们被vm_entry推入堆栈的顺序相反。在vm_exit例程之前,返回地址被计算并加载到堆栈。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

.vmp0:000000014000635F 48 89 EC               mov     rsp, rbp

.vmp0:0000000140006371 58                     pop     rax ; pop module base of the stack

.vmp0:000000014000637F 5B                     pop     rbx ; pop zero’s off the stack

.vmp0:0000000140006387 41 5F                  pop     r15

.vmp0:0000000140006393 5B                     pop     rbx

.vmp0:000000014000414C 58                     pop     rax

.vmp0:0000000140004153 41 5B                  pop     r11

.vmp0:000000014000415B 5F                     pop     rdi

.vmp0:0000000140004162 41 58                  pop     r8

.vmp0:000000014000416B 5A                     pop     rdx

.vmp0:0000000140004175 41 59                  pop     r9

.vmp0:000000014000417F 41 5A                  pop     r10

.vmp0:000000014000418F 41 5C                  pop     r12

.vmp0:000000014000419E 5D                     pop     rbp

.vmp0:00000001400041AC 9D                     popfq

.vmp0:00000001400041B7 59                     pop     rcx ; pop RSP off the stack.

.vmp0:00000001400041BA 59                     pop     rcx

.vmp0:00000001400041CB 41 5E                  pop     r14

.vmp0:00000001400041DB 5E                     pop     rsi

.vmp0:0000000140007AF4 41 5D                  pop     r13

.vmp0:0000000140007AF6 C3                     retn

check_vsp - 重定位scratch寄存器

将任何新值放在堆栈上的Vm handlers将在vm handlers执行后有一个堆栈检查。这个例程检查堆栈是否侵占了scratch寄存器。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

.vmp0:00000001400044AA 48 8D 87 E0 00 00 00       lea     rax, [rdi+0E0h]

.vmp0:00000001400044B2 48 39 C5                   cmp     rbp, rax

.vmp0:000000014000429D 0F 87 5B 17 00 00          ja      calc_jmp

.vmp0:00000001400042AC 48 89 E2                   mov     rdx, rsp

.vmp0:0000000140005E5F 48 8D 8F C0 00 00 00       lea     rcx, [rdi+0C0h]

.vmp0:0000000140005E75 48 29 D1                   sub     rcx, rdx

.vmp0:000000014000464C 48 8D 45 80                lea     rax, [rbp-80h]

.vmp0:0000000140004655 24 F0                      and     al, 0F0h

.vmp0:000000014000465F 48 29 C8                   sub     rax, rcx

.vmp0:000000014000466B 48 89 C4                   mov     rsp, rax

.vmp0:0000000140004672 9C                         pushfq

.vmp0:000000014000467C 56                         push    rsi

.vmp0:0000000140004685 48 89 D6                   mov     rsi, rdx

.vmp0:00000001400057D6 48 8D BC 01 40 FF FF FF    lea     rdi, [rcx+rax-0C0h]

.vmp0:00000001400051FC 57                         push    rdi

.vmp0:000000014000520C 48 89 C7                   mov     rdi, rax

.vmp0:0000000140004A34 F3 A4                      rep movsb

.vmp0:0000000140004A3E 5F                         pop     rdi

.vmp0:0000000140004A42 5E                         pop     rsi

.vmp0:0000000140004A48 9D                         popfq

.vmp0:0000000140004A49 E9 B0 0F 00 00             jmp     calc_jmp

注意 "movsb "的用法,它被用来复制从头开始寄存器的内容。

虚拟指令 - 操作码、操作数、规格

虚拟指令由两个或多个操作数组成。第一个操作数是虚拟指令的操作码。操作码是8位的无符号值,当被解密时,它是进入vm处理表的索引。可以有第二个操作数,这是一个1到8字节的即时值。

所有操作数都是加密的,必须用滚动的解密密钥来解密。解密是在calc_jmp以及vm处理程序本身内部进行的。进行解密的Vm处理程序将只对即时值而不是操作码进行操作。

操作符解密 - Transformations

VMProtect 2使用滚动解密密钥对其虚拟指令进行加密。该密钥位于RBX中,最初被设置为虚拟指令的地址。为解密操作数所做的转换包括XOR、NEG、NOT、AND、ROR、ROL、SHL、SHR、ADD、SUB、INC、DEC和BSWAP。当一个操作数被解密时,应用于该操作数的第一个转换包括滚动解密密钥。因此,只有XOR, AND, ROR, ROL, ADD, 和SUB是应用于操作数的第一个转换。然后,总是有三个转换直接应用于操作数。在这个阶段,操作数被完全解密,RAX中的值将保持解密的操作数值。最后,通过将滚动解密密钥与完全解密的操作数值进行转换,更新滚动解密密钥。一个例子是这样的。

1

2

3

4

5

.vmp0:0000000140005A0A 30 D8                  xor     al, bl ; decrypt using rolling key...

.vmp0:0000000140005A11 F6 D8                  neg     al ; 1/3 transformations...

.vmp0:0000000140005A19 C0 C0 05               rol     al, 5 ; 2/3 transformations...

.vmp0:0000000140005A26 FE C0                  inc     al 3/3 transformations...

.vmp0:0000000140005A2F 30 C3                  xor     bl, al ; update rolling key...

上面这段代码对第一个操作数进行解密,这个操作数总是指令操作码。这段代码是calc_jmp例程的一部分,但是对于任何第二个操作数,转换格式都是一样的。

VM Handlers - 规格

VM handlers包含执行虚拟指令的本地代码。每个 VMProtect 2 二进制文件都有一个 vm handler表,这是一个 256 个 QWORD 的数组。每个条目都包含一个加密的相对虚拟地址,指向相应的vm handler。有许多虚拟指令的变体,如不同大小的即时值以及符号和零扩展值。本节将介绍一些虚拟指令的例子,以及在尝试解析vm handler时必须注意的一些关键信息。

处理即时值的vm handler程序从RSI中获取加密的即时值。然后对这个加密的即时值进行传统的五次转换。转换格式与calc_jmp转换相同。第一个转换应用于加密的即时值,滚动解密密钥是操作的来源。然后,三个转换直接应用于加密的即时值,这将完全解密该值。最后,除了目标和源操作数互换外,滚动解密密钥通过第一次转换进行更新。

1

2

3

4

5

6

.vmp0:00000001400076D2 48 8B 06               mov     rax, [rsi] ; fetch immediate value...

.vmp0:00000001400076D9 48 31 D8               xor     rax, rbx ; rolling key transformation...

.vmp0:00000001400076DE 48 C1 C0 1D            rol     rax, 1Dh ; 1/3 transformations...

.vmp0:0000000140007700 48 0F C8               bswap   rax ; 2/3 transformations...

.vmp0:000000014000770F 48 C1 C0 30            rol     rax, 30h ; 3/3 transformations...

.vmp0:0000000140007714 48 31 C3               xor     rbx, rax ; update rolling key...

还要注意的是,vm handlers会受到不透明的分支以及死库混淆的影响。

LCONST - 在堆栈中加载常量值

最具代表性的虚拟机指令之一是LCONST。这条虚拟指令将一个常量值从虚拟指令的第二个操作数加载到堆栈中。

LCONSTQ - 加载常数QWORD

这是LCONSTQ VM handler的反混淆视图。你可以看到这个VM handler从VIP(RSI)中读取虚拟指令的第二个操作数。然后它解密了这个即时值并推进了VIP。解密后的立即值被放到VSP上。

1

2

3

4

5

6

7

8

9

mov     rax, [rsi]

xor     rax, rbx ; transformation

bswap   rax ; transformation

lea     rsi, [rsi+8] ; advance VIP…

rol     rax, 0Ch ; transformation

inc     rax ; transformation

xor     rbx, rax ; transformation (update rolling decrypt key)

sub     rbp, 8

mov     [rbp+0], rax

LCONSTCDQE - 加载常数DWORD符号扩展到一个QWORD

这条虚拟指令从RSI加载一个DWORD大小的操作数,对其进行解密,并将其扩展为一个QWORD,最后将其放在虚拟堆栈中。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

mov     eax, [rsi]

xor     eax, ebx

xor     eax, 32B63802h

dec     eax

lea     rsi, [rsi+4] ; advance VIP

xor     eax, 7E4087EEh

; look below for details on this...

push    rbx

xor     [rsp], eax

pop     rbx

cdqe ; sign extend EAX to RAX…

sub     rbp, 8

mov     [rbp+0], rax

注意,这个最后的vm handler通过把值放在栈上然后应用转换来更新滚动解密密钥。这是在解析这些vm handler时可能会引起重大问题的东西。幸运的是,有一个非常简单的技巧来处理这个问题,永远记住应用于滚动密钥的转换与第一个转换是一样的。在上面的例子中,它是一个简单的XOR。

LCONSTCBW - 加载常数字节转换为字

LCONSTCBW从RSI加载一个常数字节值,对其进行解密,并将结果扩展为一个WORD值。这个解密的值然后被放在虚拟堆栈中。

1

2

3

4

5

6

7

8

9

10

movzx eax, byte ptr [rsi]

add al, bl

inc al

neg al

ror al, 0x06

add bl, al

mov ax, [rax+rdi*1]

sub rbp, 0x02

inc rsi

mov [rbp], ax

LCONSTCWDE - 加载常数字转换为DWORD

LCONSTCWDE从RSI加载一个常数字,对其进行解密,并将其符号扩展为一个DWORD。最后,结果值被放在虚拟堆栈中。

1

2

3

4

5

6

7

8

9

10

mov ax, [rsi]

add rsi, 0x02

xor ax, bx

rol ax, 0x0E

xor ax, 0xA808

neg ax

xor bx, ax

cwde

sub rbp, 0x04

mov [rbp], eax

LCONSTDW - 加载常数DWORD

LCONSTDW从RSI加载一个常数字,对其进行解密,最后将结果放在虚拟栈上。还要注意,在下面的例子中,VIP是向后推进的。你可以在操作数的取值中看到这一点,因为它在取消引用之前从RSI中减去了。

1

2

3

4

5

6

7

8

9

10

11

12

mov eax, [rsi-0x04]

bswap eax

add eax, ebx

dec eax

neg eax

xor eax, 0x2FFD187C

push rbx

add [rsp], eax

pop rbx

sub rbp, 0x04

mov [rbp], eax

add rsi, 0xFFFFFFFFFFFFFFFC

LREG - 在堆栈中加载Scratch寄存器的值

让我们来看看另一个VM handler,这个程序的名字是LREG。就像LCONST一样,这条指令有很多变体,特别是针对不同的大小。LREG也将出现在每一个二进制文件中,因为它在虚拟机内部被用来将寄存器值加载到Scratch寄存器中。稍后会有更多关于这个的内容。

LREGQ - 加载Scratch寄存器QWORD

LREGQ有一个字节的即时值。这就是Scratch寄存器的索引。一个指向Scratch寄存器的指针总是被加载到RDI中。正如上面多次描述的那样,总共有五个变换应用于即时值,以解密它。第一个转换来自于滚动解密密钥,接着是三个直接应用于即时值的转换,对其进行完全解密。最后,滚动解密密钥被更新,以解密后的即时值为源,对其应用第一次转换。

1

2

3

4

5

6

7

8

9

10

mov     al, [rsi]

sub     al, bl

ror     al, 2

not     al

inc     al

sub     bl, al

mov     rdx, [rax+rdi]

sub     rbp, 8

mov     [rbp+0], rdx

inc     rsi

LREGDW - 加载Scratch寄存器DWORD

LREGDW是LREG的一个变体,它将一个DWORD从一个Scratch寄存器加载到堆栈中。它有两个操作数,第二个是代表scratch寄存器索引的单字节。下面的代码片断是LREGDW的一个解密视图。

1

2

3

4

5

6

7

8

9

mov     al, [rsi]

sub     al, bl

add     al, 97h

ror     al, 1

neg     al

sub     bl, al

mov     edx, [rax+rdi]

sub     rbp, 4

mov     [rbp+0], edx

SREG - 设置Scratch寄存器值

另一条标志性的虚拟指令是SREG,它在每个二进制文件中都有。这条指令有许多变体,它们将Scratch寄存器设置为某些大小的值。这条虚拟指令有两个操作数,第二个操作数是一个包含scratch寄存器索引的单字节即时值。

SREGQ - 设置Scratch寄存器值QWORD

SREGQ在虚拟堆栈的顶部用一个QWORD的值来设置一个虚拟Scratch寄存器。这条虚拟指令由两个操作数组成,第二个操作数是一个代表虚拟Scratch寄存器的单字节。

1

2

3

4

5

6

7

8

9

movzx   eax, byte ptr [rsi]

sub     al, bl

ror     al, 2

not     al

inc     al

sub     bl, al

mov     rdx, [rbp+0]

add     rbp, 8

mov     [rax+rdi], rdx

SREGDW - 设置Scratch寄存器值DWORD

SREGDW在虚拟堆栈的顶部用一个DWORD值设置一个虚拟Scratch寄存器。这条虚拟指令由两个操作数组成,第二个操作数是一个代表虚拟Scratch寄存器的单字节。

1

2

3

4

5

6

7

8

9

10

movzx eax, byte ptr [rsi-0x01]

xor al, bl

inc al

ror al, 0x02

add al, 0xDE

xor bl, al

lea rsi, [rsi-0x01]

mov dx, [rbp]

add rbp, 0x02

mov [rax+rdi*1], dx

SREGW - 设置Scratch寄存器值WORD

SREGW从虚拟堆栈的顶部用一个WORD值设置一个虚拟的Scratch寄存器。这条虚拟指令由两个操作数组成,第二个操作数是一个代表虚拟Scratch寄存器的单字节。

1

2

3

4

5

6

7

8

9

10

movzx eax, byte ptr [rsi-0x01]

sub al, bl

ror al, 0x06

neg al

rol al, 0x02

sub bl, al

mov edx, [rbp]

add rbp, 0x04

dec rsi

mov [rax+rdi*1], edx

SREGB - 设置Scratch寄存器的字节值

SREGB在虚拟堆栈的顶部设置一个具有BYTE值的虚拟Scratch寄存器。这条虚拟指令由两个操作数组成,第二个操作数是一个代表虚拟Scratch寄存器的单字节。

1

2

3

4

5

6

7

8

9

10

mov al, [rsi-0x01]

xor al, bl

not al

xor al, 0x10

neg al

xor bl, al

sub rsi, 0x01

mov dx, [rbp]

add rbp, 0x02

mov [rax+rdi*1], dl

ADD - 两个值相加

虚拟ADD指令将堆栈中的两个值相加,并将结果存储在堆栈的第二个值位置。由于ADD指令改变了RFLAGS,所以RFLAGS被推到了堆栈上。

ADDQ - 两个QWORD值相加

ADDQ添加两个存储在虚拟堆栈顶部的QWORD值。RFLAGS也被推入堆栈,因为本地ADD指令改变了标志。

1

2

3

4

mov     rax, [rbp+0]

add     [rbp+8], rax

pushfq

pop     qword ptr [rbp+0]

ADDW - 两个WORDS值相加

ADDW添加两个存储在虚拟堆栈顶部的WORD值。RFLAGS也被推入堆栈,因为本地ADD指令改变了标志。

1

2

3

4

5

mov ax, [rbp]

sub rbp, 0x06

add [rbp+0x08], ax

pushfq

pop [rbp]

ADDB - 两个字节的值相加

ADDB添加两个存储在虚拟堆栈顶部的BYTE值。RFLAGS也被推入堆栈,因为本地ADD指令改变了标志。

1

2

3

4

5

mov al, [rbp]

sub rbp, 0x06

add [rbp+0x08], al

pushfq

pop [rbp]

MUL - 无符号乘法

虚拟MUL指令将存储在堆栈中的两个值相乘。这些vm处理程序使用本地MUL指令,另外RFLAGS被推入堆栈。最后,它是一条单操作数指令,这意味着没有与该指令相关的即时值。

MULQ - QWORD的无符号乘法

MULQ将两个QWORD值相乘,结果存储在VSP+24的堆栈中,另外RFLAGS被推到堆栈中。

1

2

3

4

5

6

7

mov rax, [rbp+0x08]

sub rbp, 0x08

mul rdx

mov [rbp+0x08], rdx

mov [rbp+0x10], rax

pushfq

pop [rbp]

DIV - 无符号除法

虚拟DIV指令使用本地DIV指令,除法中使用的顶级操作数位于虚拟堆栈的顶部。这是一条单操作数的虚拟指令,因此没有即时值。RFLAGS也被推入堆栈,因为本地DIV指令也可以RFLAGS。

DIVQ - QWORD的无符号除法

DIVQ对位于虚拟堆栈上的两个QWORD值进行分割。将RFLAGS推到栈上。

1

2

3

4

5

6

7

mov rdx, [rbp]

mov rax, [rbp+0x08]

div [rbp+0x10]

mov [rbp+0x08], rdx

mov [rbp+0x10], rax

pushfq

pop [rbp]

READ - 读取内存

READ指令可以读取不同大小的内存。这个指令有一个变体,可以读取一个、两个、四个和八个字节。

READQ - 读取QWORD

READQ从存储在堆栈顶部的地址中读取一个QWORD值。这条虚拟指令有时似乎有一个段预置在上面。然而并不是所有的READQ vm处理程序都有这个ss与之相关。现在QWORD值被存储在虚拟堆栈的顶部。

1

2

3

mov rax, [rbp]

mov rax, ss:[rax]

mov [rbp], rax

READDW - 读取DWORD

READDW从存储在虚拟栈顶的地址中读取一个DWORD值。然后,该DWORD值被放在虚拟堆栈的顶部。下面是READDW的两个例子,一个使用了这个段索引语法,另一个没有使用。

1

2

3

4

mov rax, [rbp]

add rbp, 0x04

mov eax, [rax]

mov [rbp], eax

注意下面的分段偏移用法与ss...

1

2

3

4

mov rax, [rbp]

add rbp, 0x04

mov eax, ss:[rax]

mov [rbp], eax

READW - 读取字

READW从存储在虚拟栈顶的地址中读取一个WORD值。然后,该WORD值被放在虚拟栈的顶部。下面是这个vm处理程序使用段索引语法的一个例子,但是请记住,还有一些vm处理程序没有这个段索引。

1

2

3

4

mov rax, [rbp]

add rbp, 0x06

mov ax, ss:[rax]

mov [rbp], ax

WRITE - 写入内存

WRITE虚拟指令最多可以向一个地址写入八个字节。这个虚拟指令有四个变体,2的每一个次幂都有一个,包括8。每个vm处理程序也有使用段偏移类型指令编码的版本。然而在长模式下,一些段基地址是零。似乎总是被使用的段是SS段,它的基数为0,因此段基数在这里没有影响,它只是使解析这些vm处理程序更加困难。

WRITEQ - 写入内存QWORD

WRITEQ将一个QWORD值写到位于虚拟堆栈顶部的地址。堆栈增加了16个字节。

1

2

3

4

.vmp0:0000000140005A74 48 8B 45 00            mov     rax, [rbp+0]

.vmp0:0000000140005A82 48 8B 55 08            mov     rdx, [rbp+8]

.vmp0:0000000140005A8A 48 83 C5 10            add     rbp, 10h

.vmp0:00000001400075CF 48 89 10               mov     [rax], rdx

WRITEDW - 写入DWORD

WRITEDW将一个DWORD值写到位于虚拟堆栈顶部的地址。堆栈被增加了12个字节。

1

2

3

4

mov rax, [rbp]

mov edx, [rbp+0x08]

add rbp, 0x0C

mov [rax], edx

注意下面的分段偏移ss的用法...

1

2

3

4

mov rax, [rbp]

mov edx, [rbp+0x08]

add rbp, 0x0C

mov ss:[rax], edx ; note the SS usage here...

WRITEW - 写入WORD

WRITEW虚拟指令将一个WORD值写到位于虚拟堆栈顶部的地址。然后,堆栈被增加10个字节。

1

2

3

4

mov rax, [rbp]

mov dx, [rbp+0x08]

add rbp, 0x0A

mov ss:[rax], dx

WRITEB - 写入字节

WRITEB虚拟指令将一个BYTE值写到位于虚拟堆栈顶部的地址。然后,堆栈被增加10个字节。

1

2

3

4

mov rax, [rbp]

mov dl, [rbp+0x08]

add rbp, 0x0A

mov ss:[rax], dl

SHL - 左移

SHL vm handler将位于堆栈顶部的一个值向左移动若干位。要移位的位数被存储在堆栈上要移位的值的上方。然后将结果和RFLAGS一起放入堆栈。

SHLCBW - 左移转换结果为WORD

SHLCBW将一个字节值向左移动,并将结果扩展为一个WORD。RFLAGS被推到堆栈中。

1

2

3

4

5

6

7

mov     al, [rbp+0]

mov     cl, [rbp+2]

sub     rbp, 6

shl     al, cl

mov     [rbp+8], ax

pushfq

pop     qword ptr [rbp+0]

SHLW - WORD左移

SHLW将一个WORD值向左移动。RFLAGS被推到虚拟堆栈中。

1

2

3

4

5

6

7

mov ax, [rbp]

mov cl, [rbp+0x02]

sub rbp, 0x06

shl ax, cl

mov [rbp+0x08], ax

pushfq

pop [rbp]

SHLDW - DWORD左移

SHLDW将一个DWORD向左移动。RFLAGS被推到虚拟堆栈中。

1

2

3

4

5

6

7

mov eax, [rbp]

mov cl, [rbp+0x04]

sub rbp, 0x06

shl eax, cl

mov [rbp+0x08], eax

pushfq

pop [rbp]

SHLQ - QWORD左移

SHLQ将一个QWORD向左移动。RFLAGS被推到虚拟堆栈中。

1

2

3

4

5

6

7

mov rax, [rbp]

mov cl, [rbp+0x08]

sub rbp, 0x06

shl rax, cl

mov [rbp+0x08], rax

pushfq

pop [rbp]

SHLD - 双精度左移

SHLD虚拟指令使用本地指令SHLD将一个值向左移动。然后,结果被放到堆栈和RFLAGS中。这条指令有一个变体,用于一个、两个、四个和八个字节的移位。

SHLDQ - 向左移动双精度QWORD

SHLDQ以双精度将一个QWORD向左移位。然后,结果被放到虚拟堆栈中,RFLAGS被推到虚拟堆栈中。

1

2

3

4

5

6

7

8

mov rax, [rbp]

mov rdx, [rbp+0x08]

mov cl, [rbp+0x10]

add rbp, 0x02

shld rax, rdx, cl

mov [rbp+0x08], rax

pushfq

pop [rbp]

SHLDDW - 双精度DWORD左移

SHLDDW虚拟指令以双精度将一个DWORD值向左移动。其结果被推入虚拟堆栈以及RFLAGS。

1

2

3

4

5

6

7

8

mov eax, [rbp]

mov edx, [rbp+0x04]

mov cl, [rbp+0x08]

sub rbp, 0x02

shld eax, edx, cl

mov [rbp+0x08], eax

pushfq

pop [rbp]

SHR - 右移

SHR指令是对SHL的补充,这条虚拟指令改变了RFLAGS,因此执行这条虚拟指令后,RFLAGS值将在堆栈的顶部。

SHRQ - 右移QWORD

SHRQ将一个QWORD值向右移动。其结果和RFLAGS一样被放到虚拟堆栈中。

1

2

3

4

5

6

7

mov rax, [rbp]

mov cl, [rbp+0x08]

sub rbp, 0x06

shr rax, cl

mov [rbp+0x08], rax

pushfq

pop [rbp]

SHRD - 双精度右移

SHRD虚拟指令以双精度将一个值向右移动。这条指令有一个变体,用于一个、两个、四个和八个字节的移位。这条虚拟指令的结论是RFLAGS被推到虚拟堆栈中。

SHRDQ - 双精度右移QWORD

SHRDQ以双精度将一个QWORD值向右移动。其结果被放到虚拟堆栈中。然后RFLAGS被推到虚拟堆栈中。

1

2

3

4

5

6

7

8

mov rax, [rbp]

mov rdx, [rbp+0x08]

mov cl, [rbp+0x10]

add rbp, 0x02

shrd rax, rdx, cl

mov [rbp+0x08], rax

pushfq

pop [rbp]

SHRDDW - 双精度右移DWORD

SHRDDW以双精度将一个DWORD值向右移动。其结果被放到虚拟堆栈中。然后RFLAGS被推到虚拟堆栈中。

1

2

3

4

5

6

7

8

mov eax, [rbp]

mov edx, [rbp+0x04]

mov cl, [rbp+0x08]

sub rbp, 0x02

shrd eax, edx, cl

mov [rbp+0x08], eax

pushfq

pop [rbp]

NAND - Not Then And

NAND指令包括对堆栈顶部的数值应用一个Not,然后将这个Not的结果以比特的方式并入堆栈的下一个数值。and指令改变了RFLAGS,因此,RFLAGS将被推到虚拟堆栈中。

NANDW - 不是然后和WORD的

NANDW将两个WORD值进行NOT处理,然后将它们进行位操作并在一起。然后RFLAGs被推到虚拟堆栈中。

1

2

3

4

5

6

not dword ptr [rbp]

mov ax, [rbp]

sub rbp, 0x06

and [rbp+0x08], ax

pushfq

pop [rbp]

READCR3 - 读取控制寄存器3

READCR3虚拟指令是一个围绕本地mov寄存器cr3的封装vm处理程序。这条指令将把CR3的值放到虚拟堆栈中。

1

2

3

mov rax, cr3

sub rbp, 0x08

mov [rbp], rax

WRITECR3 - 写控制寄存器3

WRITECR3虚拟指令是一个围绕本地mov cr3, reg的封装vm处理程序。这条指令将把一个值放入CR3。

1

2

3

mov rax, [rbp]

add rbp, 0x08

mov cr3, rax

PUSHVSP - Push虚拟堆栈指针

PUSHVSP虚拟指令将本地寄存器RBP中的值推到虚拟堆栈中。这条指令有一个变体,用于1、2、4和8个字节。

PUSHVSPQ - Push虚拟堆栈指针QWORD

PUSHVSPQ将虚拟堆栈指针的整个值推到虚拟堆栈上。

1

2

3

mov rax, rbp

sub rbp, 0x08

mov [rbp], rax

PUSHVSPDW - 推送虚拟栈指针DWORD

PUSHVSPDW将虚拟堆栈指针的底部四个字节推到虚拟堆栈中。

1

2

3

mov eax, ebp

sub rbp, 0x04

mov [rbp], eax

PUSVSPW - 推送虚拟堆栈指针的WORD值

PUSVSPW将虚拟堆栈指针的底部WORD值推送到虚拟堆栈中。

1

2

3

mov eax, ebp

sub rbp, 0x02

mov [rbp], ax

LVSP - 加载虚拟堆栈指针

这条虚拟指令用堆栈顶部的值加载虚拟堆栈指针寄存器。

LVSPW - 加载虚拟堆栈指针字

这条虚拟指令用堆栈顶部的WORD值加载虚拟堆栈指针寄存器。

LVSPDW - 加载虚拟堆栈指针DWORD

这条虚拟指令用栈顶的DWORD值加载虚拟堆栈指针寄存器。

LRFLAGS - 加载RFLAGS

这条虚拟指令用堆栈顶部的QWORD值加载本地标志寄存器。

1

2

3

push [rbp]

add rbp, 0x08

popfq

JMP - 虚拟跳转指令

虚拟JMP指令改变RSI寄存器,指向一组新的虚拟指令。堆栈顶部的值是RVA从模块基础到虚拟指令的低32位。然后这个值被加到PE文件的可选头中找到的图像基值的前32位。然后,基址被加到这个值上。

1

2

3

4

5

6

7

mov esi, [rbp]

add rbp, 0x08

lea r12, [0x0000000000048F29]

mov rax, 0x00 ; image base bytes above 32bits...

add rsi, rax

mov rbx, rsi ; update decrypt key

add rsi, [rbp] ; add module base address

CALL - 虚拟调用指令

虚拟调用指令获取虚拟堆栈顶部的一个地址,然后调用它。RDX被用来保存地址,所以你只能用它来真正调用具有单一参数的函数。

1

2

3

mov rdx, [rbp]

add rbp, 0x08

call rdx

现在,VMProtect 2 的虚拟机架构已被记录下来,我们可以思考重要的签名。此外,VMProtect 2 产生的混淆也可以用相当简单的技术处理。这可以使解析vm_entry例程变得微不足道。vm_entry没有合法的JCC,所以每次遇到JCC时,我们可以简单地跟踪它,从指令流中删除JCC,然后一旦遇到JMP RCX/RDX就停止。我们可以通过跟踪指令如何被Zydis使用,特别是跟踪指令的目标寄存器的读写依赖性,来删除大部分deadstore。最后,有了清理过的vm_entry,我们现在可以遍历所有的指令,找到vm处理程序,解密vm处理程序表项所需的转换,最后是解密跳转到vm_entry之前推到堆栈的虚拟指令的相对虚拟地址所需的转换。

定位虚拟机处理程序表

最好的,也是最知名的签名之一是LEA r12,vm_handlers。这条指令位于vm_entry代码段内,将vm处理程序表的线性虚拟地址加载到R12中。使用Zydis,我们可以很容易地找到并解析这个LEA,从而自己找到vm handler表的位置。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

std::uintptr_t* vm::handler::table::get(const zydis_routine_t& vm_entry)

{

    const auto result = std::find_if(

        vm_entry.begin(), vm_entry.end(),

        [](const zydis_instr_t& instr_data) -> bool

        {

            const auto instr = &instr_data.instr;

            // lea r12, vm_handlers... (always r12)...

            if (instr->mnemonic == ZYDIS_MNEMONIC_LEA &&

                instr->operands[0].type == ZYDIS_OPERAND_TYPE_REGISTER &&

                instr->operands[0].reg.value == ZYDIS_REGISTER_R12 &&

                !instr->raw.sib.base) // no register used for the sib base...

                return true;

            return false;

        }

    );

    if (result == vm_entry.end())

        return nullptr;

    std::uintptr_t ptr = 0u;

    ZydisCalcAbsoluteAddress(&result->instr,

        &result->instr.operands[1], result->addr, &ptr);

    return reinterpret_cast<std::uintptr_t*>(ptr);

}

上述Zydis例程将静态地定位虚拟机处理表的地址。它只需要一个ZydisDecodedInstructions的向量,vm_entry例程中的每个指令都有一个。我的实现(vmprofiler)将首先对vm_entry进行解密,然后再传递这个向量。

定位虚拟机处理程序表条目解密

你可以很容易地以编程方式确定对虚拟机处理程序表条目应用了什么转换,首先找到从该表获取条目的指令。这条指令记录在vm_entry部分,它由一条SIB指令组成,RDX或RCX为目标,R12为基数,RAX为索引,8为比例。

1

.vmp0:0000000140005A41 49 8B 14 C4            mov     rdx, [r12+rax*8]

使用Zydis可以很容易地找到这一点。所要做的就是找到一条以RCX或RDX为目标的SIB mov指令,R12为基数,RAX为索引,最后是8为索引。现在,使用Zydis我们可以找到下一条以RDX或RCX为目标的指令,这条指令将是应用于VM处理表项的转换。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

bool vm::handler::table::get_transform(

    const zydis_routine_t& vm_entry, ZydisDecodedInstruction* transform_instr)

{

    ZydisRegister rcx_or_rdx = ZYDIS_REGISTER_NONE;

    auto handler_fetch = std::find_if(

        vm_entry.begin(), vm_entry.end(),

        [&](const zydis_instr_t& instr_data) -> bool

        {

            const auto instr = &instr_data.instr;

            if (instr->mnemonic == ZYDIS_MNEMONIC_MOV &&

                instr->operand_count == 2 &&

                instr->operands[1].type == ZYDIS_OPERAND_TYPE_MEMORY &&

                instr->operands[1].mem.base == ZYDIS_REGISTER_R12 &&

                instr->operands[1].mem.index == ZYDIS_REGISTER_RAX &&

                instr->operands[1].mem.scale == 8 &&

                instr->operands[0].type == ZYDIS_OPERAND_TYPE_REGISTER &&

                (instr->operands[0].reg.value == ZYDIS_REGISTER_RDX ||

                    instr->operands[0].reg.value == ZYDIS_REGISTER_RCX))

            {

                rcx_or_rdx = instr->operands[0].reg.value;

                return true;

            }

            return false;

        }

    );

    // check to see if we found the fetch instruction and if the next instruction

    // is not the end of the vector...

    if (handler_fetch == vm_entry.end() || ++handler_fetch == vm_entry.end() ||

        // must be RCX or RDX... else something went wrong...

        (rcx_or_rdx != ZYDIS_REGISTER_RCX && rcx_or_rdx != ZYDIS_REGISTER_RDX))

        return false;

    // find the next instruction that writes to RCX or RDX...

    // the register is determined by the vm handler fetch above...

    auto handler_transform = std::find_if(

        handler_fetch, vm_entry.end(),

        [&](const zydis_instr_t& instr_data) -> bool

        {

            if (instr_data.instr.operands[0].reg.value == rcx_or_rdx &&

                instr_data.instr.operands[0].actions & ZYDIS_OPERAND_ACTION_WRITE)

                return true;

            return false;

        }

    );

    if (handler_transform == vm_entry.end())

        return false;

    *transform_instr = handler_transform->instr;

    return true;

}

这个函数将解析vm_entry例程,并返回为解密虚拟机处理表项所做的转换。在C++中,每个转换操作都可以用lambdas来实现,可以用一个函数编码来返回必须应用的转换的相应lambda例程。

1

2

.vmp0:0000000140005A41 49 8B 14 C4            mov     rdx, [r12+rax*8]

.vmp0:0000000140005A49 48 81 F2 49 21 3D 7F   xor     rdx, 7F3D2149h

上面的代码等同于下面的C++代码。这将解密vm处理程序条目。为了加密新的数值,必须进行逆向操作。然而对于XOR来说,这只是简单的XOR。

1

2

3

4

5

6

7

8

9

10

11

12

vm::decrypt_handler _decrypt_handler =

    [](std::uint8_t idx) -> std::uint64_t

{

    return vm_handlers[idx] ^ 0x7F3D2149;

};

// this is not the best example as the inverse of XOR is XOR...

vm::encrypt_handler _encrypt_handler =

    [](std::uint8_t idx) -> std::uint64_t

{

    return vm_handlers[idx] ^ 0x7F3D2149;

};

处理转换--模板化的Lambdas和Maps

上述解密和加密处理程序可以通过创建每个转换类型的映射和该指令的C++ lambda再实现来动态生成。此外,还可以创建一个处理动态值的例程,如字节大小。这可以防止每次需要转换时都要创建一个开关案例。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

namespace transform

{

    // ...

    template <class T>

    inline std::map<ZydisMnemonic, transform_t<T>> transforms =

    {

        { ZYDIS_MNEMONIC_ADD, _add<T> },

        { ZYDIS_MNEMONIC_XOR, _xor<T> },

        { ZYDIS_MNEMONIC_BSWAP, _bswap<T> },

        // SUB, INC, DEC, OR, AND, ETC...

    };

    // max size of a and b is 64 bits, a and b is then converted to

    // the number of bits in bitsize, the transformation is applied,

    // finally the result is converted back to 64bits...

    inline auto apply(std::uint8_t bitsize, ZydisMnemonic op,

        std::uint64_t a, std::uint64_t b) -> std::uint64_t

    {

        switch (bitsize)

        {

        case 8:

            return transforms<std::uint8_t>[op](a, b);

        case 16:

            return transforms<std::uint16_t>[op](a, b);

        case 32:

            return transforms<std::uint32_t>[op](a, b);

        case 64:

            return transforms<std::uint64_t>[op](a, b);

        default:

            throw std::invalid_argument("invalid bit size...");

        }

    }

    // ...

}

这一小段代码将允许在考虑到溢出的情况下轻松实现C++中的转换。在转换过程中,重视大小是非常重要的,因为如果没有正确的大小,溢出以及滚动和移位都是不正确的。下面的代码是一个例子,说明如何通过在C++中动态地实现转换来解密虚拟指令的操作数。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

// here for your eyes - better understanding of the code :^)

using map_t = std::map<transform::type, ZydisDecodedInstruction>;

auto decrypt_operand(transform::map_t& transforms,

    std::uint64_t operand, std::uint64_t rolling_key) -> std::pair<std::uint64_t, std::uint64_t>

{

    const auto key_decrypt = &transforms[transform::type::rolling_key];

    const auto generic_decrypt_1 = &transforms[transform::type::generic1];

    const auto generic_decrypt_2 = &transforms[transform::type::generic2];

    const auto generic_decrypt_3 = &transforms[transform::type::generic3];

    const auto update_key = &transforms[transform::type::update_key];

    // apply transformation with rolling decrypt key...

    operand = transform::apply(key_decrypt->operands[0].size,

        key_decrypt->mnemonic, operand, rolling_key);

    // apply three generic transformations...

    {

        operand = transform::apply(

            generic_decrypt_1->operands[0].size,

            generic_decrypt_1->mnemonic, operand,

            // check to see if this instruction has an IMM...

            transform::has_imm(generic_decrypt_1) ?

                generic_decrypt_1->operands[1].imm.value.u : 0);

        operand = transform::apply(

            generic_decrypt_2->operands[0].size,

            generic_decrypt_2->mnemonic, operand,

            // check to see if this instruction has an IMM...

            transform::has_imm(generic_decrypt_2) ?

                generic_decrypt_2->operands[1].imm.value.u : 0);

        operand = transform::apply(

            generic_decrypt_3->operands[0].size,

            generic_decrypt_3->mnemonic, operand,

            // check to see if this instruction has an IMM...

            transform::has_imm(generic_decrypt_3) ?

                generic_decrypt_3->operands[1].imm.value.u : 0);

    }

    // update rolling key...

    rolling_key = transform::apply(key_decrypt->operands[0].size,

        key_decrypt->mnemonic, rolling_key, operand);

    return { operand, rolling_key };

}

提取转换 - 静态分析继续

重新实现转换的能力是很重要的,然而,能够从vm处理程序和calc_jmp中解析出转换是另一个需要自己解决的问题。为了确定转换的位置,我们必须首先确定是否有转换的需要。变换只适用于虚拟指令的操作数。虚拟指令的第一个操作数总是在同一个地方进行转换,这个代码被称为calc_jmp,我在前面解释过。转化的第二个地方是在处理即时值的vm处理程序中发现的。换句话说,如果一条虚拟指令有一个即时值,那么对于这个操作数将有一套独特的转换。即时值是从VIP中读出的(RSI),所以我们可以使用这个关键细节来确定是否有即时值以及即时值的大小。值得注意的是,从VIP中读出的即时值并不总是等于为LCONST等指令在堆栈中分配的解密值的大小。这是因为符号扩展和零扩展的虚拟指令。让我们来看看一个有即时值的虚拟指令的例子。这条虚拟指令被称为LCONSTWSE,代表 "加载大小为word的常量值,但符号扩展为DWORD"。这条虚拟指令的解密vm处理程序看起来像这样。

1

2

3

4

5

6

7

8

9

10

.vmp0:0000000140004478 66 0F B7 06            movzx   ax, word ptr [rsi]

.vmp0:0000000140004412 66 29 D8               sub     ax, bx

.vmp0:0000000140004416 66 D1 C0               rol     ax, 1

.vmp0:0000000140004605 66 F7 D8               neg     ax

.vmp0:000000014000460A 66 35 AC 21            xor     ax, 21ACh

.vmp0:000000014000460F 66 29 C3               sub     bx, ax

.vmp0:0000000140004613 98                     cwde

.vmp0:0000000140004618 48 83 ED 04            sub     rbp, 4

.vmp0:0000000140006E4F 89 45 00               mov     [rbp+0], eax

.vmp0:0000000140007E2D 48 8D 76 02            lea     rsi, [rsi+2]

如你所见,有两个字节从VIP中读出。这是第一条指令。这是我们可以在zydis中寻找的东西。任何以RAX为目的,RSI为源的MOVZX、MOVSX或MOV都表明有一个即时值,因此我们知道在指令流中预计有五个转换。然后我们可以搜索一条指令,其中RAX是目标,RBX是源。这将是第一个转换。在上面的例子中,第一条减法指令就是我们要找的。

1

.vmp0:0000000140004412 66 29 D8               sub     ax, bx

接下来我们可以寻找三条对RAX有写入依赖性的指令。这三条指令将是应用于操作数的通用转换。

1

2

3

.vmp0:0000000140004416 66 D1 C0               rol     ax, 1

.vmp0:0000000140004605 66 F7 D8               neg     ax

.vmp0:000000014000460A 66 35 AC 21            xor     ax, 21ACh

在这一点上,操作数被完全解密了。唯一剩下的是对滚动解密密钥(RBX)进行的一次转换。这最后一次转换更新了滚动解密密钥。

1

.vmp0:000000014000460F 66 29 C3               sub     bx, ax

所有这些转换指令现在都可以由C++的lambdas在飞行中重新实现。使用std::find_if对于这些类型的搜索算法非常有用,因为你可以一步步来。首先找到关键的转换,然后找到写给RAX的下三个指令。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

65

66

67

68

bool vm::handler::get_transforms(const zydis_routine_t& vm_handler, transform::map_t& transforms)

{

    auto imm_fetch = std::find_if(

        vm_handler.begin(), vm_handler.end(),

        [](const zydis_instr_t& instr_data) -> bool

        {

            // mov/movsx/movzx rax/eax/ax/al, [rsi]

            if (instr_data.instr.operand_count > 1 &&

                (instr_data.instr.mnemonic == ZYDIS_MNEMONIC_MOV ||

                    instr_data.instr.mnemonic == ZYDIS_MNEMONIC_MOVSX ||

                    instr_data.instr.mnemonic == ZYDIS_MNEMONIC_MOVZX) &&

                instr_data.instr.operands[0].type == ZYDIS_OPERAND_TYPE_REGISTER &&

                util::reg::compare(instr_data.instr.operands[0].reg.value, ZYDIS_REGISTER_RAX) &&

                instr_data.instr.operands[1].type == ZYDIS_OPERAND_TYPE_MEMORY &&

                instr_data.instr.operands[1].mem.base == ZYDIS_REGISTER_RSI)

                return true;

            return false;

        }

    );

    if (imm_fetch == vm_handler.end())

        return false;

    // this finds the first transformation which looks like:

    // transform rax, rbx <--- note these registers can be smaller so we to64 them...

    auto key_transform = std::find_if(imm_fetch, vm_handler.end(),

        [](const zydis_instr_t& instr_data) -> bool

        {

            if (util::reg::compare(instr_data.instr.operands[0].reg.value, ZYDIS_REGISTER_RAX) &&

                util::reg::compare(instr_data.instr.operands[1].reg.value, ZYDIS_REGISTER_RBX))

                return true;

            return false;

        }

    );

    // last transformation is the same as the first except src and dest are swapped...

    transforms[transform::type::rolling_key] = key_transform->instr;

    auto instr_copy = key_transform->instr;

    instr_copy.operands[0].reg.value = key_transform->instr.operands[1].reg.value;

    instr_copy.operands[1].reg.value = key_transform->instr.operands[0].reg.value;

    transforms[transform::type::update_key] = instr_copy;

    if (key_transform == vm_handler.end())

        return false;

    // three generic transformations...

    auto generic_transform = key_transform;

    for (auto idx = 0u; idx < 3; ++idx)

    {

        generic_transform = std::find_if(++generic_transform, vm_handler.end(),

            [](const zydis_instr_t& instr_data) -> bool

            {

                if (util::reg::compare(instr_data.instr.operands[0].reg.value, ZYDIS_REGISTER_RAX))

                    return true;

                return false;

            }

        );

        if (generic_transform == vm_handler.end())

            return false;

        transforms[(transform::type)(idx + 1)] = generic_transform->instr;

    }

    return true;

}

正如你所看到的,除了源操作数和目的操作数互换外,第一个转换与最后一个转换是一样的。VMProtect 2 在应用最后一次转换时有一些创造性的自由,有时会将滚动解密密钥推到堆栈中,应用转换,然后将结果弹回 RBX。这个小但重要的不便可以通过简单地交换ZydisDecodedInstruction变量中的目标和源寄存器来处理,如上面的代码所示。

静态分析的困境 - 静态分析的结论

试图静态分析虚拟指令的困境是,虚拟机内部的分支操作非常难以处理。为了计算一个虚拟JMP的跳转位置,需要进行仿真。我将在不久的将来进行这方面的研究(独角兽)。

vmtracer - 追踪虚拟指令

追踪虚拟指令的方法很简单,就是把每一个vm处理程序表的条目修补成一个加密的值,当解密的时候指向一个陷阱处理程序。这将允许对寄存器进行指令间检查,并有可能改变vm处理器的结果。为了很好地利用这一功能,重要的是要了解哪些寄存器包含哪些值。你可以参考这篇文章的 "概述部分"。

在拦截虚拟指令时,首先要记录的重要信息是位于AL的操作码值。记录这个将告诉我们所有执行的虚拟指令。下一个必须被记录的值是位于BL的滚动解密密钥值。这将使vmprofiler能够静态地解密操作数。

既然我们能够做到,在每一条虚拟指令之后记录所有的scratch寄存器是对记录信息的一个重要补充,因为这将描绘出一幅更大的画面,说明哪些值被操纵了。最后,记录虚拟堆栈上的前五个QWORD值是为了提供更多的信息,因为这个虚拟指令集架构是基于堆栈机的。

为了结束这篇文章的动态分析部分,我为这些运行时数据创建了一个小文件格式。该文件格式被称为 "vmp2",包含所有的运行时日志信息。这个文件格式的结构非常简单,它们被列在下面。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

65

66

67

68

namespace vmp2

{

    enum class exec_type_t

    {

        forward,

        backward

    };

    enum class version_t

    {

        invalid,

        v1 = 0x101

    };

    struct file_header

    {

        u32 magic; // VMP2

        u64 epoch_time;

        u64 module_base;

        exec_type_t advancement;

        version_t version;

        u32 entry_count;

        u32 entry_offset;

    };

    struct entry_t

    {

        u8 handler_idx;

        u64 decrypt_key;

        u64 vip;

        union

        {

            struct

            {

                u64 r15;

                u64 r14;

                u64 r13;

                u64 r12;

                u64 r11;

                u64 r10;

                u64 r9;

                u64 r8;

                u64 rbp;

                u64 rdi;

                u64 rsi;

                u64 rdx;

                u64 rcx;

                u64 rbx;

                u64 rax;

                u64 rflags;

            };

            u64 raw[16];

        } regs;

        union

        {

            u64 qword[0x28];

            u8 raw[0x140];

        } vregs;

        union

        {

            u64 qword[0x20];

            u8 raw[0x100];

        } vsp;

    };

}

vmprofile-cli - 使用运行时跟踪的静态分析

如果提供一个 "vmp2"文件,vmprofiler将产生伪虚拟指令,包括即时值以及受影响的scratch寄存器。这绝不是去虚拟化,也不提供多个代码路径的视图,但是它确实提供了一个非常有用的已执行虚拟指令的跟踪。Vmprofiler还可以用来静态地定位vm handler表,并确定使用什么转换来解密这些vm handler条目。

vmprofiler的一个示例输出将产生关于每个vm处理程序的所有信息,包括即时值的位数,虚拟指令的名称,以及应用于即时值的五个转换(如果有即时值)。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

==========[vm handler LCONSTCBW, imm size = 8]=======

================[vm handler instructions]============

> 0x00007FF65BAE5C2E movzx eax, byte ptr [rsi]

> 0x00007FF65BAE5C82 add al, bl

> 0x00007FF65BAE5C85 add al, 0xD3

> 0x00007FF65BAE6FC7 not al

> 0x00007FF65BAE4D23 inc al

> 0x00007FF65BAE5633 add bl, al

> 0x00007FF65BAE53D5 sub rsi, 0xFFFFFFFFFFFFFFFF

> 0x00007FF65BAE5CD1 sub rbp, 0x02

> 0x00007FF65BAE62F8 mov [rbp], ax

=================[vm handler transforms]=============

add al, bl

add al, 0xD3

not al

inc al

add bl, al

=====================================================

如果有的话,这些转换也会从vm处理程序中提取出来,并可以动态地执行以解密操作数。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

> SREGQ 0x0000000000000088 (VSP[0] = 0x00007FF549600000) (VSP[1] = 0x0000000000000000)

> LCONSTDSX 0x000000007D361173 (VSP[0] = 0x0000000000000000) (VSP[1] = 0x0000000000000000)

> ADDQ (VSP[0] = 0x000000007D361173) (VSP[1] = 0x0000000000000000)

> SREGQ 0x0000000000000010 (VSP[0] = 0x0000000000000202) (VSP[1] = 0x000000007D361173)

> SREGQ 0x0000000000000048 (VSP[0] = 0x000000007D361173) (VSP[1] = 0x0000000000000000)

> SREGQ 0x0000000000000000 (VSP[0] = 0x0000000000000000) (VSP[1] = 0x0000000000000100)

> SREGQ 0x0000000000000038 (VSP[0] = 0x0000000000000100) (VSP[1] = 0x00000000000000B8)

> SREGQ 0x0000000000000028 (VSP[0] = 0x00000000000000B8) (VSP[1] = 0x0000000000000246)

> SREGQ 0x00000000000000B8 (VSP[0] = 0x0000000000000246) (VSP[1] = 0x0000000000000100)

> SREGQ 0x0000000000000010 (VSP[0] = 0x0000000000000100) (VSP[1] = 0x000000892D8FDA88)

> SREGQ 0x00000000000000B0 (VSP[0] = 0x000000892D8FDA88) (VSP[1] = 0x0000000000000000)

> SREGQ 0x0000000000000040 (VSP[0] = 0x0000000000000000) (VSP[1] = 0x0000000000000020)

> SREGQ 0x0000000000000030 (VSP[0] = 0x0000000000000020) (VSP[1] = 0x0000000000000000)

> SREGQ 0x0000000000000020 (VSP[0] = 0x0000000000000000) (VSP[1] = 0x2AAAAAAAAAAAAAAB)

// ...

为了显示所有的跟踪信息,如本地寄存器的值、从动寄存器的值和虚拟堆栈的值,我创建了一个非常小的Qt项目,它可以让你在跟踪中一步步的进行。我觉得控制台的限制性太大,而且我也发现很难对需要在控制台显示的内容进行优先排序,因此需要一个GUI。

在vm_entry例程执行后,所有被推入堆栈的寄存器都被加载到虚拟机的scratch寄存器。这也延伸到了模块基数和RFLAGS,它们也被推入了堆栈。本机寄存器到scratch寄存器的映射不被尊重。

虚拟机架构表现出的另一个行为是,如果一条本地指令没有用vm处理程序实现,那么vmexit将发生,以执行本地指令。在我的VMProtect 2版本中,CPUID没有用vm处理程序实现,所以会发生退出。

在vmexit之前,从零开始的寄存器的值被加载到虚拟堆栈。vmexit虚拟指令将把这些值放回本地寄存器中。你可以看到,从头开始的寄存器和直接在vmentry之后的寄存器是不同的。这是因为就像我之前说的,从头开始的寄存器没有被映射到本地寄存器。

在这个Demo中,我将虚拟一个非常简单的二进制文件,它只是执行CPUID,如果支持AVX则返回真,否则返回假。下面显示的是它的汇编代码。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

.text:00007FF776A01000 ; int __fastcall main()

.text:00007FF776A01000                 public main

.text:00007FF776A01000                 push    rbx

.text:00007FF776A01002                 sub     rsp, 10h

.text:00007FF776A01006                 xor     ecx, ecx

.text:00007FF776A01008                 mov     eax, 1

.text:00007FF776A0100D                 cpuid

.text:00007FF776A0100F                 shr     ecx, 1Ch

.text:00007FF776A01012                 and     ecx, 1

.text:00007FF776A01015                 mov     eax, ecx

.text:00007FF776A01017                 add     rsp, 10h

.text:00007FF776A0101B                 pop     rbx

.text:00007FF776A0101C                 retn

.text:00007FF776A0101C main            endp

在保护这段代码时,为了简化演示,我选择了不使用打包。我使用了 "Ultra "设置来保护二进制文件,这只是混淆+虚拟化。看一下输出文件的PE头,我们可以看到入口点RVA是0x1000,图像基数是0x140000000。我们现在可以把这些信息给vmprofiler-cli,它应该给我们提供虚拟机处理表RVA以及所有虚拟机处理信息。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

> vmprofiler-cli.exe --vmpbin vmptest.vmp.exe --vmentry 0x1000 --imagebase 0x140000000

> 0x00007FF670F2822C push 0xFFFFFFFF890001FA

> 0x00007FF670F27FC9 push 0x45D3BF1F

> 0x00007FF670F248E4 push r13

> 0x00007FF670F24690 push rsi

> 0x00007FF670F24E53 push r14

> 0x00007FF670F274FB push rcx

> 0x00007FF670F2607C push rsp

> 0x00007FF670F24926 pushfq

> 0x00007FF670F24DC2 push rbp

> 0x00007FF670F25C8C push r12

> 0x00007FF670F252AC push r10

> 0x00007FF670F251A5 push r9

> 0x00007FF670F25189 push rdx

> 0x00007FF670F27D5F push r8

> 0x00007FF670F24505 push rdi

> 0x00007FF670F24745 push r11

> 0x00007FF670F2478B push rax

> 0x00007FF670F27A53 push rbx

> 0x00007FF670F2500D push r15

> 0x00007FF670F26030 push [0x00007FF670F27912]

> 0x00007FF670F2593A mov rax, 0x7FF530F20000

> 0x00007FF670F25955 mov r13, rax

> 0x00007FF670F25965 push rax

> 0x00007FF670F2596F mov esi, [rsp+0xA0]

> 0x00007FF670F25979 not esi

> 0x00007FF670F25985 neg esi

> 0x00007FF670F2598D ror esi, 0x1A

> 0x00007FF670F2599E mov rbp, rsp

> 0x00007FF670F259A8 sub rsp, 0x140

> 0x00007FF670F259B5 and rsp, 0xFFFFFFFFFFFFFFF0

> 0x00007FF670F259C1 mov rdi, rsp

> 0x00007FF670F259CB lea r12, [0x00007FF670F26473]

> 0x00007FF670F259DF mov rax, 0x100000000

> 0x00007FF670F259EC add rsi, rax

> 0x00007FF670F259F3 mov rbx, rsi

> 0x00007FF670F259FA add rsi, [rbp]

> 0x00007FF670F25A05 mov al, [rsi]

> 0x00007FF670F25A0A xor al, bl

> 0x00007FF670F25A11 neg al

> 0x00007FF670F25A19 rol al, 0x05

> 0x00007FF670F25A26 inc al

> 0x00007FF670F25A2F xor bl, al

> 0x00007FF670F25A34 movzx rax, al

> 0x00007FF670F25A41 mov rdx, [r12+rax*8]

> 0x00007FF670F25A49 xor rdx, 0x7F3D2149

> 0x00007FF670F25507 inc rsi

> 0x00007FF670F27951 add rdx, r13

> 0x00007FF670F27954 jmp rdx

> located vm handler table... at = 0x00007FF670F26473, rva = 0x0000000140006473

我们可以看到vmprofiler-cli已经对vm_entry代码进行了扁平化和去模糊化处理,并找到了vm handler表。我们还可以看到为解密vm handler实体所做的转换,它是在mov rdx, [r12+rax*8]之后的直接XOR。

1

2

> 0x00007FF670F25A41 mov rdx, [r12+rax*8]

> 0x00007FF670F25A49 xor rdx, 0x7F3D2149

我们还可以看到,由于RSI被INC指令递增,VIP的进展是积极的。

1

> 0x00007FF670F25507 inc rsi

有了这些信息,我们现在可以编译一个vmtracer程序,它将把所有的vm处理程序表条目修补到我们的陷阱处理程序中,这将使我们能够跟踪虚拟指令以及改变虚拟指令的结果。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

// lambdas to encrypt and decrypt vm handler entries

// you must extract this information from the flattened

// and deobfuscated view of vm_entry…

vm::decrypt_handler_t _decrypt_handler =

[](u64 val) -> u64

{

    return val ^ 0x7F3D2149;

};

vm::encrypt_handler_t _encrypt_handler =

[](u64 val) -> u64

{

    return val ^ 0x7F3D2149;

};

vm::handler::edit_entry_t _edit_entry =

[](u64* entry_ptr, u64 val) -> void

{

    DWORD old_prot;

    VirtualProtect(entry_ptr, sizeof val,

        PAGE_EXECUTE_READWRITE, &old_prot);

    *entry_ptr = val;

    VirtualProtect(entry_ptr, sizeof val,

        old_prot, &old_prot);

};

// create vm trace file header...

vmp2::file_header trace_header;

memcpy(&trace_header.magic, "VMP2", sizeof "VMP2" - 1);

trace_header.epoch_time = time(nullptr);

trace_header.entry_offset = sizeof trace_header;

trace_header.advancement = vmp2::exec_type_t::forward;

trace_header.version = vmp2::version_t::v1;

trace_header.module_base = module_base;

我省略了一些其他的代码,如ofstream代码和vmtracer类实例化,你可以在这里找到这些代码。显示这些信息的主要目的是告诉你如何解析一个vm_entry并提取创建跟踪所需的信息。

在我的演示追踪器中,我只是简单地将受保护的二进制文件LoadLibraryExA,初始化一个vmtracer类,修补vm处理程序表,然后调用模块的入口点。这不是很理想,但对于演示目的来说,这就足够了。

1

2

3

4

5

6

7

8

9

// patch vm handler table...

tracer.start();

// call entry point...

auto result = reinterpret_cast<int (*)()>(

    NT_HEADER(module_base)->OptionalHeader.AddressOfEntryPoint + module_base)();

// unpatch vm handler table...

tracer.stop();

现在已经创建了一个跟踪文件,我们现在可以通过vmprofiler-cli或vmprofiler-qt来检查跟踪。 不过我建议使用后者,因为这个程序是明确为查看跟踪文件而创建的。

当把跟踪文件加载到vmprofiler-qt中时,必须知道vm_entry RVA以及在PE文件的可选头中发现的镜像基础。考虑到所有这些信息以及原始的受保护二进制文件,vmprofiler-qt将显示跟踪文件中的所有虚拟指令,并允许你 "单步 "通过它。

让我们看一下这个跟踪文件,看看我们是否能找到原来的指令,这些指令现在已经被转换为基于RISC、堆栈机的架构。在vm_entry之后执行的第一个代码块似乎不包含与原始二进制文件有关的代码。它在这里只是为了混淆视听,防止对虚拟指令进行静态分析,因为要了解虚拟JMP指令的落点,需要对虚拟指令集进行仿真。这第一个跳转块位于每个受保护的二进制文件中。

在虚拟JMP指令之后的下一个块做了一些与堆栈有关的有趣的数学运算。如果你仔细观察,你可以看到正在执行的数学运算是:sub(x, y) = ~((~(x) & ~(x)) + y) & ~((~(x) & ~(x)) + y); sub(VSP, 10)

如果我们简化这个数学操作,我们可以看到这个操作是对VSP做的减法。sub(x, y) = ~((~x) + y)。这等同于本地操作sub rsp, 0x10。如果我们看一下原始的二进制,也就是没有被虚拟化的二进制,我们可以看到事实上有这个指令。

在虚拟指令中可以看到上面显示的MOV EAX, 1紧随在VSP上做的减法之后。MOV EAX, 1是通过一个LCONSTBSX和一个SREGDW完成的。SREG的位数与本地寄存器的宽度32bits相匹配,同时也与被加载的常数值相匹配。

接下来我们看到一个vmexit发生了。我们可以通过查看vmexit之前的最后一个ADDQ来了解代码执行在虚拟机之外的情况。堆栈上的前两个值应该是模块的基本地址和将被返回的例程的32位相对虚拟地址。在这个跟踪中,RVA是0x140008236。如果我们在IDA中检查这个地址,我们可以看到指令 "CPUID "在这里。

1

2

3

4

.vmp0:0000000140008236 0F A2                                         cpuid

.vmp0:0000000140008238 0F 81 88 FE FF FF                             jno     loc_1400080C6

.vmp0:000000014000823E 68 05 02 00 79                                push    79000205h

.vmp0:0000000140008243 E9 77 FD FF FF                                jmp     loc_140007FBF

正如你所看到的,在CPUID指令之后,代码执行直接回到了虚拟机中。在用位于虚拟堆栈上的本地寄存器值设置所有的虚拟scratch寄存器后,直接将一个常数加载到堆栈中,其值为0x1C。然后,CPUID的结果值被这个常数移到了右边。

AND操作是通过两个NAND操作完成的。第一个NAND操作简单地反转了SHR的结果;invert(x) = ~(x) & ~(x)。这是通过将DWORD值两次加载到堆栈中来完成的,以形成一个QWORD。

这个AND操作的结果将被设置到虚拟scratch寄存器7(SREGDW 0x38)。然后,它被移到scratch寄存器16中。如果我们看一下vmexit指令和LREGQ的执行顺序,我们可以看到这确实是正确的。

最后,我们还可以看到ADD指令和LVSP指令向VSP增加了一个值。这是预料之中的,因为在原始二进制中存在一个ADD RSP,0x10。

从上面的信息我们可以重建以下的本地指令。

1

2

3

4

5

6

7

8

sub rsp, 0x10

mov eax, 1

cpuid

shr ecx, 0x1C

and ecx, 1

mov eax, ecx ; from the LREGDW 0x38; SREGDW 0x80...

add rsp, 0x10

ret

正如你所看到的,有几条指令被遗漏了,特别是RBX的push's和pop's,以及XOR将ECX的内容清零。我认为这些指令没有直接转换为虚拟指令,而是以一种迂回的方式实现。

为了改变虚拟指令,必须首先重新实现整个vm处理程序。如果vm处理程序对第二个操作数进行解密,必须记住解密密钥有效性的重要性。因此,必须计算出原始的即时值,并通过原始转换应用到解密密钥上。然而,在更新解密密钥后,这个值可以随后被丢弃。这方面的一个例子可以是改变上节中SHR之前的LCONST的常数值。

这个虚拟指令有两个操作数,第一个是要执行的vm handler索引,第二个是即时值,在这个例子中是一个单字节。由于有两个操作数,所以在vm处理程序中会有五个转换。

我们可以重新编码这个vm处理程序,将解密后的即时值与0x1C进行比较,然后分支到一个子程序,将一个不同的值加载到堆栈。这将导致SHR计算一个不同的结果。本质上我们可以欺骗CPUID的结果。另一种方法是重新创建SHR处理程序,然而为了简单起见,我只是要转移到一个被设置的位。在这种情况下,如果支持VMX,CPUID之后的ECX第5位将被设置,由于我的CPU支持虚拟化,这个位将是高的。下面是新的vm处理程序。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

.data

    __mbase dq 0h

    public __mbase

.code

__lconstbzx proc

    mov al, [rsi]

    lea rsi, [rsi+1]

    xor al, bl

    dec al

    ror al, 1

    neg al

    xor bl, al

    pushfq            ; save flags...

    cmp ax, 01Ch

    je swap_val

                    ; the constant is not 0x1C

    popfq            ; restore flags...    

    sub rbp, 2

    mov [rbp], ax

    mov rax, __mbase

    add rax, 059FEh    ; calc jmp rva is 0x59FE...

    jmp rax

swap_val:            ; the constant is 0x1C

    popfq            ; restore flags...

    mov ax, 5        ; bit 5 is VMX in ECX after CPUID...

    sub rbp, 2

    mov [rbp], ax

    mov rax, __mbase

    add rax, 059FEh    ; calc jmp rva is 0x59FE...

    jmp rax

__lconstbzx endp

end

如果我们现在再次运行vm tracer,将这个新的vm处理程序设置为索引0x55,我们应该能够看到LCONSTBZX的变化。为了方便这个钩子,我们必须把新的vm处理程序的虚拟地址设置为vm::handler::table_t对象。

1

2

3

4

// change vm handler 0x55 (LCONSTBZX) to our implimentation of it…

auto _meta_data = handler_table.get_meta_data(0x55);

_meta_data.virt = reinterpret_cast<u64>(&__lconstbzx);

handler_table.set_meta_data(0x55, _meta_data);

如果我们现在运行二进制,它将返回1。你可以在下面看到这个。

由于VMProtect 2生成的虚拟机执行的是以其自身字节码编码的虚拟指令,因此,如果能够对其进行编码,就可以在虚拟机上运行自己的虚拟指令。编码的虚拟指令也必须在4GB的地址空间范围内,因为虚拟指令的RVA是32bits宽。在本节中,我将编码一组非常简单的虚拟指令,将两个QWORD值相加并返回结果。

首先,对虚拟指令进行编码需要在二进制文件中加入上述虚拟指令的vm处理程序。通过'vmprofiler'来定位这些vm处理程序。vm处理程序的索引是第一个操作码,即时值(如果有的话)是第二个。结合这两组操作数将产生一个编码的虚拟指令。这是组装虚拟指令的第一阶段,第二阶段是对操作数进行加密。

一旦我们有了编码的虚拟指令,我们现在就可以使用vm handler变换的逆操作以及calc_jmp的逆操作来加密它们。值得注意的是,在加密时必须考虑到VIP的推进方式,因为操作数和虚拟指令的顺序取决于这个推进方向。

为了执行这些新装配的虚拟指令,必须把虚拟指令放在vm_entry例程的32位地址范围内,然后把这些虚拟指令的加密rva放到堆栈中,最后再调用到vm_entry。我建议使用VirtualAllocEx在受保护模块的正下方分配一个RW页面。下面显示了一个运行虚拟指令的例子。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

SIZE_T bytes_copied;

STARTUPINFOA info = { sizeof info };

PROCESS_INFORMATION proc_info;

// start the protected binary suspended...

// keep in mind this binary is not packed...

CreateProcessA("vmptest.vmp.exe", nullptr, nullptr,

    nullptr, false,

    CREATE_SUSPENDED | CREATE_NEW_CONSOLE,

    nullptr, nullptr, &info, &proc_info);

// wait for the system to finish setting up...

WaitForInputIdle(proc_info.hProcess, INFINITE);

auto module_base = get_process_base(proc_info.hProcess);

// allocate space for the virtual instructions below the module...

auto virt_instrs = VirtualAllocEx(proc_info.hProcess,

    module_base + vmasm->header->offset,

    vmasm->header->size,

    MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);

// write the virtual instructions...

WriteProcessMemory(proc_info.hProcess, virt_instrs,

    vmasm->data, vmasm->header->size, &bytes_copied);

// create a thread to run the virtual instructions...

auto thandle = CreateRemoteThread(proc_info.hProcess,

    nullptr, 0u,

    module_base + vm_entry_rva,

    nullptr, CREATE_SUSPENDED, &tid);

CONTEXT thread_ctx;

GetThreadContext(thandle, &thread_ctx);

// sub rsp, 8...

thread_ctx.Rsp -= 8;

thread_ctx.Rip = module_base + vm_entry_rva;

// write encrypted rva onto the stack...

WriteProcessMemory(proc_info.hProcess, thread_ctx.Rsp,

    &vmasm->header->encrypted_rva,

    sizeof vmasm->header->encrypted_rva, &bytes_copied);

// update thread context and resume execution...

SetThreadContext(thandle, &thread_ctx);

ResumeThread(thandle);

最后,我的动态分析方案不是最理想的方案,但是它应该允许对受保护的二进制文件进行基本的反向工程。随着时间的推移,对虚拟指令的静态分析将成为可能,然而目前动态分析将不得不做。在未来,我将使用unicorn来模拟虚拟机的处理程序。

尽管我已经记录了少数的虚拟指令,但还有更多的指令没有被记录。我记录这些虚拟指令的目的是让本文的读者获得一种感觉,即虚拟机处理程序应该是什么样子,以及如何改变这些虚拟机处理程序的结果。本文中记录的虚拟指令也是最常见的。这些虚拟指令很可能会出现在每个虚拟机中。

我在资源库中添加了一些参考构建,供你尝试通过改变虚拟机处理程序使它们返回1。还有一个构建是在一个二进制文件中使用多个虚拟机。

最后,我想重申,这项研究肯定已经由私人实体完成,我不是第一个记录这篇文章中讨论的一些虚拟机架构的人。我已经记下了那些我已经研究过的人的名字,但是可能还有很多人已经做了关于VMProtect 2的研究,我没有列出,只是因为我没有遇到过他们的工作。

[2022冬季班]《安卓高级研修班(网课)》月薪两万班招生中~

最后于 2022-12-7 14:30 被TUGOhost编辑 ,原因:


文章来源: https://bbs.pediy.com/thread-275444.htm
如有侵权请联系:admin#unsafe.sh