ARTICLE · 1256446
VMP源码阅读(四)
前言
在上一篇文章《VMP源码阅读(三)》中,我们学习了 VMP 的代码变异(Mutation)功能。它会在原始指令之间插入大量花指令,再将函数切割成许多 Block,最后打乱这些 Block 的顺序。
代码变异虽然让程序变得很难阅读,但 CPU 最终执行的依然是原来的 x86 指令。
而代码虚拟化(Virtualization)则完全不同。它会先将原来的 x86 指令翻译成一套 VMP 自己定义的指令,然后再生成一台虚拟机,由这台虚拟机负责解释执行。
本文只研究其中最容易理解的一种:Classic 代码虚拟化。
为了控制篇幅,我会忽略异常处理、内存保护和各种反调试逻辑,只关注一段代码从进入虚拟机,到执行完毕,再返回原生代码的完整过程。
Demo
首先,还是准备一个极其简单的函数:
uint64_t return_constant() { return 0x1122334455667788;}它编译后的核心代码只有两句:
mov rax, 0x1122334455667788ret在没有加密时,CPU 直接执行这两句指令,将常量放入 RAX 寄存器,然后返回调用者。
但是经过 VMP 虚拟化以后,原函数中的两句汇编会消失,函数入口只剩下一句跳转。真正的函数逻辑则被翻译成 VM 指令,编码为 VM Code 保存在 .rdata 中。

这里所谓的虚拟机,并不是 VMware 那种可以运行操作系统的虚拟机。它更像是一个由 VMP 临时生成的解释器:CPU 负责运行解释器,解释器再负责运行原函数。
编译
在 VMP 源码中,每一个需要加密的函数都由 IntelFunction 表示,函数中的每一条汇编指令则由 IntelCommand 表示。
当我们为函数选择 Virtualization 后,最终会进入 IntelFunction::Compile():
bool IntelFunction::Compile(const CompileContext &ctx){ switch (compilation_type()) { case ctMutation: CompileToNative(ctx); break; case ctVirtualization: CompileToVM(ctx); break; case ctUltra: Mutate(ctx, true); CompileToVM(ctx); break; default: return false; } return BaseFunction::Compile(ctx);}这里的逻辑非常直白:
• Mutation:将指令重新编译成 x86 机器码。• Virtualization:将指令编译成 VM 指令。• Ultra:先进行代码变异,再进行代码虚拟化。
因此,代码虚拟化最核心的步骤,就是 CompileToVM()。

例如 Demo 中的这一句:
mov rax, 0x1122334455667788在 VMP 内部会被拆成两条 VM 指令:
PUSH_VALUE64 0x1122334455667788POP_REGISTER64 RAX先把常量压入 VM 栈,再从 VM 栈中弹出,保存到虚拟的 RAX 寄存器中。
可以看到,VMP 的 VM 是一台典型的 栈式虚拟机。它不会直接执行 RAX = value,大部分计算都需要先把数据放入 VM 栈,再通过 PUSH、POP、ADD 等简单指令组合完成。
一条很简单的 x86 指令,往往会被拆成多条 VM 指令,这也是虚拟化后代码体积迅速膨胀的原因之一。
虚拟机
VMP 不仅需要生成 VM 指令,还需要生成一台能够执行这些指令的虚拟机。
这部分代码主要在 IntelVirtualMachine::InitCommands() 中。它会先随机挑选几个物理寄存器,分别承担虚拟机运行时的重要工作:
VIP | |
VSP | |
KEY | |
TABLE |
需要注意的是,这些名字只是为了便于理解。它们并不是固定的 CPU 寄存器。
例如在我调试的这个样本中,RSI 是 VIP,R11 是 VSP,RDI 是 KEY,R10 则保存 Handler Table。换一次编译后,它们很可能又会变成其他寄存器。

除此之外,InitCommands() 还会为每一种 VM 指令生成对应的执行代码,这些代码称为 Handler。
例如:
• PUSH_VALUE64 Handler:读取一个常量,并将它压入 VM 栈。• POP_REGISTER64 Handler:从 VM 栈弹出一个值,并写入寄存器槽位。• RET Handler:恢复 CPU 寄存器,然后返回原生调用者。
这些 Handler 本身依然是 x86 机器码,因为 CPU 真正能够执行的始终只有 x86 指令。
于是,VMP 最终生成了两个东西:
1. 由自定义指令组成的 VM Code。2. 负责解释这些指令的 x86 Handler。
进入虚拟机
前面只是从源码的角度了解了 VMP 会生成什么东西。下面从运行时的角度,完整跟踪一次进入 Classic 虚拟机的过程。
整个过程可以先分为下面几步:

这里的 VM Entry 并不是一条指令,而是一段很长的初始化代码。它需要先接管原生程序的状态,再建立虚拟机自己的运行环境。
入口跳板
首先,原生调用者仍然像以前一样调用 return_constant():
0x140001113 call 0x1400012C00x140001118 mov rdx, raxcall 会将下一条指令地址 0x140001118 压入栈中。这个地址非常重要,因为无论虚拟机里面绕了多大一圈,最后都必须回到这里继续执行。
RSP = 0x200018000[RSP] = 0x140001118 ; Native Return Address原函数经过虚拟化后,入口处原来的 mov 和 ret 已经消失,变成了一个跳转:
0x1400012C0 jmp 0x14007D01C ; 跳转到.vmp00原函数剩余的机器码则会被随机数据覆盖。这一点和上一篇代码变异的处理方式很像:VMP 不会把膨胀后的代码硬塞回原来的函数空间,而是将它们放到新的 Section 中,原地址只保留一个入口跳板。
跳转到 .vmp00 后,会先执行一小段 Entry Stub:
0x14007D01C push 0x7037FB6B ; 加密的初始 VIP0x14007D021 call 0x14007E8A9 ; 调用VM Entry第一句压入的是加密后的 VM Code 地址,第二句 call 又会自动压入一个临时返回地址。因此,进入 VM Entry 时,栈上已经有三个值:
高地址0x200018000 0x140001118 ; 原生调用者的返回地址0x200017FF8 0x7037FB6B ; 加密的初始 VIP0x200017FF0 0x14007D026 ; Entry Stub中call的临时返回地址 ← RSP低地址需要注意的是,0x14007D026 只是 Entry Stub 调用 VM Entry 时产生的临时返回地址。虚拟机最终并不会返回这里,真正需要保留的是更上面的 0x140001118。

保存原生状态
进入 VM Entry 后,第一件事情就是保存当前 CPU 的运行状态:
pushfq; 按随机顺序保存15个通用寄存器push r13push rsipush r15; ...push r8push r9mov r9, 0 ; load-base deltapush r9为什么要保存这些寄存器?因为接下来调度器和 Handler 自己也要使用它们。如果不先保存,虚拟机运行几条指令以后,原函数进入时的寄存器状态就全部被破坏了。
在这个样本中,保存顺序如下:
RFLAGS, R13, RSI, R15, R14, RBX, RDI, RAX,RBP, R10, R12, RDX, R11, RCX, R8, R9,load-base delta此时这些数据还只是通过原生 push 指令堆在栈上,尚未成为正式的虚拟寄存器状态。
解出初始 VIP
保存完原生状态后,VM Entry 从栈中读取刚才 Entry Stub 压入的 0x7037FB6B:
mov rsi, [rsp+0x90] ; 读取加密的初始 VIPneg esi; ... Entry Cryptoradd rsi, r9 ; 加上 load-base deltaadd rsi, 0x100000000经过 Entry Cryptor 解密以后,得到真正的 VM Code 地址:
decode(0x7037FB6B) = 0x4006CC20VIP = 0x4006CC20 + load-base delta + 0x100000000 = 0x14006CC20从这一刻开始,RSI 就承担 VIP 的职责,指向下一段尚未执行的 VM Code。
需要注意的是,VIP 指向的是数据,而不是 CPU 即将执行的机器码。CPU 的 RIP 仍然在 .vmp00 中执行 Handler,只是 Handler 会通过 VIP 去 .rdata 中读取 VM Code。
准备 VM Stack 和 Context Slots
接下来,VM Entry 会把当前 RSP 保存到 R11,然后再将 RSP 向低地址移动:
mov r11, rsp ; R11作为VSPsub rsp, 0x180and rsp, -0x10 ; 16字节对齐在当前样本中:
执行前 RSP = 0x200017F68mov r11,rsp 后 R11/VSP = 0x200017F68sub rsp,0x180 = 0x200017DE8对齐后的 RSP = 0x200017DE0此时,同一片原生栈被划分成了两个逻辑区域:
• VM Stack:由R11/VSP指向,用于 VM 指令之间传递临时数据。• Context Slots:由RSP作为基地址,用于保存原函数的寄存器状态。

这里最容易混淆的是,RSP 和 VSP 都指向原生栈中的地址,但它们的职责已经完全不同:
RSP 固定指向 Context Slots 的基地址VSP / R11 随着VM的PUSH和POP不断移动Context Slots 一共占用 0x100 字节。其中前 24 个 Slot 用来保存寄存器状态,后面 8 个 Slot 为 Windows x64 异常处理预留。本文只关注前面的寄存器 Slots。
准备调度器
栈空间准备完成后,VM Entry 还需要初始化 Classic 调度器使用的另外两个状态:
mov rdi, rsi ; 以初始 VIP 作为 KEYsub rdi, load_base_deltalea r10, [handler_table] ; Handler Table基地址至此,当前样本中的几个重要寄存器如下:
RSP = 0x200017DE0 ; Context Slots 基地址VIP = RSI = 0x14006CC20 ; 下一条 VM CodeVSP = R11 = 0x200017F68 ; VM Stack 栈顶KEY = RDI = 0x14006CC20 ; Rolling KeyTABLE=R10 = 0x14007EF68 ; Handler Table 基地址虚拟机现在已经可以读取和调度 VM Code,但是进入时保存的寄存器还堆在 VM Stack 上,Context Slots 里面仍然没有正式的数据。
建立虚拟寄存器
这最后一步不再由一段特殊的原生代码完成,而是直接开始执行 VM Code。
VMP 在源码的 AddBeginSection() 中生成了一批 POP_REGISTER64 指令:
for (size_t i = registr_order->size(); i > 0; i--) { uint8_t reg = registr_order->at(i - 1); AddVMCommand(ctx, cmPop, otRegistr, size_, reg, options);}这些 VM Code 会逐个弹出刚才保存在 VM Stack 上的原生状态,再写入对应的 Context Slot。例如第一条 VM Code 是:
OP_POP_REGISTER64 0xB8它执行的逻辑如下:
value = [VSP] ; 取出 load-base deltaVSP += 8[RSP + 0xB8] = value ; 写入 Context Slot[23]后面的 VM Code 会用相同的方法,依次搬运 R9、R8、RCX、R11、RDX 等寄存器。最后两个 POP 则负责消费 Entry Stub 留下的临时返回地址和加密 VIP。

在当前样本中,这里一共执行 19 条 POP_REGISTER64。全部执行完成以后:
RSP = 0x200017DE0 ; Context Slots 基地址保持不动VSP = 0x200018000 ; 回到最初的 Native RSP[VSP] = 0x140001118此时 VM Stack 已经重新变成空栈,原生调用者的返回地址仍然保存在栈顶;而进入函数时的寄存器和 RFLAGS,已经全部转移到 Context Slots 中。从此以后,原函数眼中的 RAX、RBX 等寄存器,实际上都是某个 Slot 中保存的值,真正的 CPU 寄存器则可以留给 Handler 使用。
到这里,原生程序的状态已经全部交给虚拟机管理,真正的函数逻辑才开始执行。
Classic调度器
Classic 虚拟机的核心,是一个不断循环的调度器。它每次只做四件事情:
1. 通过 VIP读取一条加密的 Opcode。2. 使用当前 KEY解密 Opcode。3. 用解密后的 Opcode 查询 Handler Table。4. 跳转到对应的 Handler 执行。

Handler 执行完成以后,不会直接执行下一条 VM 指令,而是重新跳回公共调度器,再重复一次上述过程。
这就是 Classic 与 Advanced 最明显的区别:Classic 有一条公共的调度路径,而 Advanced 会在 Handler 的尾部直接计算下一条 Handler 的地址。
另外,VM Code 并不是明文保存的。Opcode 和 Operand 都会被加密,并且每执行一步,KEY 都会跟着变化:
密文 + 当前 KEY ↓解密出 Opcode / Operand ↓使用解密结果更新 KEY ↓继续解密下一段数据这种密钥通常被称为 Rolling Key。因此,即使两条 VM 指令的内容完全相同,它们在文件中保存的二进制数据也不一定相同。
执行函数
现在回到 Demo 中最重要的一句代码:
mov rax, 0x1122334455667788它被 VMP 拆成了两条 VM Code:
0x14006CC69 OP_PUSH_VALUE64 0x11223344556677880x14006CC72 OP_POP_REGISTER64 0x38其中 0x38 并不是 RAX 的编号,而是当前代表 RAX 的 Context Slot 相对于 RSP 的偏移地址。因为每个 Slot 的大小是 8 字节,所以它实际指向的是:
0x38 / 8 = slot[7]下面按照虚拟机真实执行的顺序,分别来看这两条 VM Code。
OP_PUSH_VALUE64
VM Body 开始执行时,几个重要寄存器的状态如下:
VIP = 0x14006CC69 ; 指向第一条 VM CodeVSP = 0x200018000 ; VM Stack 当前为空RSP = 0x200017DE0 ; Context Slots 的基地址KEY = 0x14006CC8D ; 当前的 Rolling Key首先,调度器通过 VIP 读取一个字节:
0x14006CC69 48 14 1E EE DF 0F 91 8E F5 │ └───────────────────────┘ │ Operand └─ Opcode这里的 0x48 并不是真正的 Opcode,而是加密后的数据。调度器使用当前的 KEY 对它进行解密,得到:
0x48 ──解密──> 0x5C(OP_PUSH_VALUE64)然后再通过 Handler Table[0x5C],跳转到 PUSH_VALUE64 Handler:
mov rcx, [rsi] ; 读取8字节加密的Operandadd rsi, 8 ; VIP向后移动8字节; ... 解密Operandsub r11, 8 ; VSP -= 8mov [r11], rcx ; [VSP] = 解密后的常量Handler 使用自己的 Operand Cryptor,将后面的 8 字节数据解密为真正的常量:
14 1E EE DF 0F 91 8E F5 ↓ 解密0x1122334455667788最后,它将 VSP 向低地址移动 8 字节,把这个常量压入 VM Stack:
VSP -= 8[VSP] = 0x1122334455667788
执行完这条 VM Code 后,状态发生了三个变化:
VIP: 0x14006CC69 → 0x14006CC72 ; 指向下一条 OpcodeVSP: 0x200018000 → 0x200017FF8 ; VM Stack 多了一个值KEY: 使用解密后的 Opcode 和 Operand 更新Context Slots 没有发生变化,因为这条指令只是将数据压入 VM Stack,还没有修改任何虚拟寄存器。
Handler 执行完以后,再次回到 Classic 的公共调度器,开始读取下一条 VM Code。
OP_POP_REGISTER64 RAX
此时 VIP 已经指向 0x14006CC72:
0x14006CC72 EF F9 │ │ │ └─ 加密后的 Context Slot 偏移 └──── 加密后的 Opcode调度器读取并解密 0xEF,得到 OP_POP_REGISTER64,然后从 Handler Table 中找到对应的 Handler。
进入 Handler 时,VSP 正指向上一条指令压入 VM Stack 的常量:
VSP = 0x200017FF8[VSP] = 0x1122334455667788POP_REGISTER64 Handler 的核心代码如下:
mov rbx, [r11] ; 从 VM Stack 读取常量add r11, 8 ; VSP += 8movzx edx, byte [rsi] ; 读取加密后的 Operandadd rsi, 1 ; VIP += 1; ... 解密 Operand,得到0x38mov [rsp+rdx], rbx ; Context Slot[7] = 常量Operand 0xF9 解密后得到 0x38,因此最后一句真正访问的内存地址是:
RSP + 0x38= 0x200017DE0 + 0x38= 0x200017E18= Context Slot[7]于是,Handler 从 VM Stack 弹出常量,再将它写入当前代表 RAX 的 Slot:
value = [VSP]VSP += 8Context Slot[7/RAX] = value
执行完第二条 VM Code 后:
VIP: 0x14006CC72 → 0x14006CC74VSP: 0x200017FF8 → 0x200018000 ; VM Stack 重新变空Context Slot[7/RAX]: 尚未分配给 RAX → 0x1122334455667788RSP: 0x200017DE0 ; 始终没有变化从结果来看,它仍然只是完成了下面这件事情:
RAX = 0x1122334455667788;但是 CPU 并没有直接执行原来的 mov。它先运行调度器,再运行两个 Handler,中间还穿插了 Opcode 解密、Operand 解密、密钥更新和各种花指令。
因此,在 IDA 中看到的一大坨杂乱汇编,真正表达的业务逻辑可能只有一句代码。
退出虚拟机
执行完 mov 后,原函数只剩下最后一句 ret。
但是虚拟机不能直接执行一个原生 ret,因为此时原函数的寄存器状态还在 Context Slots 中,CPU 的物理寄存器则保存着虚拟机自己的运行状态。
因此,在源码的 AddEndSection() 中,VMP 会生成一批 PUSH_REGISTER 指令,将各个 Context Slot 中的最终值重新压回原生栈:
for (size_t i = 0; i < registr_order->size(); i++) { uint8_t reg = registr_order->at(i); AddVMCommand(ctx, cmPush, otRegistr, size_, reg, options);}AddVMCommand(ctx, cmRet, otNone, size_, 0, options);最后,RET Handler 会将 VM 栈切换回原生栈,依次恢复所有寄存器和 RFLAGS,再通过真正的 ret 返回调用者。

此时,保存在 Context Slot 中的常量终于被恢复到真正的 RAX 寄存器中:
RAX = 0x1122334455667788对于调用者而言,这依然只是一次再普通不过的函数调用。它完全不知道中间曾经进入过一台虚拟机。
结语
回过头来看,VMP 的 Classic 代码虚拟化可以概括为三个步骤:
1. 将原始 x86 指令拆解成一组基于栈的 VM 指令。 2. 生成调度器和各种 Handler,逐条解释执行这些 VM 指令。 3. 使用 Context Slots保存原函数状态,在进入和退出虚拟机时完成状态转换。
它最厉害的地方,并不是某一种加密算法有多复杂,而是彻底切断了“文件中的机器码”和“原始程序语义”之间的直接关系。
逆向者看到的是 Handler、加密的 VM Code、不断变化的密钥和被打乱的控制流;而真正的业务逻辑,则隐藏在这些 Handler 对 VM 栈和 Context Slots 的一次次修改之中。
这也是代码虚拟化比代码变异更加难以分析的根本原因。















































































