指令虚拟化

虚拟机(VM)其实就是用软件来模拟硬件。我们可以仿照 x86 指令集,自己定义一套指令,在程序(解释器)中有一套函数和结构来解析自定义的指令并执行相应的功能。

虚拟化是一种基于虚拟机的代码保护技术。他将硬件支持的机器码转化为字节码指令系统,来达到不被轻易篡改和逆向的目的。

简单来说就是出题人通过实现一个小型的虚拟机,自定义一些操作码(opcode),然后在程序执行时通过解释操作码,执行对应的函数,从而实现程序原有的功能。

下图是常见的虚拟机结构:

image-20240123191130428

虚拟机的主程序其实就是一个循环,这个循环不断的去读取指令(伪机器码 opcode),然后执行指令opcode 所对应的一些函数,这样下来就可以与真实的程序执行相差无几。

正向实现

想要对抗虚拟化,首先要搞清楚用于保护的虚拟机是如何实现的要想实现虚拟机,需要完成两个目标:

  1. 定义一套指令集

  2. 实现对应的解释器

结构体定义

真实赛题中的 VM 通常会实现一个类似如下的结构体,用于保存虚拟机状态:

1
2
3
4
5
6
7
8
typedef struct
{
unsigned int r1; // 虚拟寄存器 r1
unsigned int r2; // 虚拟寄存器 r2
unsigned int eip; // 指向正在解释的 opcode 地址
unsigned char mem[256]; // 虚拟内存
unsigned char code[1024]; // 存放自定义机器码
} VM;

opcode 定义

接着自定义一些指令,需要决定该指令集是定长的还是变长的。

这里以变长指令集为例,先列出一个表来:

image-20240123194824707

书写机器码

假定希望实现的语义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>
#include <string.h>
int main() {
char input[6];
char target[] = "Hello";
scanf("%5s", input);
for (int i = 0; i < 5; i++) {
input[i] ^= 0x21;
}
if (!memcmp(input, target, 5))
printf("Yes\n");
else
printf("No\n");
}

根据我们定义的指令对其进行拆分和重构,可以得到如下机器码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
unsigned char code[] = {
0x20, 0x10, 0x48, // mem[0x10] = 'H'
0x20, 0x11, 0x65, // mem[0x11] = 'e'
0x20, 0x12, 0x6c, // mem[0x12] = 'l'
0x20, 0x13, 0x6c, // mem[0x13] = 'l'
0x20, 0x14, 0x6f, // mem[0x14] = 'o'
0x40, // scanf("%5s", &mem[0]);
0x11, 0x21, // r2 = 0x21
0x10, 0x0, // r1 = 0
0x30, // mem[r1] ^= r2
0x10, 0x1, // r1 = 1
0x30,
0x10, 0x2, // r1 = 2
0x30,
0x10, 0x3, // r1 = 3
0x30,
0x10, 0x4, // r1 = 4
0x30,
0x50, 0x10, 0x5 // return memcmp(&mem[0], &mem[0x10], 5);
};

初始化虚拟机

在实际运行虚拟机之前,需要先对 VM 结构体进行初始化:

1
2
3
4
5
6
VM* vm_new() {
VM* vm = (VM*)malloc(sizeof(VM)); // 申请内存
memset(vm, 0, sizeof(VM)); // 清空结构体字段
memcpy(vm->code, code, sizeof(code)); // 拷贝机器码
return vm;
}

解释器编写

现在就可以来实现每条指令的 handle 以及 dispatcher 了

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
int vm_run(VM* vm) {
char opcode;
char operand_1, operand_2;
while (1) {
opcode = vm->code[vm->eip];
switch (opcode) {
case 0x10:
operand_1 = vm->code[vm->eip + 1];
vm->r1 = operand_1;
vm->eip += 2;
break;
case 0x11:
operand_1 = vm->code[vm->eip + 1];
vm->r2 = operand_1;
vm->eip += 2;
break;
case 0x20:
operand_1 = vm->code[vm->eip + 1];
operand_2 = vm->code[vm->eip + 2];
vm->mem[operand_1] = operand_2;
vm->eip += 3;
break;
case 0x30:
vm->mem[vm->r1] ^= vm->r2;
vm->eip += 1;
break;
case 0x40:
scanf("%5s", &vm->mem[0]);
vm->eip += 1;
break;
case 0x50:
operand_1 = vm->code[vm->eip + 1];
operand_2 = vm->code[vm->eip + 2];
return memcmp(&vm->mem[0], &vm->mem[operand_1], operand_2);
}
}
}

启动

于是 main 函数可以这样写:

1
2
3
4
5
6
7
int main() {
VM* vm = vm_new();
if (!vm_run(vm))
printf("Yes\n");
else
printf("No\n");
}

解题步骤

遇到 VM 类的赛题,我们一般按照如下的步骤来解题:

  • 分析 VM 结构

    • 结构体大小
    • 有哪些字段(内存、寄存器)
  • 分析指令集

    • 指令长度是否可变
    • 每种指令的构成
    • 每种指令的含义(伪汇编)
    • VM 的退出条件
  • 编写 Python 版解释器,输出伪汇编代码

  • 阅读伪代码,分析程序流程,写出去虚拟化的原始代码

  • 书写解题脚本

本例中的 Python 版解释器如下:

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
code = [0x20, 0x10, 0x48, 0x20, 0x11, 0x65, 0x20, 0x12, 0x6c, 0x20, 0x13, 0x6c,
0x20, 0x14, 0x6f, 0x40, 0x11, 0x21, 0x10, 0x0, 0x30, 0x10, 0x1, 0x30, 0x10, 0x2,
0x30, 0x10, 0x3, 0x30, 0x10, 0x4, 0x30, 0x50, 0x10, 0x5]
ip = 0
r1 = r2 = 0
mem = [0] * 256
while True:
opcode = code[ip]
if opcode == 0x10 :
r1 = code[ip + 1]
print(f"mov r1, {hex(code[ip + 1])}")
ip += 2
elif opcode == 0x11 :
r2 = code[ip + 1]
print(f"mov r2, {hex(code[ip + 1])}")
ip += 2
elif opcode == 0x20 :
op1, op2 = code[ip + 1], code[ip + 2]
mem[op1] = op2
print(f"mov [{hex(op1)}], {hex(op2)}")
ip += 3
elif opcode == 0x30 :
mem[r1] ^= r2
print(f"xor [{hex(r1)}], {hex(r2)}")
ip += 1
elif opcode == 0x40 :
# flag = input().encode()
flag = b"iDMMN"
for i in range(len(flag)) :
mem[i] = flag[i]
ip += 1
elif opcode == 0x50 :
op1, op2 = code[ip + 1], code[ip + 2]
print(mem[0:op2] == mem[op1:op1 + op2])
break
else:
raise ValueError("unknown opcode")