Zj_W1nd's BLOG

虚拟机初探

2024/09/03

我们在干嘛?

简单来说,题目会给我们一个二进制程序,这个程序的功能会类似于一个计算机,接收我们输入的指令然后去执行,我们要做的是找到这个虚拟机里存在的漏洞并进行攻击。

我们怎么办?

攻击的漏洞肯定还会是底层上的那些漏洞,最终攻击的实现还是靠的ROP+onegadget(不过在这之前我们要劫持虚拟机的内存到实际进程的栈上),或是那些能拿shell的fsop方法(house of apple那种)。难的是在我们和实际的进程交互的中间加了一层他自定义的方式,我们只能遵守他的规则(很不爽但是没办法),因此就很考验我们的逆向功夫,能够理解他的这套规则,并能发现虚拟机中执行过程上的漏洞/溢出/检查不到位。

什么是vm?到手之后该干什么?

前面提到,vm是在我们和进程的交互中间套了一层需要我们逆向的规则,只是这层规则很类似于一个计算机,并且他内部的指令集一般来说也能对应上现实中的指令集,比如算术运算(加减乘除左右移位),赋值操作(mov,push,pop)等等(跳转例题中没有但不排除可能)。某种程度上来说这是减少我们理解负担的一个点吧。另外,和一个计算机一样,一般来说这个程序会维护自己的寄存器(肯定有,通用寄存器和pc应该是必须有的),主存(自己的堆/栈或是什么buffer),指令集(重要),然后允许我们按他的规则输入指令操控这个程序的内部控制流。

因此我们先要逆向分析他的实际控制流,捋清楚程序是怎么执行的。具体又分为好几个步骤。大概关键的点包括但不限于以下几点:

  • 逆向指令集都有什么,都是什么功能

  • 我们输入的字节流是如何被解析的,是如何转化为vm的控制信号的

  • 从输入字节流开始,虚拟程序是如何运作的,控制流是否有漏洞

  • 重点关注那些和进程实际内存操作有关的动作

  • 结构体还原,命名核心域

  • 选好入口,vm逆向分析很容易像无头苍蝇乱撞,例题中一个很好的入口是解析指令的函数vm_id::run和构造函数、执行函数对比来确定结构体成员

这个程序的运行逻辑很像一个计算机,因此可以参考计算机组成原理的内容进行分析和理解。比如对于指令集中的一条指令,要有操作码,操作数(单操作数,双、多操作数?),寻址方式等等。对于一条指令就要重点关注这几点,找出操作码,操作数,寻址方式标记的字段(一般不会太复杂,立即数,寄存器,寄存器相对就不少了)。

对于寄存器,可以找找他的pc在哪维护,可以找找通用寄存器,rsp和rbp这种栈寄存器的位置。在一起维护最好。

ciscn2024 magic_vm

这个vm很好心的给我们留下了函数符号表(虽然是c++但是也很友好了),但是也非常的神秘,首先先胡乱搞了一通,简单的做了点结构体还原的工作(但是不准,有很多错误)。然后静下心参考网上的好几个wp捋了一下思路。下面大概记录一下,截止到写blog的时候其实还没有完全懂,网上的exp虽然思路相似但全都打不通。

修复结构体

首先,从保留的符号可以看出原本是有这么几个结构体:虚拟机vm,vm_mem, vm_id, vm_alu。(后面证明这个命名是一拖史)挨个试着去还原了一下。其实有很多字段的内容不看控制流先还原是非常令人迷惑的,后面看了这个神秘操作才懂。我们首先能知道的是程序的入口是main->vm.run,这里面是一个while(1)的大循环,先对vm中的vm_mem和vm_alu进行赋值,然后调用vm_alu.run进行指令执行但是又接着调用vm_mem.run进行了一些赋值操作。

这是还原后的入口vm::run

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
__int64 __fastcall vm::run(vm *my_vm)
{
__int64 v1; // rax
int cur_instruct_result; // [rsp+1Ch] [rbp-4h]
while ( 1 )
{
vm_alu::set_input(my_vm->ALU_last_instruct, my_vm);// 并非初始化,这里是从vm_id结构体copy了alu状态的一些参数
vm_mem::set_input((vm_mem *)my_vm->vm_mem, my_vm);// 这里是从alu状态里再copy一些mem参数??
my_vm->pc += (int)vm_id::run((vm_id *)my_vm->vm_id, my_vm); //解析指令
cur_instruct_result = vm_alu::run(my_vm->ALU_last_instruct, my_vm);// 运行指令
vm_mem::run((vm_mem *)my_vm->vm_mem, my_vm);
if ( !cur_instruct_result )
break;
if ( cur_instruct_result == -1 )
{
v1 = std::operator<<<std::char_traits<char>>(&std::cout, "SOME STHING WRONG!!");
std::ostream::operator<<(v1, &std::endl<char,std::char_traits<char>>);
exit(0);
}
}
return 0LL;
}

解析指令集

这里关键是三个run函数,这里自己分析的时候开始忽略了的vm_id.run才是最重要的之一,这个函数实现了对我们指令的解析。对照这个函数、vm的构造函数和alu.run可以大概把结构体分析的差不多了。

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
// The following explaination is generated by MLM DecompilerThis code snippet is a
// function that takesa virtual machine instruction and executes it. The purpose of
// this function is to analyze the instruction anddetermine if it is valid or
// not.It checks the type of instruction, theregisters ituses, and the addressing
// mode. If the instructionisvalid, it sets the opcode, field3,arg1, and arg2
// values. If the instruction is invalid, it sets theopcode to -1. The function
// also checks for any null pointers and sets the is_nop flagto true.
//
// This
// functionisuseful in virtual machineemulators andcompilers. It ensures that the
// virtual machine executes only valid instructions and prevents anyerrors or
// security vulnerabilities. It can also be used to optimize code by
// identifyingredundant or unnecessary instructions.
// 预读取指令并对内容做检查
__int64 __fastcall vm_id::run(vm_id *vm_id, vm *my_vm)
{
char *instruct_ptr; // rax
char *where_op_froma; // rax
int v4; // eax
__int64 *v5; // rax
__int64 *v6; // rax
int v7; // eax
char *where_op_from; // rax
char code; // [rsp+18h] [rbp-18h]
char v11; // [rsp+19h] [rbp-17h]
char v12; // [rsp+1Ah] [rbp-16h]
char v13; // [rsp+1Ah] [rbp-16h]
char v14; // [rsp+1Ah] [rbp-16h]
char v15; // [rsp+1Ah] [rbp-16h]
char v16; // [rsp+1Ah] [rbp-16h]
char v17; // [rsp+1Bh] [rbp-15h]
unsigned int len; // [rsp+1Ch] [rbp-14h]
_BYTE *where_op_from2; // [rsp+20h] [rbp-10h]
__int64 *arg1_1; // [rsp+20h] [rbp-10h]
char *arg1; // [rsp+20h] [rbp-10h]

instruct_ptr = (char *)(my_vm->text_buf + my_vm->pc);
where_op_from2 = instruct_ptr + 1;
code = *instruct_ptr;
len = 1;
if ( *instruct_ptr <= 0 || code > 8 )
{
if ( code <= 8 || code > 10 )
{
if ( code && code != 11 )
{
vm_id->op_code = -1LL;
}
else // halt
{
vm_id->op_code = code;
vm_id->where_op_from = 0LL;
vm_id->arg1 = 0LL;
vm_id->arg2 = 0LL;
}
}
else
{
where_op_from = instruct_ptr + 1;
arg1 = where_op_from2 + 1;
v17 = *where_op_from;
len = 2;
vm_id->where_op_from = *where_op_from;
if ( (v17 & 3) == 2 )
{
len = 3;
v16 = *arg1;
if ( (unsigned int)vm_id::check_regs(vm_id, *arg1, my_vm) )
{
vm_id->op_code = code;
vm_id->arg1 = v16;
vm_id->arg2 = 0LL;
}
else
{
vm_id->op_code = -1LL;
}
}
else
{
vm_id->op_code = -1LL;
}
if ( (my_vm->my_rsp & 7) != 0 )
vm_id->op_code = -1LL;
if ( code == 9 )
{
if ( my_vm->my_rsp >= (unsigned __int64)my_vm->stack_size || my_vm->my_rsp <= 7uLL )
vm_id->op_code = -1LL;
}
else if ( (unsigned __int64)(my_vm->stack_size - 8) < my_vm->my_rsp )
{
vm_id->op_code = -1LL;
}
}
}
else
{
where_op_froma = instruct_ptr + 1;
arg1_1 = (__int64 *)(where_op_from2 + 1);
v11 = *where_op_froma;
len = 2;
vm_id->where_op_from = *where_op_froma;
v4 = v11 & 3;
if ( v4 == 2 )
{
len = 3;
v5 = arg1_1;
arg1_1 = (__int64 *)((char *)arg1_1 + 1);
v12 = *(_BYTE *)v5;
if ( (unsigned int)vm_id::check_regs(vm_id, *(char *)v5, my_vm) )
{
vm_id->op_code = code;
vm_id->arg1 = v12;
}
else
{
vm_id->op_code = -1LL;
}
}
else if ( v4 == 3 )
{
len = 3;
v6 = arg1_1;
arg1_1 = (__int64 *)((char *)arg1_1 + 1);
v13 = *(_BYTE *)v6;
if ( (unsigned int)vm_id::check_addr(vm_id, my_vm->regs[*(char *)v6], my_vm) )
{
vm_id->op_code = code;
vm_id->arg1 = v13;
}
else
{
vm_id->op_code = -1LL;
}
}
else
{
vm_id->op_code = -1LL;
}
if ( vm_id->op_code != -1 )
{
v7 = (v11 >> 2) & 3;
if ( v7 == 3 )
{
++len;
v15 = *(_BYTE *)arg1_1;
if ( (unsigned int)vm_id::check_addr(vm_id, my_vm->regs[*(char *)arg1_1], my_vm) )
vm_id->arg2 = v15;
else
vm_id->op_code = -1LL;
}
else
{
if ( ((v11 >> 2) & 3u) > 3 )
{
LABEL_25:
vm_id->op_code = -1LL;
goto LABEL_45;
}
if ( v7 == 1 )
{
len += 8;
vm_id->arg2 = *arg1_1;
}
else
{
if ( v7 != 2 )
goto LABEL_25;
++len;
v14 = *(_BYTE *)arg1_1;
if ( (unsigned int)vm_id::check_regs(vm_id, *(char *)arg1_1, my_vm) )
vm_id->arg2 = v14;
else
vm_id->op_code = -1LL;
}
}
}
}
LABEL_45:
vm_id->is_nop = 1;
return len;
}

可以看到这里面从我们的输入取指令进行操作然后对vm_id结构体的成员进行赋值,这说明这个函数将一条指令解析后的结果放在vm_id这么一个结构体中(里面保存操作码,操作数和寻址方法等等)。同时从这个函数也能分析出我们指令的输入格式,第一字节是操作码,第二字节是寻址方式(0-1是第一操作数的,2-3是第二操作数的),然后是第一操作数和第二操作数。

放上alu.run对比参考,可以得到各种寻址方式对应的值

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
__int64 __fastcall vm_alu::run(struct vm_opcode *cur_instruct, vm *vm)
{
unsigned __int64 arg2_from; // rax
__int64 arg1_from; // rax
unsigned __int64 op_code; // rax
if ( !cur_instruct->is_nop )
return 1LL;
if ( cur_instruct->op_code && cur_instruct->op_code <= 8uLL )
{
arg2_from = ((unsigned __int64)cur_instruct->op_where_src >> 2) & 3;
if ( arg2_from == 3 )
{
cur_instruct->arg2 = *(_QWORD *)(vm->memory_after_code + vm->regs[cur_instruct->arg2]);
}
else if ( arg2_from != 1 )
{
if ( arg2_from != 2 )
return 0xFFFFFFFFLL;
cur_instruct->arg2 = vm->regs[cur_instruct->arg2];
}
arg1_from = cur_instruct->op_where_src & 3;
if ( arg1_from == 2 )
{
cur_instruct->arg1_dst_type = 1;
cur_instruct->arg1_addr = (__int64)&vm->regs[cur_instruct->arg1];
cur_instruct->arg1 = vm->regs[cur_instruct->arg1];
}
else
{
if ( arg1_from != 3 )
return 0xFFFFFFFFLL;
if ( (cur_instruct->op_where_src & 0xC) == 12 )
return 0xFFFFFFFFLL;
cur_instruct->arg1_dst_type = 1;
cur_instruct->arg1_addr = vm->memory_after_code + vm->regs[cur_instruct->arg1];
cur_instruct->arg1 = *(_QWORD *)(vm->memory_after_code + vm->regs[cur_instruct->arg1]);
}
switch ( cur_instruct->op_code )
{
case 1LL:
cur_instruct->res = cur_instruct->arg2 + cur_instruct->arg1;
break;
case 2LL:
cur_instruct->res = cur_instruct->arg1 - cur_instruct->arg2;
break;
case 3LL:
cur_instruct->res = cur_instruct->arg1 << cur_instruct->arg2;
break;
case 4LL:
cur_instruct->res = (unsigned __int64)cur_instruct->arg1 >> cur_instruct->arg2;
break;
case 5LL:
cur_instruct->res = cur_instruct->arg2;
break;
case 6LL:
cur_instruct->res = cur_instruct->arg2 & cur_instruct->arg1;
break;
case 7LL:
cur_instruct->res = cur_instruct->arg2 | cur_instruct->arg1;
break;
case 8LL:
cur_instruct->res = cur_instruct->arg2 ^ cur_instruct->arg1;
break;
default:
goto CONTINUE_RUN;
}
goto CONTINUE_RUN;
}
op_code = cur_instruct->op_code;
if ( op_code == 11 )
{
cur_instruct->run_state = 0;
return 1LL;
}
if ( op_code > 0xB )
return 0xFFFFFFFFLL;
if ( op_code == 10 )
{
cur_instruct->arg1_dst_type = 2;
cur_instruct->arg1_addr = (__int64)&vm->regs[cur_instruct->arg1];
cur_instruct->res = *(_QWORD *)(vm->my_rbp + vm->my_rsp);
cur_instruct->rsp_old = (__int64)&vm->my_rsp;
cur_instruct->rsp_cur = vm->my_rsp + 8;
goto CONTINUE_RUN;
}
if ( !op_code )
{
cur_instruct->run_state = 0;
return 0LL;
}
if ( op_code != 9 )
return 0xFFFFFFFFLL;
cur_instruct->arg1_dst_type = 2;
cur_instruct->arg1_addr = vm->my_rbp + vm->my_rsp - 8;
cur_instruct->res = vm->regs[cur_instruct->arg1];
cur_instruct->rsp_old = (__int64)&vm->my_rsp;
cur_instruct->rsp_cur = vm->my_rsp - 8;
CONTINUE_RUN:
cur_instruct->run_state = 1;
return 1LL;
}

到这里大概就能知道这些:
首先一字节的操作数开头,对应下表:(0参考vm_id的解析过程,最后并不报错而是类似于nop的返回)

opcode option
-1 illegal option
0 nop
1 add
2 sub
3 shl
4 shr
5 mov
6 and
7 or
8 xor
9 push
10 pop
11 nop

然后是寻址方式, 这个8位的字段只看低4位,这四位里的低2位代表arg1,高两位代表arg2.对于一个两位的flag,0x0似乎没定义,0x1是来自立即数(只在双操作数的时候生效),0x2代表寄存器寻址,0x3代表寄存器间接寻址。这个从各个不同操作数的check能够看出来,在0x3的时候会传入regs[*arg1]。

最后就是两个操作数arg1和arg2,二者在寄存器和寄存器间接寻址的时候都是一字节,在立即数寻址的时候arg2会是一个8字节整数。

分析控制流

注意主体的vmrun:

1
2
3
4
5
6
7
8
while ( 1 )
{
vm_alu::set_input(my_vm->ALU_last_instruct, my_vm);// 并非初始化,这里是从vm_id结构体copy了alu状态的一些参数
vm_mem::set_input((vm_mem *)my_vm->vm_mem, my_vm);// 这里是从alu状态里再copy一些mem参数??
my_vm->pc += (int)vm_id::run((vm_id *)my_vm->vm_id, my_vm);
cur_instruct_result = vm_alu::run(my_vm->ALU_last_instruct, my_vm);// 运行指令
vm_mem::run((vm_mem *)my_vm->vm_mem, my_vm);
}

跟进之后发现,第一行是将vm_id中解析出的的内容赋给了vm_alu,第二行则是接着从vm_alu的指令运行结果中继续读取涉及内存读写的操作进行实际赋值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct vm_opcode *__fastcall vm_alu::set_input(struct vm_opcode *alu, vm *my_vm)
{
struct vm_id *vm_id; // rdx
struct vm_opcode *result; // rax
__int64 op_code; // rbx
__int64 arg1; // rbx

// 注意赋值的右值都是vm_id
vm_id = (struct vm_id *)my_vm->vm_id;
result = alu;
op_code = vm_id->op_code;
*(_QWORD *)&alu->is_nop = *(_QWORD *)&vm_id->is_nop;
alu->op_code = op_code;
arg1 = vm_id->arg1;
alu->op_where_src = vm_id->where_op_from;
alu->arg1 = arg1;
alu->arg2 = vm_id->arg2;
return result;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
vm_mem *__fastcall vm_mem::set_input(vm_mem *this, vm *my_vm)
{
struct vm_opcode *ALU_last_instruct; // rdx
vm_mem *result; // rax
__int64 *arg1_addr; // rbx
__int64 rsp_old; // rbx

//注意赋值的右值都是vm_alu
ALU_last_instruct = my_vm->ALU_last_instruct;
result = this;
arg1_addr = (__int64 *)ALU_last_instruct->arg1_addr;
*(_QWORD *)&this->flag = *(_QWORD *)&ALU_last_instruct->run_state;
this->arg1_addr = arg1_addr;
rsp_old = ALU_last_instruct->rsp_old;
this->res = (__int64 *)ALU_last_instruct->res;
this->rsp_old = rsp_old;
this->rsp_cur = ALU_last_instruct->rsp_cur;
return result;
}

这两个函数执行完后是vm_id.run对指令进行解析,然后把字节流中的一条指令翻译出来放在vm_id中,然后是alurun对指令进行执行。很有意思的是,alu.run只是将结果放在了另一个结构体里,也就是另一个代表当前指令的vm_alu,里面保存有参数的相关信息和计算结果等等。事实上的写回等操作发生在下一行的vm_mem.run中:

1
2
3
4
5
6
7
8
9
10
11
result = this->flag;// 这个flag只要不是nop都会是1返回
if ( (_DWORD)result ){
for ( i = 0; ; ++i )
{
result = (unsigned int)this->result; // 就是1
if ( i >= (int)result )
break;
**(&this->arg1_addr + 2 * i) = *((_QWORD *)&this->res + 2 * i);
}
}
return result;

这里直到我写blog的时候才发现,result不是0就是1,事实上这个循环就只跑一次,之前还纳闷为什么会有数组还以为是什么错位的神秘定位()。那么这里就是简单的把res给arg1应该在的地方写入进去。

检查

这个检查非常的简单,在vm_id解析指令的时候进行。对于reg只检查id是否小于等于三而不检查内容,

弱点

回顾之前的主循环vm_run,解析指令后首先放在vm_id,指令要想被执行要在vm_alu中,而被解析的指令直到下一次循环开始才能通过vm_alu.setinput被二次解析准备执行。这还没完,执行完的指令如果要进行内存写,那么必须要在vm_mem中进行,而vm_mem来自于vm_alu,这个操作又要等到下一个循环的vm_mem.setinput才能执行!因此也就是说,一条内存操作的指令要3个循环才会被执行,而check发生在指令解析阶段,那么这个检查在一个循环内事实上和我们真正在执行的内存操作是完全不符的,这就是一个逻辑漏洞。

对于地址的检查

参考自https://akaieurus.github.io/2024/05/20/2024国赛初赛pwn-wp/#思路-2

轮次 vm_id vm_alu vm_mem
1 解析更改reg为非法值的指令
2 解析nop指令 执行更改reg为非法值的指令
3 解析mem操作指令(reg此时为合法值,检查通过) 执行nop指令 reg更改生效
4 执行mem操作指令
5 mem操作生效
  1. 第一轮的时候尽管指令非法但reg值是合法的,pass;

  2. 第二轮填nop让指令在vm_alu.run中跑一轮(没有检查)

  3. 第三轮解析下一个mem操作指令,但其在reg变为非法值之前,因此继续,此时reg被改。

  4. 在reg的非法值被检测到之前,其就会被前面解析的mem操作更新为合法值,这样我们每3个循环就可以执行一次任意地址写。

这里其实我没有完全懂exp(exp也打不通),不过思路是这样,中间不断填nop让指令能执行。由于虚拟机内存在mmap分配,想办法在虚拟机内算出libc地址保存后再用libc中的__environ变量泄露栈,最后利用偏移劫持vm寄存器到栈上执行rop,onegadget。

总结

去你妈的虚拟机,我不会逆向。

CATALOG
  1. 1. 我们在干嘛?
  2. 2. 我们怎么办?
  3. 3. 什么是vm?到手之后该干什么?
  4. 4. ciscn2024 magic_vm
    1. 4.1. 修复结构体
    2. 4.2. 解析指令集
    3. 4.3. 分析控制流
    4. 4.4. 检查
    5. 4.5. 弱点
  5. 5. 总结