第一章 指令虚拟化技术原理

指令虚拟化无外乎三个部分,VM Bytecode、VM Context、VM Dispatcher、VM Handler。接下来对这三个部分进行解析。

void vm_entry(bytecode_ptr) {
    VMContext ctx;

    init_context(&ctx);

    while (1) {
        opcode = *bytecode_ptr++;
        handler = handler_table[opcode];
        handler(&ctx);
    }
}

为了方便理解,这里以VMProtect的实现为例。

VM Bytecode类似于CPU的指令流,里面的指令类似于机器码,只不过是VMProtect的自定义的指令集。VM Context中保存了VM虚拟机的状态,对应CPU寄存器和栈空间。在实际的CPU中,Bytecode和Context都是存储在特定的硬件中的,但是在VM中这些都以数据的形式存储于内存中。

VM Dispatcher是一个指令的分发器,可以说相当于上面的伪代码的整个控制框架。VM Handler则是指令的执行器。在实际的CPU中,Dispatcher和Handler是由硬件实现的,但是在VM中是通过汇编代码实现的。

第二章 VMP1.1分析

多说无益,直接通过VMProtect1.1的加固分析来更深地理解VM指令虚拟化的原理。

在分析之前,需要说明的是VMProtect1.1的ctx指针是通过edi寄存器来实现的,bytecode_ptr指针是通过esi指针实现的。这也会在之后的实践中得到验证。

2.1 使用VMProtect1.1加固软件

先写一个夹杂汇编的cpp文件,命名为VMPTest_001.cpp:

#include <iostream>

__declspec(naked) void test() {
    __asm {
        mov eax, 0x12345678
        ret
    }
}

int main() {
    test();
    std::cout << "Hello, VMP!\n";
    system("pause");
    return 0;
}

接下来进行编译,由于VS2022老是报错,这里我直接古法编程,写了一个bat脚本来编译cpp文件,这个脚本会以release、x86并且关闭随机基址的方式编译:

@echo off
setlocal

REM set source
set SRC=VMPTest_001.cpp
set OUT=VMPTest_001.exe

REM init VS2022 x86 env
call "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvarsall.bat" x86
REM 这里call的指令直接换成自己的编译器环境即可

REM compile
cl %SRC% ^
    /O2 /MT /DNDEBUG /EHsc ^
    /Fe:%OUT% ^
    /link /DYNAMICBASE:NO

if %ERRORLEVEL% neq 0 (
    echo Build failed!
) else (
    echo Build success: %OUT%
)

endlocal
pause

编译通过后会有一个VMPTest_001.exe,不管通过什么办法,搞到mov eax,0x12345678指令的地址,我这里是通过IDA搞到,当然,如果VS2022正常编译可以直接查看反汇编得到。

使用VMProtect1.1打开VMPTest_001.exe,Project -> New procedure,将刚才获取到的指令地址填进去,右键ret指令,选择End of procedure:

image-20260326200913928

点击Option,配置如下:

image-20260326201015540

点击菜单栏的绿色箭头运行,在原项目下生成的VMPTest_001.vmp.exe就是加固后的软件,将其丢进动态调试软件,直接跳转至mov指令所在的地址:

image-20260326201245386

2.2 分析虚拟化流程

发现原来的mov eax 0x12345678变成了jmp指令,步进jmp:

image-20260326201512420

这里的431FBF就是VM Bytecode的地址,将其压入栈中。下面的jmp指令跳转的地址就是dispatcher的地址,跟进:

image-20260326201812352

经典的VM指令虚拟化前的操作,pushfd和pushad分别将通用寄存器和标志寄存器压入栈中(保存当前CPU状态),push 0将四字节的0压入栈。

2.2.1 vm_entry

mov esi, dword ptr ss:[esp+28],把bytecode_ptr传给esi。为什么esp + 28就是之前的431FBF?

pushad -> 压入8个通用寄存器 
pushfd -> 压入一个标志寄存器
push 0 -> 压入四字节数据0

(8+1+1)*4 = 40 = 0x28

2.2.2 init_context

执行完vm_entry的函数初始化,接下来初始化context,为了方便分析,我把init_context的部分截出来:

cld 
mov edx,vmptest_001.vmp.42D000
call dword ptr ds:[<GetCurrentThreadId>]
mov ebx,eax
mov ecx,100
mov edi,edx
repne scasd 
je vmptest_001.vmp.4318B2
mov eax,100
xchg ecx,eax
mov edi,edx
repne scasd 
mov dword ptr ds:[edi-4],ebx
mov ebp,edi
sub edi,edx
shl edi,1
lea edi,dword ptr ds:[edx+edi*8+3C0]
add esi,dword ptr ss:[esp] ;esi+0

这里主要解释一些高阶指令。

cld:Clean DF,将方向标志位清零,这样指针寄存器EDI、ESI朝着递增的方向移动。

repne:Repeat While Not Equal,一个重复前缀,导致后续字符串指令在ZF=0且ECX计数寄存器不为0时重复

scasd:Scan String Doubleword,将EAX寄存器中的值与[EDI]的双字进行比较,根据方向标志位DF将EDI+4或-4,并根据比较结果设置ZF标志。

现在总结一下上面的初始化过程:

1.获取当前线程ID
2.在42D000开始的256个双字中查找是否与当前线程ID匹配,如果不匹配,则找一块空着的地方(00000000)写入当前线程ID
3.如果匹配,则为这个线程分配一个64字节大小的VM Context。
4.将edi指向VM Context,将esi指向VM Bytecode,ebp指向线程ID槽

2.2.3 dispatcher&handler

初始化context之后进入dispatcher分发和handler执行程序,对应伪C代码流程中的:

		while (1) {
            opcode = *bytecode_ptr++;
            handler = handler_table[opcode];
            handler(&ctx);
        }

我们根据剩下的汇编代码一一对应:

lodsb 
movzx eax,al
push dword ptr ds:[eax*4+4319F6]
ret

lodsb:从[ESI]加载一个字节到al,esi自增1(DF=0)。

movzx eax, al:将al零扩展到eax。

push dword ptr ds:[eax*4+4319F6]:4319F6是handler_table的基地址,这是根据bytecode的自定义指令寻址。

ret:弹出栈顶地址并跳转。

综上,上述汇编对应的伪C代码是:

opcode = *bytecode_ptr++;
handler = handler_table[opcode];

但是我们注意到,这里并没有循环的程序,先别急,执行到ret,跟进:

image-20260326213234080

这里就是handler函数的具体实现,只不过相比伪C代码,这里多了一个跳转回lodsb指令的jmp,综上,VMProtect1.1的dispatcher其实伪C代码应该如下:

void vm_entry(bytecode_ptr) {
    VMContext ctx;

    init_context(&ctx);
	
    dispatcher:
        opcode = *bytecode_ptr++;
        handler = handler_table[opcode];
        handler(&ctx);

}

handler(VMContext ctx) {
    ……
    goto dispatcher;
}

不过无所谓,其实和while循环是逻辑等价的。

2.3 还原虚拟指令流

分析完指令虚拟化流程,接下来还原虚拟指令流。

确认指针环境

esi指向Bytecode,edi指向VM Context

2.3.1 从431FBF处获取完整指令流

00431FBF 26 04 DF 0A | 26 00 26 09 |26 02 3A 06 |26 07 26 0E

00431FCF 26 02 3A 01 |48 E2 78 56 |34 12 DF 03 |9D 04 E2 B5

00431FDF 22 40 00 10 |9D 01 9D 03 |9D 0E 9D 07 |9D 06 9D 0E

00431FEF 9D 09 9D 00 |9D 0A 9D 04 |19 68 BF 1F |43 00 E9 7F

00431FFF F8 FF FF 23 |D0 E4 7B 39 |00 00 00 00 |00 00 00 00

看起来挺头大的对吧,但是其实一半都是无效指令,其中充当指令的我加粗处理了。为什么这么说,等逐个分析就知道了。

结构化VM Context

之前说过,这个VMP为每一个线程分配了64字节的内存,但是因为在32位程序且为了简便,我以4个字节为单位,构建VM Context结构体:

typedef struct vm_context{
    DWORD[16] ctx; 
}

初始栈元素

初始的栈元素结构如下:

00000000  -> 栈顶
EFLAG
EDI
ESI
EBP
ESP
EBX
EDX
ECX
EAX 

2.3.2 分析opcode

既然是动态调试,那我们直接执行查看堆栈就知道handler函数的位置了。在ret处下断点。

  • 26

    此时的栈顶元素为4317DF,跳转分析:

    movzx eax,byte ptr ds:[esi]
    add esi,1
    pop dword ptr ds:[edi+eax*4]
    jmp vmptest_001.vmp.4318C2 ;跳转dispatcher

    取[esi]的单字节(即26opcode的下一个字节为26的操作数)零扩展到eax,esi自增1;从栈顶取双字节数据到VM Context的ctx[eax]。

  • DF

    此时栈顶元素为4317DF,和26一样,这意味着handler[26]和handler[DF]处存放的地址是一样的,分析同上。

  • 3A

    由于前面很多是重复的,无非是某个CPU寄存器分配到VM Context的哪一个ctx的问题,所以直接设置eax == 0x3A的条件断点,跳转,然后发现和handler[26]存放的地址是同一个,分析同上。

  • 48

    pop eax
    jmp vmptest_001.vmp.4318C2

    将栈顶元素pop到eax寄存器,比起赋值操作更像是丢弃栈顶元素(DROP),因为执行完这个handler后eax会被强制刷新为48opcode的下一个opcode

  • E2

    lodsd 
    push eax
    jmp vmptest_001.vmp.4318C2

    从[esi],也就是虚拟指令流读取E2之后的dword4字节数据到eax,将eax压入栈。

  • 9D

    movzx eax,byte ptr ds:[esi]
    lea esi,dword ptr ds:[esi+1]
    push dword ptr ds:[edi+eax*4]
    jmp vmptest_001.vmp.4318C2

    将虚拟指令流中9D下一个操作数零扩展至eax,esi += 1(由于是lea指令递增的,不改变标志位),将ctx[eax]压入栈

  • 10

    pop eax
    add dword ptr ss:[esp],eax
    jmp vmptest_001.vmp.4318C2

    将栈顶元素压入eax,为新的栈顶元素增加eax,其实就是[esp]+[esp+4]。

  • 19

    mov dword ptr ss:[ebp-4],0
    pop eax
    popad 
    popfd 
    ret 

    将[ebp-4]的四个字节赋值为0,此时的[ebp-4]存储着线程ID,将栈顶元素pop给eax,回复寄存器的值,VM虚拟指令结束,跳转回正常执行流程。

至此,我们所有的opcode就分析完了,下面还原整个流程。

2.3.3 还原虚拟指令流

虚拟机初始化

26/DF/3A指向同一个handler,进行的操作是将寄存器映射到VM Context中的VM Stack,最终的映射关系如下:

typedef struct vm_context {
    DWORD ctx[4] = 0;
    DWORD ctx[A] = EDI;
    DWORD ctx[0] = ESI;
    DWORD ctx[9] = EBP;
    DWORD ctx[2] = ESP;
    DWORD ctx[6] = EBX;
    DWORD ctx[7] = EDX;
    DWORD ctx[E] = ECX;
    DWORD ctx[2] = EAX;
    DWORD ctx[1] = EFLAG;
}

这段字节码建立的映射其实就是在虚拟机正式工作前,加载进入虚拟机的上下文,让后面虚拟化二点指令接着之前的上下文工作。虚拟机中的寄存器变化全部由VM Stack接手,等到退出之后再将VM Stack的状态还原回真实栈,然后popfd,popad回到真实寄存器中。也就是说,即使在虚拟机中,最终真实的CPU寄存器的值也会根据虚拟机操作而改变。

另外,注意这里ESP和EAX冲突,按先后顺序是EAX最终会被保存,这是因为ESP不需要保存,退出vm后ESP由于堆栈平衡自动回到之前的值。

虚拟机指令执行

结束了虚拟机初始化的工作后,我们看虚拟机的指令执行过程:

指令	  操作数
48 						;丢弃栈顶元素
E2  	78 56 34 12 	;将0x12345678入栈
DF  	03 				;将0x12345678保存在ctx[3]->EAX中,等同于mov eax,0x12345678
9D  	04 				;将ctx[4]=0压入栈中
E2  	B5 22 40 00 	;将0x004022B5入栈
10  					;将0x004022B5与ctx[4]=0相加,没什么用,是混淆代码

还原栈状态

之后的9D指令将ctx[index]还原到真实栈上,需要关注的是9D 03会把ctx[3] = 0x12345678从虚拟eax还原回初始栈中eax的位置,便于后面的popad还原回真实寄存器。

退出vm

19指令销毁线程ID,并将0,通用寄存器,EFLAG依次出栈将虚拟寄存器的状态还原到真实寄存器。

第三章 VMP函数调用、指令加密处理

在第二章的分析中,我们知道VMP可以只保护一小段指令,那么,如果被保护的指令中存在call指令,且调用的函数不被保护会怎么样?这里引出第一个知识点函数调用

3.1 函数调用

依旧拿一个程序来进行分析,源码如下:

#include <iostream>
#include <windows.h>

const char* msg = "Hello, VMP!";
const char* caption = "xuanyuan";

__declspec(naked) void test() {
    __asm {
        push 0              //MB_OK
        push caption        //caption
        push msg            //msg
        push 0              //parent hwnd
        call MessageBoxA
        mov eax, 0x12345678
        ret
    }
}

int main() {
    test();

    std::cout << "Hello, VMP!\n";
    system("pause");
    
    return 0;
}

构建脚本如下:

@echo off
setlocal

REM set source
set SRC=VMPTest_002.cpp
set OUT=VMPTest_002.exe

echo SRC=%SRC%
echo OUT=%OUT%

REM init VS2022 x86 env
call "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvarsall.bat" x86

REM compile
cl %SRC% ^
    /Od /MT /DNDEBUG /EHsc ^
    /Fe:%OUT% ^
    /link /DYNAMICBASE:NO user32.lib

if %ERRORLEVEL% neq 0 (
    echo Build failed!
) else (
    echo Build success: %OUT%
)

endlocal
pause

依旧是关闭随机加载地址,记住push 0指令的地址,我这里是0x4010D0。

像第二章一样用VMP1.1只对test函数进行保护,option选项全部不勾选。

定位到4010D0:

image-20260330173848342

发现这里直接进入到dispatcher的跳转,那为什么第二章中需要两次跳转呢,因为第二章中保护的代码只有5个字节,无法支撑push + jmp指令的字节码写入,而这次的可修改字节充足,就不用两次跳转找空白位写入了。另外,这里从fldz到ret之前的代码都是为了不对原程序造成影响而补足剩余字节码的无效代码。

3.1.1 虚拟化流程分析

进入dispatcher:

pushfd 
pushad 
push 0
mov esi,dword ptr ss:[esp+28]
cld 
mov edx,vmptest_002.vmp.42E000
call dword ptr ds:[<GetCurrentThreadId>]
mov ebx,eax
mov ecx,100
mov edi,edx
repne scasd 
je vmptest_002.vmp.432E2D
mov eax,100
xchg ecx,eax
mov edi,edx
repne scasd 
mov dword ptr ds:[edi-4],ebx
mov ebp,edi
sub edi,edx
shl edi,1
lea edi,dword ptr ds:[edx+edi*8+3C0]
add esi,dword ptr ss:[esp]
mov cl,byte ptr ds:[esi]
movzx eax,cl
inc esi
jmp dword ptr ds:[eax*4+432656]

经过第二章的讲解,这里我就不过多对汇编代码进行解析了,抓取分发过程:

mov cl,byte ptr ds:[esi]
movzx eax,cl
inc esi
jmp dword ptr ds:[eax*4+432656]

有没有发现和之前不太一样:

lodsb 
movzx eax,al
push dword ptr ds:[eax*4+4319F6]
ret

但是只要一分析就能发现实现的逻辑是等价的,这也是VMP的特点,每次保护进行的混淆都不一样,让人无法轻易根据特征反混淆。

3.1.2 还原虚拟指令流

提取Bytecode:

00432FA8 94 01 94 03 94 09 94 0C 94 00 94 00 94 0B 94 05

00432FB8 94 06 94 02 51 C9 00 80 04 B0 42 00 DB 80 00 B0

00432FC8 42 00 DB C9 00 4A 01 80 29 30 43 00 7D 80 54 F1

00432FD8 41 00 DB 50 02 4A 06 50 05 4A 0B 50 00 38 4A 0C

00432FE8 4A 09 50 03 4A 01 6F

前面的94一眼盯真,鉴定为真实栈元素对应寄存器映射到VM Context的过程,事实上跟进去看汇编也确实是。然后后面的50、4A跟的操作数完全是映射的逆过程,也很容易判断是退出程序前的寄存器逆映射过程。

  • 51

    pop edx
    jmp vmptest_002.vmp.432E3D

    将栈顶元素(此时是Bytecode的地址)赋值给edx

  • C9

    lodsb 							;从[esi]加载一个字节给al,esi += 1
    cbw 							;al有符号扩展为ax
    cwde							;ax有符号扩展为eax 
    push eax						;将eax入栈
    jmp vmptest_002.vmp.432E3D		

    这里对应test函数中的push 0

  • 80

    mov ecx,dword ptr ds:[esi]		;从[esi]加载双字给ecx
    push ecx						;ecx入栈
    add esi,4						;esi += 4
    jmp vmptest_002.vmp.432E3D
  • DB

    pop ebx							;栈顶元素pop给ebx
    push dword ptr ds:[ebx]			;将ebx地址存储的值入栈
    jmp vmptest_002.vmp.432E3D
  • 7D

    pop ebx							;将栈顶元素pop给ebx
    add dword ptr ss:[esp],ebx		;将ebx的值加到现在的栈顶元素上
    jmp vmptest_002.vmp.432E3D
  • 38

    push esp
    jmp vmptest_002.vmp.432E3D
  • 6F

    mov dword ptr ss:[ebp-4],0
    pop eax
    popad 
    popfd 
    ret 

    很显然是退出vm函数

总体分析,抛开寄存器映射与逆映射以及退出vm指令。真正需要分析的是下面这段:

51 C9 00 80 0x0042B004 DB 80 0x0042B000 DB C9 00 4A 01 80 0x00433029 7D 80 0x0041F154 DB 38

因为80 + DB指令显然是取地址的值入栈,我们将其绑定分析,得到如下的分组:

51 混淆用的,无意义
C9 00  -> push 0
80 0x0042B004 DB -> push caption
80 0x0042B000 DB -> push msg
C9 00 -> push 0
80 0x00433029 7D -> 这里压入一个地址00433029到栈中,这个地址是MessageBox执行完后的栈顶元素,ret指令会直接转到这里,至于为什么这样做一会说
80 0x0041F154  DB -> push MessageBox的地址入栈
38 将ESP入栈,无意义,因为堆栈平衡无需管理ESP

现在分析完后,我们跟进0x433029地址,看看为什么VMP要在MessageBox执行完后返回到这里。

image-20260330191403249

似曾相识的感觉,这里的432DFC不就是dispatcher吗。还记得test函数的内容吗,我们分析完直到vm退出都没有见过mov eax, 0x12345678指令。事实上,mov eax, 0x12345678就在这里的432FEF指令流里,对的,这里的432FEF也是Bytecode。

由于本章节内容是探讨VMP函数调用,这里我们就不去还原mov eax, 0x12345678指令了。主要分析VMP为什么这样做。

很简单,我们在VMP中新建过程的时候,虽然包含了call指令,但是被调用的函数它里面的指令却没有进行虚拟化,不在受保护的范围。而执行call指令的时候,CPU的执行流就会进入未被保护的区域。因此,VMP需要先退出虚拟机,等被调用的函数调用完成之后,再回来继续执行被虚拟化保护的指令

那VMP如何保证能把执行流程收得回来呢?这就需要手动构造函数调用堆栈,在调用MessageBoxA函数之前安插VMP自己的代码块作为返回地址。等到MessageBox函数调用完成之后,就会顺着堆栈,重新回到VMP的掌控范围,然后重新进行虚拟机,重新进行初始化。