一个基于虚拟机壳思想的CrackMe的实现
本帖最后由 airshelf 于 2018-7-26 11:22 编辑一个基于虚拟机壳思想的CrackMe的实现
===
### 1、虚拟机壳原理概述
基于虚拟机的代码保护也可以算是代码混淆技术的一种。代码混淆的目的就是防止代码被逆向分析,但是所有的混淆技术都不是完全不能被分析出来,只是增加了分析的难度或者加长了分析的时间,虽然这些技术对保护代码很有效果,但是也存在着副作用,比如会或多或少的降低程序效率,这一点在基于虚拟机的保护中格外突出,所以大多基于虚拟机的保护都只是保护了其中比较重要的部分。在基于虚拟机的代码保护中可以大致分为两种:
>(1)使用虚拟机解释执行解壳代码。这种混淆是为了隐藏原代码是如何被加密的,又是如何被解壳代码解密的。这种方式对于静态分析来说比较有效,但是对于动态调试效果不大。因为动态调试的时候完全可以等到解壳代码解密初源代码之后进行脱壳。只有配合其他保护技术才会有比较强的保护效果。
>(2)把需要保护的程序源码转换为自定义字节码,再使用虚拟机解释执行被转换后的程序字节码,而程序的源码是不会出现在程序中的。这种方式不管静态还是动态都可以有效的保护。
第一种只保护解壳代码,没有保护源码。第二种直接保护了所有源码。所以第一种的强度也小于第二种。本文则是以第二种方式来实现保护,也就是保护所有源码。
在基于虚拟机的保护技术中,通常自定义的字节码与native指令都存在着映射关系,也就是说一条或多条字节码对应于一条native指令。至于为什么需要多条字节码对应同一条native指令,其实是为了增加虚拟机保护被破解的难度,这样在对被保护的代码进行转换的时候就可以随机生成出多套字节码不同,但执行效果相同的程序,导致逆向分析时的难度增加。
---
### 2、实现具体过程
#### 2.1 定义字节码
字节码只是一个标识,可以随意定义,以下是我定义的字节码,其中每条指令标识都对应于一个字节。
```
enum OPCODES
{
MOV = 0xa0, // mov 指令字节码对应 0xa0
XOR = 0xa1, // xor 指令字节码对应 0xa1
CMP = 0xa2, // cmp 指令字节码对应 0xa2
RET = 0xa3, // ret 指令字节码对应 0xa3
SYS_READ = 0xa4, // read 系统调用字节码对应 0xa4
SYS_WRITE = 0xa5, // write 系统调用字节码对应 0xa5
JNZ = 0xa6 // jnz 指令字节码对应 0xa0
};
```
由于只是简单的demo,所以仅定义如上常用指令。
#### 2.2 实现虚拟CPU结构
在定义好指令对应的字节码之后,就可以实现一个解释器用来解释上面定义的指令字节码了。在实现虚拟机解释器之前需要先搞清楚我们都要虚拟出一些什么。
一个虚拟机其实就是虚拟出一个程序(自定义的字节码)运行的环境,其实这里的虚拟机在解释执行字节码时与我们真实处理器执行很相似。在物理机中的程序都需要一个执行指令的处理器、栈、堆等环境才可以运行起来,所以首当其冲需要虚拟出一个处理器,处理器中需要有一些寄存器来辅助计算,以下是我定义的虚拟处理器:
```
/*
* virtual processor
*/
typedef struct processor_t
{
int r1; // 虚拟寄存器r1
int r2; // 虚拟寄存器r2
int r3; // 虚拟寄存器r3
int r4; // 虚拟寄存器r4
int flag; // 虚拟标志寄存器flag,作用类似于eflags
unsigned char *eip; // 虚拟机寄存器eip,指向正在解释的字节码地址
vm_opcode op_table; // 字节码列表,存放了所有字节码与对应的处理函数
} vm_processor;
/*
* opcode struct
*/
typedef struct opcode_t
{
unsigned char opcode; // 字节码
void (*func)(void *); // 与字节码对应的处理函数
} vm_opcode;
```
上面结构中r1~r4是4个通用寄存器,用来传参数和返回值。eip则指向当前正在执行的字节码地址。op_table中存放了所有字节码指令的处理函数。
上面的两个虚拟出的结构就是虚拟机的核心,之后解释器在解释字节码的时候都是围绕着以上两个结构的。因为demo程序逻辑简单,所以只需要虚拟出一个处理器就可以了,堆和栈都不是必须的。程序中的数据我用了一个buffer来存储,也可以把整个buffer理解成堆或者是栈。
#### 2.3 实现解释器
有了上面两个结构之后,就可以来动手写解释器了。解释器的工作其实就是判断当前解释的字节码是否可以解析,如果可以就把相应参数传递给相应的处理函数,让处理函数来解释执行这一条指令。以下是解释器代码:
```
void vm_interp(vm_processor *proc)
{
/* eip指向被保护代码的第一个字节
* target_func + 4是为了跳过编译器生成的函数入口的代码
*/
proc->eip = (unsigned char *) target_func + 4;
// 循环判断eip指向的字节码是否为返回指令,如果不是就调用exec_opcode来解释执行
while (*proc->eip != RET)
{
exec_opcode(proc);
}
}
```
其中target_func是自定义字节码编写的目标函数,是eip指向目标函数的第一个字节,准备解释执行。当碰到RET指令就结束,否则调用**exec_opcode**执行字节码。
以下是**exec_opcode**代码
```
void exec_opcode(vm_processor *proc)
{
int flag = 0;
int i = 0;
// 查找eip指向的正在解释的字节码对应的处理函数
while (!flag && i < OPCODE_NUM)
{
if (*proc->eip == proc->op_table.opcode)
{
flag = 1;
// 查找到之后,调用本条指令的处理函数,由处理函数来解释
proc->op_table.func((void *) proc);
}
else
{
i++;
}
}
}
```
**exec_opcode**主要功能是查找eip指向的正在解释的字节码对应的处理函数,并交由该处理函数进行解释。
#### 2.4 定义每个处理函数
共对(xor,cmp,jnz,ret,read,write,mov)进行了处理函数的编写。
```
/*
* xor 指令解释函数
*/
void vm_xor(vm_processor *proc)
{
// 异或的两个数据分别存放在r1,r2寄存器中
int arg1 = proc->r1;
int arg2 = proc->r2;
// 异或结果存在r1中
proc->r1 = arg1 ^ arg2;
// xor指令只占一个字节,所以解释后,eip向后移动一个字节
proc->eip += 1;
}
```
```
/*
* cmp 指令解释函数
*/
void vm_cmp(vm_processor *proc)
{
// 比较的两个数据分别存放在r1和buffer中
int arg1 = proc->r1;
// 字节码中包含了buffer的偏移
unsigned char *arg2 = proc->eip + 1;
// 比较并对flag寄存器置位,1为相等,0为不等
if (arg1 == *arg2) {
proc->flag = 1;
}
else {
proc->flag = 0;
}
// cmp指令占两个字节,eip向后移动2个字节
proc->eip += 2;
}
```
```
/*
* jnz 指令解释函数
*/
void vm_jnz(vm_processor *proc)
{
// 获取字节码中需要的地址相距eip当前地址的偏移
unsigned char arg1 = *(proc->eip + 1);
// 通过比较flag的值来判断之前指令的结果,如果flag为零说明之前指令不相等,jnz跳转实现
if (proc->flag == 0) {
// 跳转可以直接修改eip,偏移就是上面获取到的偏移
proc->eip += arg1;
}
else {
proc->flag = 0;
}
// jnz 指令占2个字节,所以eip向后移动两个字节
proc->eip += 2;
}
```
```
/*
* ret 指令解释函数
*/
void vm_ret(vm_processor *proc)
{
}
```
```
void vm_read(vm_processor *proc)
{
// read系统调用有两个参数,分别存放在r1,r2寄存器中,r1中是保存读入数据的buf的偏移,r2为希望读入的长度
char *arg2 = heap_buf + proc->r1;
int arg3 = proc->r2;
char p;
// 直接调用read
//read(0, arg2, arg3);
scanf_s("%s", arg2,arg3);
// read系统调用占1个字节,所以eip向后移动1个字节
proc->eip += 1;
}
```
```
/*
* write 系统调用解释函数
*/
void vm_write(vm_processor *proc)
{
// 与read系统调用相同,r1中是保存写出数据的buf的偏移,r2为希望写出的长度
char *arg2 = heap_buf + proc->r1;
int arg3 = proc->r2;
// 直接调用write
for (int i = 0; i < arg3; i++)
{
printf("%c", arg2);
}
// write系统调用占1个字节,所以eip向后移动1个字节
proc->eip += 1;
}
```
```
/*
* mov 指令解释函数
*/
void vm_mov(vm_processor *proc)
{
// mov 指令两个参数都隐含在字节码中了,指令标识后的第一个字节是寄存器的标识,指令标识后的第二到第五个字节是要mov的立即数,目前只实现了mov一个立即数到一个寄存器中和mov一个buffer中的内容到一个r1寄存器
unsigned char *dest = proc->eip + 1;
int *src = (int *)(proc->eip + 2);
// 前4个case分别对应r1~r4,最后一个case中,*src保存的是buffer的一个偏移,实现了把buffer中的一个字节赋值给r1
switch (*dest) {
case 0x10:
proc->r1 = *src;
break;
case 0x11:
proc->r2 = *src;
break;
case 0x12:
proc->r3 = *src;
break;
case 0x13:
proc->r4 = *src;
break;
case 0x14:
proc->r1 = *(heap_buf + *src);
break;
}
// mov指令占6个字节,所以eip向后移动6个字节
proc->eip += 6;
}
```
#### 2.5 自行编写真正需要执行的字节码
```
unsigned char TEST[] = {
0xa0, 0x10, 0x00, 0x00, 0x00, 0x00,
//mov R1,0x00000000
0xa0, 0x11, 0x12, 0x00, 0x00, 0x00,
//mov R2,0x00000012
0xa4,
//CALL SYS_READ
0xa0, 0x14, 0x00,0x00, 0x00, 0x00,
//MOV R1,0x00000000
0xa0, 0x11, 0x29, 0x00, 0x00, 0x00,
//MOV R2,0x00000029
0xa1,
//XOR R1,R2
0xa2, 0x48,
//CMP R1,0x48
0xa6, 0x5b,
//JNZ (eip+0x5b)
0xa0, 0x14, 0x01, 0x00, 0x00, 0x00,
//MOV R1,0x00000001
0xa1,
//XOR R1,R2
0xa2, 0x40,
//CMP R1,0x40
0xa6, 0x50,
//JNZ (eip+0x50)
0xa0, 0x14, 0x02, 0x00, 0x00, 0x00,
//MOV R1,0x00000002
0xa1,
//XOR R1,R2
0xa2, 0x5b,
//CMP R1,0x5b
0xa6, 0x45,
//JNZ (eip+0x45)
0xa0, 0x14, 0x03, 0x00, 0x00, 0x00,
//MOV R1,0x00000003
0xa1,
//XOR R1,R2
0xa2, 0x5a,
//CMP R1,0x5a
0xa6, 0x3a,
//JNZ (eip+0x3A)
0xa0, 0x14, 0x04, 0x00, 0x00, 0x00,
//MOV R1,0x00000004
0xa1,
//XOR R1,R2
0xa2, 0x41,
//CMP R1,0x41
0xa6, 0x2f,
//JNZ (eip+0x2F)
0xa0, 0x14, 0x05, 0x00, 0x00, 0x00,
//MOV R1,0x00000005
0xa1,
//XOR R1,R2
0xa2, 0x4c,
//CMP R1,0x4c
0xa6, 0x24,
//JNZ (eip+0x24)
0xa0, 0x14, 0x06, 0x00, 0x00, 0x00,
//MOV R1,0x00000006
0xa1,
//XOR R1,R2
0xa2, 0x45,
//CMP R1,0x45
0xa6, 0x19,
//JNZ (eip+0x19)
0xa0, 0x14, 0x07, 0x00, 0x00, 0x00,
//MOV R1,0x00000007
0xa1,
//XOR R1,R2
0xa2, 0x4f,
//CMP R1,0x4f
0xa6, 0x0f,
//JNZ (eip+0x0F)
0xa0, 0x10, 0x30, 0x00, 0x00, 0x00,
//MOV R1,0x00000030
0xa0, 0x11, 0x09, 0x00, 0x00, 0x00,
//MOV R2,0x00000009
0xa5,
//CALL PRINT
0xa3,
//RET
0xa0, 0x10, 0x40, 0x00, 0x00, 0x00,
//MOV R1,0x00000040
0xa0, 0x11, 0x07, 0x00, 0x00, 0x00,
//MOV R1,0x00000007
0xa5,
//CALL PRINT
0xa3
//RET
};
```
---
### 3、实现效果
![](https://i.imgur.com/E1bOqHY.png)
输入正确的FLAG后:
![](https://i.imgur.com/OHi0CHA.png)
---
### 4、小结
在本文,实现了一个虚拟的字节码解释器,并按照自定义的字节码编写了一段需要虚拟解释器解释的字节数组,完成了一道Crackme的实验。
欢迎有兴趣的童鞋下载附件中的该Crackme进行研究。
感谢分享,学到了,这很虚拟机。
我觉得既然都实现了虚拟机的功能,还不如借鉴下 VMProtect,这样效率倒是提高了不少:(不过可能只适合我这些懒虫吧
and al, 3Ch
mov edx,
sub ebp, 4
mov , edx
/*
* mov 指令解释函数
*/
void vm_mov(vm_processor *proc)
{
// mov 指令两个参数都隐含在字节码中了,指令标识后的第一个字节是寄存器的标识,指令标识后的第二到第五个字节是要mov的立即数,目前只实现了mov一个立即数到一个寄存器中和mov一个buffer中的内容到一个r1寄存器
unsigned char *dest = proc->eip + 1;
unsigned int *vm_context = (unsigned int*)proc;
int *src = (int *)(proc->eip + 2);
// 前4个case分别对应r1~r4,最后一个case中,*src保存的是buffer的一个偏移,实现了把buffer中的一个字节赋值给r1
vm_context[*dest - 0x10] = *src;
// mov指令占6个字节,所以eip向后移动6个字节
proc->eip += 6;
} 好的,谢谢了 楼主是否可以提供完整的源码呢 苏紫方璇 发表于 2018-7-26 11:18
楼主是否可以提供完整的源码呢
嗯嗯,好的,已上传 airshelf 发表于 2018-7-26 11:24
嗯嗯,好的,已上传
好的,帖子写的挺好的,已加精处理,期待您的下次发帖 必须滴要学习学习,好文章 好文章,多谢楼主分享!学习了 MARK一下 感谢分享,很有趣