吉沃运营专员 发表于 2024-6-7 15:21:59

VMProtect 2 虚拟机架构细节

https://blog.back.engineering/17/05/2021/
一、序言
在深入探讨之前,先声明一些有关现有 VMProtect 2 工作、本文的目的以及意图,因为这些内容有时会被误解和扭曲。

1.1 目的
尽管已经对 VMProtect 2 进行了大量研究,但我觉得仍然有一些信息没有公开讨论,也没有向公众公开足够的源代码。在本文中披露的信息目的在于超越通用架构分析,但要低得多。在已有的 VMProtect 文件情况下,可以对自己的虚拟机指令进行编码,并轻松拦截和更改虚拟指令的结果。本文讨论的动态分析基于 Samuel Chevet 的研究工作,我的动态分析研究和 vmtracer 项目只是他在 "Inside VMProtect" 演讲中演示的工作扩展。

1.2 意图
本篇文章并没有意针对 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

本文中使用的术语 transform 特指为解密虚拟指令和虚拟机处理程序表项的操作数而完成的操作。这些转换包括 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 的例程。

三、混淆
提示:原文为 Opaque Branching,Opaque 作为形容词意为不透明的,不清晰的,在这个人理解为晦涩的,难懂的,不明更接近

VMProtect 2 大多数情况下使用两种类型的混淆,第一种是 DeadStore,第二种是不明分支。在整个混淆例程中可以看到一些指令后面跟着一个 JCC,然后是另一组指令后面跟着另一个 JCC。不明分支的另一个作用是影响标志寄存器的随机指令,随处都可看到这些东西,它们主要是位测试指令、无用比较以及设置、清除标志指令。

3.1 不明分支示例
在这个不明的分支混淆示例中,我将介绍不明的分支表现形式还有其他,例如 rflags 的状态,以及最重要的是如何确定正在查看的是不明分支还是合法 JCC。

.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,
考虑上面的混淆代码,注意 JNO 分支,如果在 ida 中遵循此分支并将指令与 JNO 之后的指令进行比较,会发现该分支毫无用处,因为两条路径都执行相同的有意义的指令。

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,
.vmp0:0000000140004458 D2 F2                  sal   dl, cl
.vmp0:000000014000445A D2 C2                  rol   dl, cl
.vmp0:000000014000445C 8B 14 38               mov   edx,
如果仔细观察,会发现两个分支中都有一些指令,确定哪些代码是 DeadStore 以及需要哪些代码可能很困难,但是如果在 ida 中选择一个寄存器并查看它在你正在查看的指令之前写入的所有位置,可以删除所有其他代码写入指令直到读取所述寄存器。现在,回到示例,在这种情况下,以下说明很重要:

.vmp0:0000000140004448 28 C3                  sub   bl, al
.vmp0:000000014000445C 8B 14 38               mov   edx,
这些不明分支的生成使得存在重复指令。对于每个代码路径,还存在更多的 DeadStore 混淆以及不明分支和影响 RFLAGS 的其他指令。



3.2 DeadStore 混淆示例
除了不明的位测试和比较之外,VMProtect 2 deadstore 混淆还会向指令流添加最多的垃圾指令。这些说明没有任何作用,可以轻松地发现和移除,考虑以下:

.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               setnlecl
.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,
.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,
.vmp0:0000000140007AF1 41 0F CD               bswap   r13d
.vmp0:0000000140007AF4 41 5D                  pop   r13
.vmp0:0000000140007AF6 C3                     retn
让我们从顶部开始,一次一条指令。0x140004149 处的第一条指令是 RCL (循环左进位),该指令影响 FLAGS 寄存器和 DI。让我们看看下次 DI 被引用的时间。是读还是写?对 DI 的下一个引用是 0x140004158 处的 NOT 指令。NOT 读写 DI,到目前为止两条指令都有效。下一条引用 DI 的指令是 POP 指令。这一点至关重要,因为在此 POP 之前对 RDI 的所有写入都可以从指令流中删除。

.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
下一条指令是在 0x14000414C 处的 POP RAX,RAX 在整个指令流中也从未被写入,只能从中读取。由于它具有读取依赖性,因此无法删除该指令。转到下一条指令,SHLD (双精度左移),写入依赖于 R11,读取依赖于 BX。引用 R11 的下一条指令是 0x140004153 处的 POP R11,可当作为 DeadStore 将其删除 (SHLD 指令)。

.vmp0:000000014000414C 58                     pop   rax
.vmp0:0000000140004153 41 5B                  pop   r11
.vmp0:0000000140004155 80 E6 CA               and   dh, 0CAh
.vmp0:000000014000415B 5F                     pop   rdi
现在只需对每条指令重复该过程即可,最终结果应该是这样的:

.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。这种删除 DeadStore 的方法也仅适用于 vm_entry 和 vm handlers。这不能按原样应用于一般混淆的例程。再次强调,此方法不适用于任何混淆的例程,它是专门为 vm_entry 和 vm 处理程序定制的,因为这些例程中没有合法的 JCC。

四、VMProtect 2 虚拟机概述
虚拟指令由称为 vm 处理程序的虚拟指令处理程序解密和解释,虚拟机是一个基于 RISC 的堆栈机器,带有暂存寄存器。在 vm-entries 之前,虚拟指令的加密 RVA (相对虚拟地址) 被推送到堆栈上,并且所有通用寄存器和标志都被推送到堆栈上。VIP 被解密、计算并加载到 RSI 中,然后在 RBX 中启动滚动解密密钥,并用于解密每个虚拟指令的每个操作数,滚动解密密钥通过用解密的操作数值进行转换来更新。



4.1 滚动解密
VMProtect 2 使用滚动解密密钥,该密钥用于解密虚拟指令操作数,可以防止任何类型的挂钩,就好像任何虚拟指令乱序执行一样,滚动解密密钥将变得无效,导致虚拟操作数的进一步解密无效。

4.2 使用原生寄存器
在虚拟机内部执行期间,一些原生寄存器专用于虚拟机机制,例如虚拟指令指针和虚拟堆栈。在本节中,我将讨论这些原生寄存器及其在虚拟机中的用途。

4.2.1 特定用途的寄存器
首先,RSI 始终用于虚拟指令指针,操作数是从 RSI 中存储的地址中获取的,加载到 RSI 中的初始值是由 vm_entry 完成。

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

R12 加载了 vm 处理程序表的线性虚拟地址,这是在 vm_entry 内部完成,并且在虚拟机 R12 内部的整个执行期间都将包含该地址。

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

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

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



4.3 vm_entry
vm_entry 是非常重要的部分,在进入 VM 之前,加密的 RVA 会被 push 到堆栈上,RVA 是一个四字节值。

.vmp0:000000014000822C 68 FA 01 00 89         push    0FFFFFFFF890001FAh
将该值压入堆栈后,开始执行 jmp,跳转到 vm_entry,vm_entry 混淆了,我在上面详细解释了这一点。通过展平然后删除 DeadStore 代码,可以获得 vm_entry 的清晰视图。

> 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
> 0x593a :                                    mov rax, 0x7FF634270000
> 0x5955 :                                    mov r13, rax
> 0x5965 :                                    push rax
> 0x596f :                                    mov esi,
> 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,
> 0x59df :                                    mov rax, 0x100000000
> 0x59ec :                                    add rsi, rax
> 0x59f3 :                                    mov rbx, rsi
> 0x59fa :                                    add rsi,
> 0x5a05 :                                    mov al,
> 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,
> 0x5a49 :                                    xor rdx, 0x7F3D2149
> 0x5507 :                                    inc rsi
> 0x7951 :                                    add rdx, r13
> 0x7954 :                                    jmp rdx
正如预期的那样,所有寄存器以及 RFLAGS 都被 push 到堆栈。最后一次 push 将八个字节的 0 放入堆栈中,而不是我最初预期的重定位。这些 push 发生的顺序在每个构建中都是唯一的,但是最后一次 push 的 8 个 0 在所有二进制文件中始终相同。这是一个非常稳定的签名,用于确定一般寄存器 push 结束的时间,以下是我在本段中提到的确切说明顺序。

> 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 ; pushes 0’s
将所有寄存器和 RFLAGS 压入堆栈后,模块的基地址将加载到 R13 中。这种情况发生在每个二进制文件中,R13 在 VM 执行期间始终包含模块的基地址,模块的基地址也被压入堆栈。

> 0x593a :                                    mov rax, 0x7FF634270000
> 0x5955 :                                    mov r13, rax
> 0x5965 :                                    push rax
接下来,解密所需执行的虚拟指令的相对虚拟地址。这是通过将 32 位 RVA 从 RSP + 0xA0 加载到 ESI 中来完成的。这是一个非常重要的标记,很容易找到。然后将三个变换应用于 ESI,以获得虚拟指令的解密 RVA,这三个转换对于每个二进制文件都是唯一的,然而,总是存在三个转变。

> 0x596f :                                    mov esi,
> 0x5979 :                                    not esi
> 0x5985 :                                    neg esi
> 0x598d :                                    ror esi, 0x1A
此外,发生的下一个值得注意的操作是在堆栈上为临时寄存器分配的空间。RSP 始终移至 RBP,然后 RSP 减去 0x140,然后按 16 字节对齐。完成此操作后,地址将移至 RDI,在 VM 执行期间,RDI 始终包含指向暂存寄存器的指针。

> 0x599e :                                    mov rbp, rsp
> 0x59a8 :                                    sub rsp, 0x140
> 0x59b5 :                                    and rsp, 0xFFFFFFFFFFFFFFF0
> 0x59c1 :                                    mov rdi, rsp
下一个值得注意的操作是将 vm 处理程序表的地址加载到 R12 中,这是在每个 VMProtect 2 二进制文件上完成的,R12 始终包含 vm 处理程序表的线性虚拟地址。这是另一个重要的标记,可用于非常简单地查找 vm 处理程序表的位置。

> 0x59cb :                                    lea r12,
然后对 RSI 进行另一个操作来计算 VIP,在 PE 标头内部,有一个称为可选标头的标头。这包含各种各样的信息。其中一个字段称为 ImageBase。如果该字段中有任何高于 32 的位,则这些位将添加到 RSI。例如,vmptest.vmp.exe ImageBase 字段包含值 0x140000000,因此,0x100000000 被添加到 RSI 作为计算的一部分。如果 ImageBase 字段包含小于 32 位的值,则 RSI 中将添加 0。

> 0x59df :                                    mov rax, 0x100000000
> 0x59ec :                                    add rsi, rax
在对 RSI 进行此加法之后,将执行一条小且有些无关紧要的指令。该指令将虚拟指令的线性虚拟地址加载到 RBX 中。现在,RBX 有一个非常特殊的用途,它包含滚动解密密钥。正如你所看到的,加载到 RBX 中的第一个值将是虚拟指令本身的地址,不是线性虚拟地址,而是包括 ImageBase 字段的前 32 位的 RVA。

> 0x59f3 :                                    mov rbx, rsi
接下来,将 vmp 模块的基地址添加到 RSI 中,计算虚拟指令的完整线性虚拟地址。请记住,RBP 包含分配暂存空间之前的 RSP 地址,此时模块的基地址位于堆栈顶部。

> 0x59fa :                                    add rsi,
vm_entry 的细节到此结束,该例程的下一部分实际上称为 calc_vm_handler,并且在除 vm_exit 指令之外的每个虚拟指令之后执行。

4.4 vm_jmp - Vm Handler 索引解密
calc_jmp 是 vm_entry 例程的一部分,但不仅仅是 vm_entry 例程引用它。每个虚拟机处理程序最终都会跳转到 calc_jmp (除了 vm_exit 之外)。这段代码负责解密每个虚拟指令的操作码以及对 vm 处理程序表进行索引、解密 vm 处理程序表项并跳转到生成的 vm 处理程序。

> 0x5a05 :                                    mov al,
> 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,
> 0x5a49 :                                    xor rdx, 0x7F3D2149
> 0x5507 :                                    inc rsi
> 0x7951 :                                    add rdx, r13
> 0x7954 :                                    jmp rdx
这段代码的第一条指令从 RSI 中读取一个字节,这是 VIP,该字节是加密的操作码,换句话说,它是虚拟机处理程序表的加密索引,总共完成了 5 次转换,第一个转换始终应用于加密的操作码和 RBX 中的值作为源,这就是滚动加密的作用。需要注意的是,加载到 RBX 中的第一个值是虚拟指令的 RVA。因此 BL 将包含该 RVA 的最后一个字节。

> 0x5a05 :                                    mov al,
> 0x5a2f :                                    xor bl, al ; transformation is unique to each build
接下来,将三个变换直接应用于 AL。这些转换可以具有立即值,但是永远不会将其他寄存器的值添加到这些转换中。

> 0x5a11 :                                    neg al
> 0x5a19 :                                    rol al, 0x05
> 0x5a26 :                                    inc al
最后一个转换应用于存储在 RBX 中的滚动加密密钥,此变换与第一个变换相同,然而寄存器交换位置,最终结果是解密后的虚拟机处理程序索引,然后 AL 的值为零扩展到 RAX 的其余部分。

> 0x5a2f :                                    xor bl, al
> 0x5a34 :                                    movzx rax, al
既然 VM Handler 表的索引已被解密,则必须获取并解密 VM Handler 项本身,仅对这些虚拟机处理程序表条目应用了一个转换,这些转换中从未使用过任何寄存器值,加密的 vm 表项值加载到的寄存器始终是 RCX 或 RDX。

> 0x5a41 :                                    mov rdx,
> 0x5a49 :                                    xor rdx, 0x7F3D2149
VIP现已进阶,VIP 可以向前或向后前进,并且前进操作本身可以是 LEA、INC、DEC、ADD 或 SUB 指令。

> 0x5507 :                                    inc rsi
最后,将模块的基地址添加到解密的虚拟机处理程序 RVA 中,然后执行 JMP 以开始执行该虚拟机处理程序例程。同样,RDX 或 RCX 始终用于此 ADD 和 JMP,这是虚拟机中的另一个重要标记。

> 0x7951 :                                    add rdx, r13
> 0x7954 :                                    jmp rdx
4.5 vm_exit
与 vm_entry 不同,vm_exit 是一个非常简单的例程。该例程只是将所有寄存器 (包括 RFLAGS) POP 放回原位。有一些冗余的 POP 用于清除堆栈中的模块基础、填充以及 RSP,因为它们并不需要。POP 操作发生的顺序与 vm_entry 将其压入堆栈的顺序相反,返回地址在 vm_exit 例程之前计算并加载到堆栈中。

.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
4.6 check_vsp
将任何新值放入堆栈的 VM 处理程序将在 vm 处理程序执行后进行堆栈检查,该例程检查堆栈是否正在侵占暂存寄存器。

.vmp0:00000001400044AA 48 8D 87 E0 00 00 00       lea   rax,
.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,
.vmp0:0000000140005E75 48 29 D1                   sub   rcx, rdx
.vmp0:000000014000464C 48 8D 45 80                lea   rax,
.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,
.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
4.7 虚拟指令 - 操作码、操作数、规范
虚拟指令由两个或多个操作数组成,第一个操作数是虚拟指令的操作码。操作码是 8 位无符号值,解密后是 vm 处理程序表的索引。可以有第二个操作数,它是 1 到 8 字节的立即值。



所有操作数均已加密,必须使用滚动解密密钥进行解密。解密是在 calc_jmp 以及 vm 处理程序本身内部完成的。进行解密的 VM 处理程序将仅对立即值进行操作,而不是对操作码进行操作。

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

.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 例程的一部分,但任何第二个操作数的转换格式都是相同的。



4.8 VM Handles
VM Handlers 包含执行虚拟指令的原生代码,每个 VMProtect 2 二进制文件都有一个 vm handler 表,它是 256 个 QWORD 的数组。每项都包含相应 VM Handler 的加密相对虚拟地址。虚拟指令有许多变种,例如不同大小的立即值以及符号和零扩展值。本节将介绍一些虚拟指令示例以及尝试解析 VM 处理程序时必须注意的一些关键信息。



处理立即值的 VM Handler 从 RSI 获取加密的立即值,然后将传统的五种转换应用于该加密的立即值,转换格式与 calc_jmp 转换相同。第一个转换应用于加密的立即值,滚动解密密钥是操作的源。然后将三个转换直接应用于加密的立即值,这将完全解密该值。最后,通过执行第一次转换来更新滚动解密密钥,除了交换目标和源操作数之外。

.vmp0:00000001400076D2 48 8B 06               mov   rax, ; 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 会受到不明分支和 DeadStore 混淆的影响。

4.8.1 LCONST
最具标志性的虚拟机指令之一是 LCONST,该虚拟指令将第二操作数的常量值加载到堆栈上。

4.8.1.1 LCONSTQ
这是 LCONSTQ VM 处理程序的反混淆视图。正如您所看到的,该 VM 处理程序从 VIP (RSI) 中读取虚拟指令的第二个操作数。然后它会解密该即时值并提升 VIP。然后将解密后的立即值放入 VSP 中。

mov   rax,
xor   rax, rbx ; transformation
bswap   rax ; transformation
lea   rsi, ; advance VIP…
rol   rax, 0Ch ; transformation
inc   rax ; transformation
xor   rbx, rax ; transformation (update rolling decrypt key)
sub   rbp, 8
mov   , rax
4.8.1.2 LCONSTCDQE
该虚拟指令从 RSI 加载 DWORD 大小的操作数,对其进行解密,并将其扩展为 QWORD,最后将其放入虚拟堆栈中。

mov   eax,
xor   eax, ebx
xor   eax, 32B63802h
dec   eax
lea   rsi, ; advance VIP
xor   eax, 7E4087EEh

; look below for details on this...
push    rbx
xor   , eax
pop   rbx

cdqe ; sign extend EAX to RAX…
sub   rbp, 8
mov   , rax
请注意,最后一个 VM Handler 通过将值放入堆栈然后应用转换来更新滚动解密密钥。在解析这些虚拟机处理程序时,这可能会导致严重问题。幸运的是,有一个非常简单的技巧可以处理这个问题,请始终记住应用于滚动 key 的转换与第一个转换相同。在上面的例子中,它是一个简单的异或。

4.8.1.3 LCONSTCBW
LCONSTCBW 从 RSI 加载常量字节值,对其进行解密,并将结果零扩展为 WORD 值。然后将该解密值放置在虚拟堆栈上。

movzx eax, byte ptr
add al, bl
inc al
neg al
ror al, 0x06
add bl, al
mov ax,
sub rbp, 0x02
inc rsi
mov , ax
4.8.1.4 LCONSTCWDE
LCONSTCWDE 从 RSI 加载常量字,对其进行解密,并将其符号扩展为 DWORD。最后将结果值放置在虚拟堆栈上。

mov ax,
add rsi, 0x02
xor ax, bx
rol ax, 0x0E
xor ax, 0xA808
neg ax
xor bx, ax
cwde
sub rbp, 0x04
mov , eax
4.8.1.5 LCONSTDW
LCONSTDW 从 RSI 加载一个常量双字,对其进行解密,最后将结果放置在虚拟堆栈上。另请注意,VIP 在下面的示例中向后前进。可以在操作数获取中看到这一点,因为它在取消引用之前从 RSI 中减去。

mov eax,
bswap eax
add eax, ebx
dec eax
neg eax
xor eax, 0x2FFD187C
push rbx
add , eax
pop rbx
sub rbp, 0x04
mov , eax
add rsi, 0xFFFFFFFFFFFFFFFC
4.8.2 LREG
让我们看一下另一个 VM Handler,这个 Handler 的名称为 LREG,就像 LCONST 一样,该指令有许多变种,特别是针对不同的大小。LREG 也将出现在每个二进制文件中,因为它在虚拟机内部用于将寄存器值加载到暂存寄存器中。稍后会详细介绍这一点。

4.8.2.1 LREGQ
LREGQ 有一个 1 字节立即值,这是暂存寄存器索引,指向暂存寄存器的指针始终加载到 RDI 中。如上所述,总共有五种变换应用于立即值来解密它。第一个转换是根据滚动解密密钥应用的,然后是直接应用于立即值的三个转换,从而将其完全解密。最后,通过以解密的立即值作为源对其应用第一次转换来更新滚动解密密钥。

mov   al,
sub   al, bl
ror   al, 2
not   al
inc   al
sub   bl, al
mov   rdx,
sub   rbp, 8
mov   , rdx
inc   rsi
4.8.2.2 LREGDW
LREGDW 是 LREG 的变种,它将 DWORD 从临时寄存器加载到堆栈上。它有两个操作数,第二个操作数是表示暂存寄存器索引的单个字节。下面的代码片段是 LREGDW 的反混淆。

mov   al,
sub   al, bl
add   al, 97h
ror   al, 1
neg   al
sub   bl, al
mov   edx,
sub   rbp, 4
mov   , edx
4.8.3 SREG
每个二进制文件中的另一个标志性虚拟指令是 SREG,该指令有许多变种,可将暂存寄存器设置为特定大小的值。该虚拟指令有两个操作数,第二个操作数是包含暂存寄存器索引的单字节立即值。

4.8.3.1 SREGQ
SREGQ 使用虚拟堆栈顶部的 QWORD 值设置虚拟暂存寄存器。该虚拟指令由两个操作数组成,第二个操作数是表示虚拟暂存寄存器的单个字节。

movzx   eax, byte ptr
sub   al, bl
ror   al, 2
not   al
inc   al
sub   bl, al
mov   rdx,
add   rbp, 8
mov   , rdx
4.8.3.2 SREGDW
SREGDW 使用虚拟堆栈顶部的 DWORD 值设置虚拟暂存寄存器。该虚拟指令由两个操作数组成,第二个操作数是表示虚拟暂存寄存器的单个字节。

movzx eax, byte ptr
xor al, bl
inc al
ror al, 0x02
add al, 0xDE
xor bl, al
lea rsi,
mov dx,
add rbp, 0x02
mov , dx
4.8.3.3 SREGW
SREGW 使用虚拟堆栈顶部的 WORD 值设置虚拟暂存寄存器。该虚拟指令由两个操作数组成,第二个操作数是表示虚拟暂存寄存器的单个字节。

movzx eax, byte ptr
sub al, bl
ror al, 0x06
neg al
rol al, 0x02
sub bl, al
mov edx,
add rbp, 0x04
dec rsi
mov , edx
4.8.3.4 SREGB
SREGB 使用虚拟堆栈顶部的字节值设置虚拟暂存寄存器。该虚拟指令由两个操作数组成,第二个操作数是表示虚拟暂存寄存器的单个字节。

mov al,
xor al, bl
not al
xor al, 0x10
neg al
xor bl, al
sub rsi, 0x01
mov dx,
add rbp, 0x02
mov , dl
4.8.4 ADD
虚拟 ADD 指令将堆栈上的两个值相加,并将结果存储在堆栈上的第二个值位置。然后,当 ADD 指令更改 RFLAGS 时,RFLAGS 被压入堆栈。

4.8.4.1 ADDQ
ADDQ 将存储在虚拟堆栈顶部的两个 QWORD 值相加。当本机 ADD 指令更改标志时,RFLAGS 也会被推入堆栈。

mov   rax,
add   , rax
pushfq
pop   qword ptr
4.8.4.2 ADDW
ADDW 将存储在虚拟堆栈顶部的两个 WORD 值相加。当原生 ADD 指令更改标志时,RFLAGS 也会被推入堆栈。

mov ax,
sub rbp, 0x06
add , ax
pushfq
pop
4.8.4.3 ADDB
ADDB 添加存储在虚拟堆栈顶部的两个 BYTE 值。当原生 ADD 指令更改标志时,RFLAGS 也会被推入堆栈。

mov al,
sub rbp, 0x06
add , al
pushfq
pop
4.8.5 MUL
虚拟 MUL 指令将堆栈上存储的两个值相乘,这些虚拟机处理程序使用本机 MUL 指令,另外 RFLAGS 被推送到堆栈上。最后,它是单操作数指令,这意味着没有与该指令关联的立即值。

4.8.5.1 MULQ
MULQ 将两个 QWORD 值相乘,结果存储在堆栈的 VSP+24 处,另外 RFLAGS 也被压入堆栈。

mov rax,
sub rbp, 0x08
mul rdx
mov , rdx
mov , rax
pushfq
pop
4.8.6 DIV
虚拟 DIV 指令使用原生 DIV 指令,除法中使用的顶部操作数位于虚拟堆栈的顶部。这是单操作数虚拟指令,因此没有立即值。 RFLAGS 也被压入堆栈,因为原生 DIV 指令也可以 RFLAGS。

4.8.6.1 DIVQ
DIVQ 将位于虚拟堆栈上的两个 QWORD 值相除。将 RFLAGS 压入堆栈。

mov rdx,
mov rax,
div
mov , rdx
mov , rax
pushfq
pop
4.8.7 READ
READ 指令读取不同大小的内存,该指令有一个变种,可以读取 1 个、2 个、4 个和 8 个字节。

4.8.7.1 READQ
READQ 从存储在堆栈顶部的地址读取 QWORD 值。该虚拟指令似乎有时会在其前面添加一个段。然而,并非所有 READQ 都具有与其关联的 ss。QWORD 值现在存储在虚拟堆栈的顶部。

mov rax,
mov rax, ss:
mov , rax
4.8.7.2 READDW
READDW 从存储在虚拟堆栈顶部的地址读取 DWORD 值,然后将 DWORD 值放在虚拟堆栈的顶部。下面是 READDW 的两个示例,一个使用此段索引语法,另一个不使用此语法。

mov rax,
add rbp, 0x04
mov eax,
mov , eax
请注意下面的段偏移量使用 ss

mov rax,
add rbp, 0x04
mov eax, ss:
mov , eax
4.8.7.3 READW
READW 从存储在虚拟堆栈顶部的地址读取一个 WORD 值。然后将 WORD 值放在虚拟堆栈的顶部。下面是使用段索引语法 VM Handler 的示例,但请记住,有些 VM Handler 没有此段索引。

mov rax,
add rbp, 0x06
mov ax, ss:
mov , ax
4.8.8 WRITE
WRITE 虚拟指令最多可将八个字节写入一个地址,该虚拟指令有四种变种,一种对应于 2 至 8 的每一次幂。每个 VM Handler 也有使用段偏移类型指令编码的版本。然而,在长模式下,某些段基地址为 0。似乎总是使用的段是 SS 段,它的基数为 0,因此段基数在这里不起作用,它只是使解析这些 VM Handler 变得更加困难。

4.8.8.1 WRITEQ
WRITEQ 将 QWORD 值写入位于虚拟堆栈顶部的地址,堆栈增加 16 个字节。

.vmp0:0000000140005A74 48 8B 45 00            mov   rax,
.vmp0:0000000140005A82 48 8B 55 08            mov   rdx,
.vmp0:0000000140005A8A 48 83 C5 10            add   rbp, 10h
.vmp0:00000001400075CF 48 89 10               mov   , rdx
4.8.8.2 WRITEDW
WRITEDW 将 DWORD 值写入位于虚拟堆栈顶部的地址,堆栈增加 12 个字节。

mov rax,
mov edx,
add rbp, 0x0C
mov , edx
请注意下面的段偏移 ss 用法

mov rax,
mov edx,
add rbp, 0x0C
mov ss:, edx ; note the SS usage here...
4.8.8.3 WRITEW
WRITEW 虚拟指令将 WORD 值写入位于虚拟堆栈顶部的地址。然后堆栈增加 10 个字节。

mov rax,
mov dx,
add rbp, 0x0A
mov ss:, dx
4.8.8.4 WRITEB
WRITEB 虚拟指令将 BYTE 值写入位于虚拟堆栈顶部的地址,然后堆栈增加十个字节。

4.8.9 SHL
SHL VM Handler 将位于堆栈顶部的值向左移动多个位,要移位的位数存储在堆栈上要移位的值之上,然后将结果以及 RFLAGS 放入堆栈中。

4.8.9.1 SHLCBW
SHLCBW 将字节值左移并将结果零扩展为 WORD,RFLAGS 被压入堆栈。

mov   al,
mov   cl,
sub   rbp, 6
shl   al, cl
mov   , ax
pushfq
pop   qword ptr
4.8.9.2 SHLDW
SHLDW 将 DWORD 左移,RFLAGS 被推送到虚拟堆栈上。

mov eax,
mov cl,
sub rbp, 0x06
shl eax, cl
mov , eax
pushfq
pop
4.8.9.3 SHLQ
SHLQ 将 QWORD 左移,RFLAGS 被推送到虚拟堆栈上。

mov rax,
mov cl,
sub rbp, 0x06
shl rax, cl
mov , rax
pushfq
pop
4.8.10 SHLD
SHLD 虚拟指令使用本机指令 SHLD 将值左移。然后将结果以及 RFLAGS 放入堆栈中。该指令有一个变种,用于 1、2、4 和 8 字节移位。

4.8.10.1 SHLDQ
SHLDQ 以双精度将 QWORD 左移,然后将结果放入虚拟堆栈,并将 RFLAGS 压入虚拟堆栈。

mov rax,
mov rdx,
mov cl,
add rbp, 0x02
shld rax, rdx, cl
mov , rax
pushfq
pop
4.8.10.2 SHLDDW
SHLDDW 虚拟指令以双精度将 DWORD 值左移,结果被推送到虚拟堆栈以及 RFLAGS 上。

mov eax,
mov edx,
mov cl,
sub rbp, 0x02
shld eax, edx, cl
mov , eax
pushfq
pop
4.8.11 SHR
SHR 指令是 SHL 的补充,该虚拟指令会更改 RFLAGS,因此执行该虚拟指令后 RFLAGS 值将位于堆栈顶部。

4.8.11.1 SHRQ
SHRQ 将 QWORD 值右移,结果被放入虚拟堆栈以及 RFLAGS 中。

mov rax,
mov cl,
sub rbp, 0x06
shr rax, cl
mov , rax
pushfq
pop
4.8.12 SHRD
SHRD 虚拟指令以双精度将值右移,该指令有一个变种,用于 1、2、4 和 8 字节移位。虚拟指令以 RFLAGS 被推入虚拟堆栈而结束。

4.8.12.1 SHRDQ
SHRDQ 以双精度将 QWORD 值右移,结果被放入虚拟堆栈中,然后 RFLAGS 被推送到虚拟堆栈上。

mov rax,
mov rdx,
mov cl,
add rbp, 0x02
shrd rax, rdx, cl
mov , rax
pushfq
pop
4.8.12.2 SHRDDW
SHRDDW 将 DWORD 值以双精度右移,结果被放入虚拟堆栈中,然后 RFLAGS 被推送到虚拟堆栈上。

mov eax,
mov edx,
mov cl,
sub rbp, 0x02
shrd eax, edx, cl
mov , eax
pushfq
pop
4.8.13 NAND
NAND 指令包括不应用于堆栈顶部的值,然后将不按位的结果应用于堆栈上的下一个值,and 指令改变 RFLAGS,因此 RFLAGS 将被推送到虚拟堆栈上。

4.8.13.1 NANDW
NANDW 将两个 WORD 值按位 AND 将它们组合在一起,然后 RFLAG 被 PUSH 到虚拟堆栈上。

not dword ptr
mov ax,
sub rbp, 0x06
and , ax
pushfq
pop
4.8.14 READCR3
READCR3 虚拟指令是原生 mov reg, cr3 周围的包装虚拟机处理程序,该指令会将 CR3 的值放入虚拟堆栈中。

mov rax, cr3
sub rbp, 0x08
mov , rax
4.8.15 WRITECR3
WRITECR3 虚拟指令是原生 mov cr3, reg 周围的容器,该指令会将一个值放入 CR3 中。

mov rax,
add rbp, 0x08
mov cr3, rax
4.8.16 PUSHVSP
PUSHVSP 虚拟指令将原生寄存器 RBP 中包含的值 PUSH 到虚拟堆栈上,该指令有一个变种,适用于 1、2、4 和 8 字节。

4.8.16.1 PUSHVSPQ
PUSHVSPQ 将虚拟堆栈指针的整个值 PUSH 到虚拟堆栈。

mov rax, rbp
sub rbp, 0x08
mov , rax
4.8.16.2 PUSHVSPDW
PUSHVSPDW 将虚拟堆栈指针的底部 4 个字节 PUSH 到虚拟堆栈。

mov eax, ebp
sub rbp, 0x04
mov , eax
4.8.16.3 PUSHVSPW
PUSHVSPW 将虚拟堆栈指针的底部 WORD 值 PUSH 到虚拟堆栈。

mov eax, ebp
sub rbp, 0x02
mov , ax
4.8.17 LVSP
该虚拟指令将堆栈顶部的值加载到虚拟堆栈指针寄存器中。

mov rbp,
4.8.17.1 LVSPW
该虚拟指令将堆栈顶部的 WORD 值加载到虚拟堆栈指针寄存器中。

mov bp,
4.8.17.2 LVSPDW
该虚拟指令将堆栈顶部的 DWORD 值加载到虚拟堆栈指针寄存器中。

mov ebp,
4.8.18 LRFLAGS
该虚拟指令将堆栈顶部的 QWORD 值加载到原生标志寄存器中。

push
add rbp, 0x08
popfq
4.8.19 JMP
虚拟 JMP 指令更改 RSI 寄存器以指向一组新的虚拟指令,堆栈顶部的值是从模块基址到虚拟指令的 RVA 的低 32 位,然后将该值添加到 PE 文件可选标头中找到的图像基值的前 32 位,将基地址添加到该值中。

mov esi,
add rbp, 0x08
lea r12,
mov rax, 0x00 ; image base bytes above 32bits...
add rsi, rax
mov rbx, rsi ; update decrypt key
add rsi, ; add module base address
4.8.20 CALL
虚拟调用指令获取虚拟堆栈顶部的地址,然后调用它。RDX 用于保存地址,因此只能使用它真正调用具有单个参数的函数。

mov rdx,
add rbp, 0x08
call rdx
五、特征分析
现在 VMProtect 2 的虚拟机架构已被记录下来,我们可以反思重要的特征。此外,VMProtect 2 生成的混淆也可以通过非常简单的技术来处理。这可以使 vm_entry 例程的解析变得简单。vm_entry 没有合法的 JCC,因此每次遇到 JCC 时,我们都可以简单地遵循它,从指令流中删除 JCC,然后在遇到 JMP RCX/RDX 时停止。可以通过遵循 Zydis 指令的使用方式来删除大多数 DeadStore,特别是跟踪指令目标寄存器的读写依赖性。最后,通过清理后的 vm_entry,现在可以迭代所有指令并找到 VM Handler、解密 VM Handler 表项所需的转换,以及最后解密之前 PUSH 到堆栈的虚拟指令的相对虚拟地址所需的转换,跳转到 vm_entry。

5.1 定位 VM Handler 表
最好、最著名的特征之一是 LEA r12,vm_handlers。该指令位于 vm_entry 代码片段内部,并将 VM Handler 表的线性虚拟地址加载到 R12 中。使用 Zydis,可以轻松定位并解析此 LEA,以自行定位 VM Handler 表。

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.type == ZYDIS_OPERAND_TYPE_REGISTER &&
                instr->operands.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, result->addr, &ptr);

    return reinterpret_cast<std::uintptr_t*>(ptr);
}
上述 Zydis 例程将静态定位 VM Handler 表的地址,它只需要一个 ZydisDecodedInstructions 向量,一个对应 vm_entry 例程中的每条指令。我对此的实现 (vmprofiler) 将首先对 vm_entry 进行反混淆,然后传递该向量。

5.3 定位 VM Handler 表解密
可以通过首先找到从所述表中获取条目的指令,以编程方式轻松确定对 VM 处理程序表条目应用哪种转换。该指令记录在 vm_entry 部分中,它由 SIB 指令组成,以 RDX 或 RCX 作为目标,R12 作为基数,RAX 作为索引,8 作为标度。

.vmp0:0000000140005A41 49 8B 14 C4            mov   rdx,
使用 Zydis 可以轻松找到该位置,必须做的就是定位一条 SIB mov 指令,其中 RCX 或 RDX 作为目标,R12 作为基数,RAX 作为索引,最后以 8 作为索引。现在,使用 Zydis 我们可以找到以 RDX 或 RCX 作为目标的下一条指令,该指令将是应用于 VM Handler 表项的转换。

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.type == ZYDIS_OPERAND_TYPE_MEMORY &&
                instr->operands.mem.base == ZYDIS_REGISTER_R12 &&
                instr->operands.mem.index == ZYDIS_REGISTER_RAX &&
                instr->operands.mem.scale == 8 &&
                instr->operands.type == ZYDIS_OPERAND_TYPE_REGISTER &&
                (instr->operands.reg.value == ZYDIS_REGISTER_RDX ||
                  instr->operands.reg.value == ZYDIS_REGISTER_RCX))
            {
                rcx_or_rdx = instr->operands.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.reg.value == rcx_or_rdx &&
                instr_data.instr.operands.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 例程并返回完成的转换以解密 VM Handler 表。在 C++ 中,每个转换操作都可以在 lambda 中实现,并且可以对单个函数进行编码以返回必须应用的转换的相应 lambda 例程。

.vmp0:0000000140005A41 49 8B 14 C4            mov   rdx,
.vmp0:0000000140005A49 48 81 F2 49 21 3D 7F   xor   rdx, 7F3D2149h
上面的代码相当于下面的 C++ 代码,这将解密 VM Handler 项。要加密新值,必须执行逆运算。然而对于异或来说,这只是异或。

vm::decrypt_handler _decrypt_handler =
    [](std::uint8_t idx) -> std::uint64_t
{
    return vm_handlers ^ 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 ^ 0x7F3D2149;
};
5.3 处理转换
上述解密和加密处理程序可以通过创建每个转换类型的映射以及该指令的 C++ lambda 重新实现来动态生成。此外,可以创建处理动态值 (例如字节大小) 的例程。这可以防止每次需要转换时都创建 switch case。

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>(a, b);
      case 16:
            return transforms<std::uint16_t>(a, b);
      case 32:
            return transforms<std::uint32_t>(a, b);
      case 64:
            return transforms<std::uint64_t>(a, b);
      default:
            throw std::invalid_argument("invalid bit size...");
      }
    }
    // ...
}
这段小代码片段将允许在 C++ 中轻松实现转换并考虑到溢出。在转换过程中遵守大小非常重要,因为如果没有正确的大小溢出,滚动和移位将不正确。下面的代码是如何通过在 C++ 中动态实现转换来解密虚拟指令的操作数的示例。

// 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;
    const auto generic_decrypt_1 = &transforms;
    const auto generic_decrypt_2 = &transforms;
    const auto generic_decrypt_3 = &transforms;
    const auto update_key = &transforms;

    // apply transformation with rolling decrypt key...
    operand = transform::apply(key_decrypt->operands.size,
      key_decrypt->mnemonic, operand, rolling_key);

    // apply three generic transformations...
    {
      operand = transform::apply(
            generic_decrypt_1->operands.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.imm.value.u : 0);

      operand = transform::apply(
            generic_decrypt_2->operands.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.imm.value.u : 0);

      operand = transform::apply(
            generic_decrypt_3->operands.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.imm.value.u : 0);
    }

    // update rolling key...
    rolling_key = transform::apply(key_decrypt->operands.size,
      key_decrypt->mnemonic, rolling_key, operand);

    return { operand, rolling_key };
}
5.3.1 静态分析
重新实现转换的能力很重要,但是,能够从 VM Handler 和 calc_jmp 中解析转换是另一个需要解决的问题。为了确定改造在哪里,必须首先确定是否需要改造。转换仅应用于虚拟指令的操作数,虚拟指令的第一个操作数总是在同一位置进行转换,这段代码称为 calc_jmp,之前解释过,第二个进行转换的地方是处理立即值的 VM Handler 内部。换句话说,如果虚拟指令具有立即值,则该操作数将有一组唯一的转换。立即值是从 VIP (RSI) 中读出的,因此可以使用这个关键细节来确定是否存在立即值以及立即值的大小。需要注意的是,从 VIP 读出的立即值并不总是等于为 LCONST 等指令在堆栈上分配的解密值的大小。这是因为符号扩展和零扩展虚拟指令,让我们检查一个具有立即值的虚拟指令示例。该虚拟指令称为 LCONSTWSE,代表 加载大小字的常量值,但符号扩展为 DWORD,该虚拟指令的反混淆 vm 处理程序如下所示:

.vmp0:0000000140004478 66 0F B7 06            movzx   ax, word ptr
.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   , eax
.vmp0:0000000140007E2D 48 8D 76 02            lea   rsi,
从 VIP 中读取了两个字节,这是第一条指令,这是可以在 zydis 中寻找的东西,任何 MOVZX、MOVSX 或 MOV (其中 RAX 为目标、RSI 为源) 都显示存在立即值,因此我们知道指令流中预计有五次转换,然后可以搜索 RAX 为目标、RBX 为源的指令,这将是第一次转变。在上面的例子中,第一个减法指令就是我们要寻找的。

.vmp0:0000000140004412 66 29 D8               sub   ax, bx
接下来可以寻找对 RAX 具有写入依赖性的三个指令,这三个指令将是应用于操作数的通用转换。

.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) 进行一次转换,最后的转换更新滚动解密密钥。

.vmp0:000000014000460F 66 29 C3               sub   bx, ax
所有这些转换指令现在都可以由 C++ lambda 即时重新实现,使用 std::find_if 对于这些类型的搜索算法非常有用,因为可以一次一步地进行。首先找到关键转换,然后找到接下来写入 RAX 的三个指令。

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,
            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.type == ZYDIS_OPERAND_TYPE_REGISTER &&
                util::reg::compare(instr_data.instr.operands.reg.value, ZYDIS_REGISTER_RAX) &&
                instr_data.instr.operands.type == ZYDIS_OPERAND_TYPE_MEMORY &&
                instr_data.instr.operands.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.reg.value, ZYDIS_REGISTER_RAX) &&
                util::reg::compare(instr_data.instr.operands.reg.value, ZYDIS_REGISTER_RBX))
                return true;
            return false;
      }
    );

    // last transformation is the same as the first except src and dest are swapped...
    transforms = key_transform->instr;
    auto instr_copy = key_transform->instr;
    instr_copy.operands.reg.value = key_transform->instr.operands.reg.value;
    instr_copy.operands.reg.value = key_transform->instr.operands.reg.value;
    transforms = 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.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 在应用最后一次转换时需要一些创造性的自由,有时可以将滚动解密密钥 PUSH 到堆栈,应用转换,然后将结果弹出回 RBX。这个虽小但显着的不便可以通过简单地交换 ZydisDecodedInstruction 变量中的目标和源寄存器来解决,如上面的代码所示。

5.4 静态分析结论
尝试静态分析虚拟指令的困境是虚拟机内部的分支操作非常难以处理,为了计算虚拟 JMP 跳转到的位置,需要进行仿真,我将在不久的将来追求 unicorn。

六、跟踪虚拟指令
通过将每个 VM Handler 表项修补为加密值 (解密后指向陷阱处理程序),可以轻松实现虚拟指令跟踪。这将允许寄存器的指令间检查以及更改虚拟机处理程序结果的可能性。为了充分利用此功能,了解哪些寄存器包含哪些值非常重要。



拦截虚拟指令时要记录的第一个也是最重要的信息是位于 AL 中的操作码值,记录所有执行的虚拟指令,必须记录的下一个值是位于 BL 中的滚动解密密钥值,这将允许 vmprofiler 静态解密操作数。

既然能够做到这一点,那么在每条虚拟指令之后记录所有暂存寄存器是对记录信息的重要补充,因为这将描绘出正在操纵的值的更大图景。最后,记录虚拟堆栈上的前五个 QWORD 值是为了提供更多信息,因为该虚拟指令集架构基于堆栈。

为了结束本文的动态分析部分,我为此运行时数据创建了一个小文件格式。文件格式称为 “vmp2”,包含所有运行时日志信息。这种文件格式的结构非常简单,如下所示

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;
      } regs;

      union
      {
            u64 qword;
            u8 raw;
      } vregs;

      union
      {
            u64 qword;
            u8 raw;
      } vsp;
    };
}
七、使用 Runtime Traces 进行静态分析
提供 vmp2 文件,vmprofiler 将生成伪虚拟指令,包括立即值以及受影响的暂存寄存器。这无论如何都不是去虚拟化,也不提供多个代码路径的视图,但它确实提供了执行虚拟指令的非常有用的跟踪。Vmprofiler 还可用于静态定位 VM Handler 表并确定使用什么转换来解密这些 VM Handler 项。

vmprofiler 的示例输出将生成有关每个 VM Handler 的所有信息,包括立即值位大小、虚拟指令名称以及应用于立即值 (如果有立即值) 的五种转换。

=================
============================
> 0x00007FF65BAE5C2E movzx eax, byte ptr
> 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 , ax
==============================
add al, bl
add al, 0xD3
not al
inc al
add bl, al
=====================================================
转换 (如果有) 也从 vm 处理程序中提取,并且可以动态执行以解密操作数。

八、显示跟踪信息
为了显示所有跟踪信息,例如原生寄存器值、暂存寄存器值和虚拟堆栈值,我创建了一个非常小的 Qt 项目,能够单步执行跟踪。我觉得控制台的限制太多,而且我还发现很难确定需要在控制台上显示的内容的优先级,因此需要 GUI。



九、虚拟机行为
执行 vm_entry 例程后,所有压入堆栈的寄存器都会被加载到虚拟机暂存寄存器中。这也扩展到模块基础和 RFLAGS,它们也被 PUSH 到堆栈上,不考虑原生寄存器到暂存寄存器的映射。



虚拟机架构表现出的另一个行为是,如果未使用 VM Handler 实现原生指令,则 vmexit 将碰巧执行原生指令。在我的 VMProtect 2 CPUID 版本中,未使用 VM Handler 实现,因此会发生退出。



在 vmexit 之前,临时寄存器中的值会加载到虚拟堆栈上,vmexit 虚拟指令会将这些值放回到本机寄存器中,可以看到暂存寄存器与 vmmentry 后面的寄存器不同。这是因为就像我之前所说的那样,暂存寄存器没有映射到原生寄存器。



十、演示创建和检测虚拟跟踪
对于这个演示,我将虚拟化一个非常简单的二进制文件,它只执行 CPUID,如果支持 AVX,则返回 true,否则返回 false。下面显示了其汇编代码。

.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,它应该为我们提供 VM Handler 表 RVA 以及所有 VM Handler 信息。

> 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
> 0x00007FF670F2593A mov rax, 0x7FF530F20000
> 0x00007FF670F25955 mov r13, rax
> 0x00007FF670F25965 push rax
> 0x00007FF670F2596F mov esi,
> 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,
> 0x00007FF670F259DF mov rax, 0x100000000
> 0x00007FF670F259EC add rsi, rax
> 0x00007FF670F259F3 mov rbx, rsi
> 0x00007FF670F259FA add rsi,
> 0x00007FF670F25A05 mov al,
> 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,
> 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, 之后进行的异或。

> 0x00007FF670F25A41 mov rdx,
> 0x00007FF670F25A49 xor rdx, 0x7F3D2149
随着 INC 指令增加 RSI,VIP 也在增加。

> 0x00007FF670F25507 inc rsi
有了这些信息,我们现在可以编译一个 vmtracer 程序,它将所有 VM Handler 表项 Patch 到陷阱处理程序,可跟踪虚拟指令并更改虚拟指令结果。

// 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 类,Patch VM Handler 表,然后调用模块的入口点,这远非理想,但出于演示目的就足够了。

// 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 位大小与 32 位的本机寄存器宽度以及加载到其中的常量值匹配。



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

.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 指令之后,代码执行立即返回虚拟机。使用位于虚拟堆栈上的本机寄存器值设置所有虚拟暂存寄存器后,立即将常量加载到堆栈上,其值为 0x1C。然后将 CPUID 的结果值右移该常量值。



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



然后,该 AND 运算的结果被设置到虚拟暂存寄存器 (SREGDW 0x38) 中,然后它被移入暂存寄存器 16,如果查看 vmexit 指令和 LREGQ 的执行顺序,可以看到这确实是正确的。



最后,还可以看到 ADD 指令和 LVSP 指令,它们为 VSP 加值。这是预期的,因为原始二进制文件中有一个 ADD RSP,0x10。



根据上面的信息我们可以重构以下本机指令:

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 的入栈和出栈,以及将 ECX 内容清零的 XOR。我假设这些指令不会直接转换为虚拟指令,而是以迂回的方式实现。

十一、改变虚拟指令结果
为了改变虚拟指令,必须首先重新实现整个 VM Handler。如果 VM Handler 解密第二个操作数,则必须记住解密密钥有效性的重要性。因此,必须计算原始立即值并通过原始转换将其应用于解密密钥。然而,在更新解密密钥后,该值随后可以被丢弃。例如,在上一节中,可以在 SHR 之前更改 LCONST 中的常量值。



该虚拟指令有两个操作数,第一个是要执行的 VM Handler 索引,第二个是立即值,在本例中为单个字节。由于有两个操作数,因此 VM Handler 内部将有五次转换。



可以重新编码这个 VM Handler,并将解密的立即值与 0x1C 进行比较,然后分支到子例程以将不同的值加载到堆栈中。这将导致 SHR 计算出不同的结果。本质上可以欺骗 CPUID 结果。另一种方法是重新创建 SHR 处理程序,但为了简单起见,我将转到已设置的位。在这种情况下,如果支持 VMX,则设置 CPUID 后 ECX 中的位 5,并且由于 CPU 支持虚拟化,因此该位将为高电平,下面是新的 VM Handler

.data
    __mbase dq 0h
    public __mbase

.code
__lconstbzx proc
    mov al,
    lea rsi,
    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 , 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 , ax
    mov rax, __mbase
    add rax, 059FEh    ; calc jmp rva is 0x59FE...
    jmp rax
__lconstbzx endp
end
如果现在再次运行 vm 跟踪器,并将这个新的 VM Handler 设置为索引 0x55,应该能够看到 LCONSTBZX 中的变化。为了促进这一挂钩,必须将新 VM Handler 的虚拟地址设置为 vm::handler::table_t 对象。

// 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 生成一个虚拟机,该虚拟机执行用其自己的字节码编码的虚拟指令,如果可以对其进行编码,则可以在 VM 上运行自己的虚拟指令。尽管虚拟指令的 RVA 是 32 位宽,但编码的虚拟指令也必须在 4GB 地址空间范围内。在本节中,我将编码一组非常简单的虚拟指令,将两个 QWORD 值相加并返回结果。

首先,编码虚拟指令要求所述虚拟指令的虚拟机处理程序位于二进制文件内部。找到这些虚拟机处理程序是由 vmprofiler 完成的。VM Handler 索引是第一个操作码,立即值 (如果有) 是第二个操作码,组合这两组操作数将产生编码的虚拟指令,这是组装虚拟指令的第一阶段,第二阶段是加密操作数。

一旦有了编码的虚拟指令,现在就可以使用 VM Handler 转换的逆操作以及 calc_jmp 的逆操作来加密它们。需要注意的是,加密时必须考虑 VIP 前进的方式,因为操作数和虚拟指令的顺序取决于此前进方向。



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

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 来模拟虚拟机处理程序。

尽管已经记录了一些虚拟指令,但还有更多虚拟指令我尚未记录。记录所拥有的虚拟指令的目的是让本文的读者了解虚拟机处理程序的表现以及如何更改这些虚拟机处理程序的结果。本文中记录的虚拟指令也是最常见的指令,这些虚拟指令很可能位于每个虚拟机内部。

我在存储库中添加了一些参考构建,供你尝试通过更改 VM Handler 使它们返回 1,还有一个构建在单个二进制文件中使用多个虚拟机。

最后,我想重申,这项研究肯定已经由私人完成,而且我并不是第一个记录本文中讨论的一些虚拟机架构的人。我已经对那些已经研究过的人表示感谢,但是可能还有更多的人对 VMProtect 2 进行了研究,我没有列出这些人只是因为我还没有接触过他们的工作。

页: [1]
查看完整版本: VMProtect 2 虚拟机架构细节