tcache makes heap exploitation easy again
0x01 Tcache overview
在 tcache 中新增了两个结构体,分别是 tcache_entry 和 tcache_perthread_struct
/* We overlay this structure on the user-data portion of a chunk when the chunk is stored in the per-thread cache. */
typedef struct tcache_entry
{
struct tcache_entry *next;
} tcache_entry;
/* There is one of these for each thread, which contains the per-thread cache (hence "tcache_perthread_struct"). Keeping overall size low is mildly important. Note that COUNTS and ENTRIES are redundant (we could have just counted the linked list each time), this is for performance reasons. */
typedef struct tcache_perthread_struct
{
char counts[TCACHE_MAX_BINS];
tcache_entry *entries[TCACHE_MAX_BINS];
} tcache_perthread_struct;
static __thread tcache_perthread_struct *tcache = NULL;其中有两个重要的函数, tcache_get() 和 tcache_put():
static void
tcache_put (mchunkptr chunk, size_t tc_idx)
{
tcache_entry *e = (tcache_entry *) chunk2mem (chunk);
assert (tc_idx < TCACHE_MAX_BINS);
e->next = tcache->entries[tc_idx];
tcache->entries[tc_idx] = e;
++(tcache->counts[tc_idx]);
}
static void *
tcache_get (size_t tc_idx)
{
tcache_entry *e = tcache->entries[tc_idx];
assert (tc_idx < TCACHE_MAX_BINS);
assert (tcache->entries[tc_idx] > 0);
tcache->entries[tc_idx] = e->next;
--(tcache->counts[tc_idx]);
return (void *) e;
}
这两个函数会在函数 _int_free 和 __libc_malloc 的开头被调用,其中 tcache_put 当所请求的分配大小不大于0x408并且当给定大小的 tcache bin 未满时调用。一个tcache bin中的最大块数mp_.tcache_count是7。
再复习一遍 tcache_get() 的源码
在 tcache_get 中,仅仅检查了 tc_idx ,此外,我们可以将 tcache 当作一个类似于 fastbin 的单独链表,只是它的check,并没有 fastbin 那么复杂,仅仅检查 tcache->entries[tc_idx] = e->next;
0x02 Tcache Usage
内存释放:
可以看到,在free函数的最先处理部分,首先是检查释放块是否页对齐及前后堆块的释放情况,便优先放入tcache结构中。
内存申请:
在内存分配的malloc函数中有多处,会将内存块移入tcache中。
(1)首先,申请的内存块符合fastbin大小时并且在fastbin内找到可用的空闲块时,会把该fastbin链上的其他内存块放入tcache中。
(2)其次,申请的内存块符合smallbin大小时并且在smallbin内找到可用的空闲块时,会把该smallbin链上的其他内存块放入tcache中。
(3)当在unsorted bin链上循环处理时,当找到大小合适的链时,并不直接返回,而是先放到tcache中,继续处理。
代码太长就不全贴了,贴个符合fastbin 的时候
tcache 取出:在内存申请的开始部分,首先会判断申请大小块,在tcache是否存在,如果存在就直接从tcache中摘取,否则再使用_int_malloc分配。
在循环处理unsorted bin内存块时,如果达到放入unsorted bin块最大数量,会立即返回。默认是0,即不存在上限。
在循环处理unsorted bin内存块后,如果之前曾放入过tcache块,则会取出一个并返回。
0x03 Pwn Tcache
tcache poisoning
通过覆盖 tcache 中的 next,不需要伪造任何 chunk 结构即可实现 malloc 到任何地址。
以 how2heap 中的 tcache_poisoning 为例
看一下源码
运行结果是
分析一下,程序先申请了一个大小是 128 的 chunk,然后 free。128 在 tcache 的范围内,因此 free 之后该 chunk 被放到了 tcache 中,调试如下:
可以看到,此时第 8 条 tcache 链上已经有了一个 chunk,从 tcache_perthread_struct 结构体中也能得到同样的结论
然后修改 tcache 的 next
此时,第 8 条 tcache 链的 next 已经被改成栈上的地址了。接下来类似 fastbin attack,只需进行两次 malloc(128) 即可控制栈上的空间。
第一次 malloc
第二次 malloc,即可 malloc 栈上的地址了
可以看出 tcache posioning 这种方法和 fastbin attack 类似,但因为没有 size 的限制有了更大的利用范围。
tcache dup
类似 fastbin dup,不过利用的是 tcache_put() 的不严谨
可以看出,tcache_put() 的检查也可以忽略不计(甚至没有对 tcache->counts[tc_idx] 的检查),大幅提高性能的同时安全性也下降了很多。
因为没有任何检查,所以我们可以对同一个 chunk 多次 free,造成 cycliced list。
以 how2heap 的 tcache_dup 为例分析,源码如下:
调试一下,第一次 free
tcache 的第一条链放入了一个 chunk
第二次 free 时,虽然 free 的是同一个 chunk,但因为 tcache_put() 没有做任何检查,因此程序不会 crash
可以看出,这种方法与 fastbin dup 相比也简单了很多。
tcache perthread corruption
我们已经知道 tcache_perthread_struct 是整个 tcache 的管理结构,如果能控制这个结构体,那么无论我们 malloc 的 size 是多少,地址都是可控的。
这里没找到太好的例子,自己想了一种情况
设想有如下的堆排布情况
通过一些手段(如 tcache posioning),我们将其改为了
这样,两次 malloc 后我们就返回了 tcache_perthread_struct 的地址,就可以控制整个 tcache 了。
因为 tcache_perthread_struct 也在堆上,因此这种方法一般只需要 partial overwrite 就可以达到目的。
tcache house of spirit
拿 how2heap 的源码来讲:
攻击之后的目的是,去控制栈上的内容,malloc 一块 chunk ,然后我们通过在栈上 fake 的chunk,然后去 free 掉他,我们会发现
Tcache 里就存放了一块 栈上的内容,我们之后只需 malloc,就可以控制这块内存。
smallbin unlink
在smallbin中包含有空闲块的时候,会同时将同大小的其他空闲块,放入tcache中,此时也会出现解链操作,但相比于unlink宏,缺少了链完整性校验。因此,原本unlink操作在该条件下也可以使用。
tcache stashing unlink attack
这种攻击利用的是 tcache bin 有剩余(数量小于 TCACHE_MAX_BINS )时,同大小的small bin会放进tcache中(这种情况可以用 calloc 分配同大小堆块触发,因为 calloc 分配堆块时不从 tcache bin 中选取)。在获取到一个 smallbin 中的一个chunk后会如果 tcache 仍有足够空闲位置,会将剩余的 small bin 链入 tcache ,在这个过程中只对第一个 bin 进行了完整性检查,后面的堆块的检查缺失。当攻击者可以写一个small bin的bk指针时,其可以在任意地址上写一个libc地址(类似 unsorted bin attack 的效果)。构造得当的情况下也可以分配 fake chunk 到任意地址。
这里以 how2heap 中的 tcache_stashing_unlink_attack.c 为例。
我们按照释放的先后顺序称 smallbin[sz] 中的两个 chunk 分别为 chunk0 和 chunk1。我们修改 chunk1 的 bk 为 fake_chunk_addr。同时还要在 fake_chunk_addr->bk 处提前写一个可写地址 writable_addr 。调用 calloc(size-0x10) 的时候会返回给用户 chunk0 (这是因为 smallbin 的 FIFO 分配机制),假设 tcache[sz] 中有 5 个空闲堆块,则有足够的位置容纳 chunk1 以及 fake_chunk 。在源码的检查中,只对第一个 chunk 的链表完整性做了检测 __glibc_unlikely (bck->fd != victim) ,后续堆块在放入过程中并没有检测。
因为tcache的分配机制是 LIFO ,所以位于 fake_chunk->bk 指针处的 fake_chunk 在链入 tcache 的时候反而会放到链表表头。在下一次调用 malloc(sz-0x10) 时会返回 fake_chunk+0x10 给用户,同时,由于 bin->bk = bck;bck->fd = bin; 的unlink操作,会使得 writable_addr+0x10 处被写入一个 libc 地址。
这个 poc 用栈上的一个数组上模拟 fake_chunk 。首先构造出5个 tcache chunk 和2个 smallbin chunk 的情况。模拟 UAF 漏洞修改 bin2->bk 为 fake_chunk ,在 calloc(0x90) 的时候触发攻击。
我们在 calloc 处下断点,调用前查看堆块排布情况。此时 tcache[0xa0] 中有 5 个空闲块。可以看到 chunk1->bk 已经被改为了 fake_chunk_addr 。而 fake_chunk->bk 也写上了一个可写地址。由于 smallbin 是按照 bk 指针寻块的,分配得到的顺序应当是 0x0000000000603250->0x0000000000603390->0x00007fffffffdbc0 (FIFO) 。调用 calloc 会返回给用户 0x0000000000603250+0x10。
调用 calloc 后再查看堆块排布情况,可以看到 fake_chunk 已经被链入 tcache_entry[8] ,且因为分配顺序变成了 LIFO , 0x7fffffffdbd0-0x10 这个块被提到了链表头,下次 malloc(0x90) 即可获得这个块。
其 fd 指向下一个空闲块,在 unlink 过程中 bck->fd=bin 的赋值操作使得 0x00007fffffffdbd0+0x10 处写入了一个 libc 地址。
libc leak
在以前的libc 版本中,我们只需这样:
但是在2.26 之后的 libc 版本后,我们首先得先把tcache 填满:
之后,我们就可以 leak libc 了。
0x04 Tcache Check
在最新的 libc 的commit 中更新了 Tcache 的 double free 的check:
目前为止,只看到了在 free 操作的时候的 check ,似乎没有对 get 进行新的check。
0x05 The pwn of CTF
Challenge 1 : LCTF2018 PWN easy_heap
基本信息
远程环境中的 libc 是 libc-2.27.so ,所以堆块申请释放过程中需要考虑 Tcache 。
基本功能
输入函数:循环读入一个字节,如果出现 null 字节或是换行符则停止读入,之后对当前读入的末尾位置和 size 位置进行置零操作。
new: 使用
malloc(0xa8)分配一个块,记录下 size ,输入内容。free: 首先根据记录下的 size 对堆块进行
memset清零,之后进行常规 freeshow:使用 puts 进行输出
功能较为简单。
记录一个 chunk 结构的结构体:
使用了一个在堆上分配的结构来记录所有 Chunk 结构体,一共可以分配 10 个块。
程序的读入输入函数存在一个 null-byte-overflow 漏洞 ,具体见如下代码
利用思路
由于存在 tcache ,所以利用过程中需要考虑到 tcache 的存在。
通常来讲在堆程序中出现 null-byte-overflow 漏洞 ,都会考虑构造 overlapping heap chunk ,使得 overlapping chunk 可以多次使用 ,达到信息泄露最终劫持控制流的目的 。
null-byte-overflow 漏洞的利用方法通过溢出覆盖 prev_in_use 字节使得堆块进行合并,然后使用伪造的 prev_size 字段使得合并时造成堆块交叉。但是本题由于输入函数无法输入 NULL 字符,所以无法输入 prev_size 为 0x_00 的值,而堆块分配大小是固定的,所以直接采用 null-byte-overflow 的方法无法进行利用,需要其他方法写入 prev_size 。
在没有办法手动写入 prev_size ,但又必须使用 prev_size 才可以进行利用的情况下,考虑使用系统写入的 prev_size 。
方法为:在 unsorted bin 合并时会写入 prev_size,而该 prev_size 不会被轻易覆盖(除非有新的 prev_size 需要写入),所以可以利用该 prev_size 进行利用。
具体过程:
将
A -> B -> C三块 unsorted bin chunk 依次进行释放A 和 B 合并,此时 C 前的 prev_size 写入为 0x200
A 、 B 、 C 合并,步骤 2 中写入的 0x200 依然保持
利用 unsorted bin 切分,分配出 A
利用 unsorted bin 切分,分配出 B,注意此时不要覆盖到之前的 0x200
将 A 再次释放为 unsorted bin 的堆块,使得 fd 和 bk 为有效链表指针
此时 C 前的 prev_size 依然为 0x200(未使用到的值),A B C 的情况:
A (free) -> B (allocated) -> C (free),如果使得 B 进行溢出,则可以将已分配的 B 块包含在合并后的释放状态 unsorted bin 块中。
但是在这个过程中需要注意 tcache 的影响。
利用步骤
重排堆块结构,释放出 unsorted bin chunk
由于本题只有 10 个可分配块数量,而整个过程中我们需要用到 3 个 unsorted bin 的 chunk ,加上 7 个 tcache 的 chunk ,所以需要进行一下重排,将一个 tcache 的 chunk 放到 3 个 unsorted bin chunk 和 top chunk 之间,否则会触发 top 的合并。
重分配后的堆结构:
按照解析中的步骤进行 NULL 字节溢出触发
为了触发 NULL 字节溢出,我们需要使得解析中的 B 块可以溢出到 C 块中。由于题目中没有 edit 功能,所以我们需要让 B 块进入 tcache 中,这样就可以在释放后再分配出来,且由于 tcache 没有太多变化和检查,会较为稳定。
之后进行 A 块的释放(用来提供有效的可以进行 unlink 的 fd 和 bk 值)
现在堆块结构如下:
tcache bin 链表中,第一位的是 B 块,所以现在可以将 B 块进行分配,且进行 NULL 字符溢出。
在之后的步骤中,我们需要 A 处于 unsorted bin 释放状态,B 处于分配状态,C 处于分配状态,且最后可以在 tcache 块 7 个全满的情况下进行释放(触发合并),所以我们需要 7 个 tcache 都被 free 掉。
此时由于 B 块被分配为 tcache 块了,所以需要将防止 top 合并的 tcache 块释放掉。
之后就可以将 C 块释放,进行合并。
合并后的结构:
地址泄露
此时的堆已经出现交叉了,接下来将 A 大小从 unsorted bin 中分配出来,就可以使得 libc 地址落入 B 中:
堆结构:
tcache UAF attack
接下来,由于 B 块已经是 free 状态,但是又有指针指向,所以我们只需要再次分配,使得有两个指针指向 B 块,之后在 tcache 空间足够时,利用 tcache 进行 double free ,进而通过 UAF 攻击 free hook 即可。
完整 exploit
Challenge 2 : HITCON 2018 PWN baby_tcache
基本信息
远程环境中的 libc 是 libc-2.27.so 和上面的题目一样。
基本功能
程序的功能很简单 ,就2个功能 ,一个功能为 New 申请使用内存不大于 0x2000 的 chunk ,总共可以申请 10 块 ,通过 bss 段上的一个全局数组 arr 来管理申请的 chunk ,同时 bss 段上的数组 size_arr 来存储相应 chunk 的申请大小 size 。
程序的另外一个功能就是 delete ,删除所选的堆块 ,删除之前会事先把 chunk 的内容区域按照申请的 size 覆盖成 0xdadadada 。
程序的漏洞代码在功能 New 的时候 ,写完数据后 ,有一个 null-byte 溢出漏洞 ,具体如下 :
利用思路
程序的漏洞很容易发现 ,而且申请的 chunk 大小可控 ,所以一般考虑构造 overlapping chunk 处理 。但是问题在于即使把 main_arena 相关的地址写到了 chunk 上 ,也没法调用 show 功能做信息泄露 ,因为程序就没提供这个功能 。
于是有两种思路:
可以考虑 partial overwrite 去改掉 main_arena 相关地址的后几个字节 ,利用 tcache 机制把
__free_hookchunk 写进 tcache 的链表中 ,后面利用 unsortedbin attack 往__free_hook里面写上 unsortedbin addr ,后面把__free_hook分配出来 ,再利用 partial overwrite 在__free_hook里面写上 one_shoot ,不过这个方法的爆破工作量太大需要 4096 次通过 IO file 进行泄露。题目中使用到了
puts函数,会最终调用到_IO_new_file_overflow,该函数会最终使用_IO_do_write进行真正的输出。在输出时,如果具有缓冲区,会输出_IO_write_base开始的缓冲区内容,直到_IO_write_ptr(也就是将_IO_write_base一直到_IO_write_ptr部分的值当做缓冲区,在无缓冲区时,两个指针指向同一位置,位于该结构体附近,也就是 libc 中),但是在setbuf后,理论上会不使用缓冲区。然而如果能够修改_IO_2_1_stdout_结构体的 flags 部分,使得其认为 stdout 具有缓冲区,再将_IO_write_base处的值进行 partial overwrite ,就可以泄露出 libc 地址了。
思路 2 中涉及到的相关代码:
puts 函数最终会调用到该函数,我们需要满足部分 flag 要求使其能够进入 _IO_do_write:
进入后的部分:
可以看到,为调用到目标函数位置,需要满足部分 flags 要求,具体需要满足的 flags :
操作过程
形成 overlapping chunk
改写文件结构体的相关字段
文件结构体更改缘由
通过修改 stdout->_flags 使得程序流能够流到 _IO_do_write (f , f->_IO_write_base , f->_IO_write_ptr - f->_IO_write_base) 这个函数
完整 exp
Challenge 2 小结
这个程序的利用过程是一个有用的技巧,这种通过文件结构体的方式来实现内存的读写的相关资料可以参考台湾 Angelboy 的博客。在 hctf2018 steak 中,也存在一个信息泄露的问题,大多数人采用了 copy puts_addr 到 __free_hook 指针里实现信息泄露,但实际上也可以通过修改文件结构体的字段来实现信息泄露。
Challenge 3 : 2014 HITCON stkof
基本信息
参见[unlink HITCON stkof 简介](./unlink.md#2014 HITCON stkof)
libc 2.26 tcache 利用方法
本题可以溢出较长字节,因此可以覆盖 chunk 的 fd 指针,在 libc 2.26 之后的 tcache 机制中,未对 fd 指针指向的 chunk 进行 size 检查,从而可以将 fd 指针覆盖任意地址。在 free 该被溢出 chunk 并且两次 malloc 后可以实现任意地址修改:
Challenge 4 : HITCON 2019 one_punch_man
基本信息
开启了常见保护,题目环境为 glibc 2.29 ,使用 seccomp 开启了沙箱保护,只有白名单上的系统调用可以使用。
基本功能
Add 函数可以分配 [0x80,0x400] 大小的堆块,分配的函数为 calloc ,输入数据首先存储到栈上,之后再使用 strncpy 拷贝到 bss 上的数组里。
Delete 函数 free 堆块之后未清空,造成 double free 和 UAF
后门函数可以调用 malloc 分配 0x217 大小的堆块,但是要要满足 *(_BYTE *)(qword_4030 + 0x20) > 6 ,我们在 main 函数里可以看到这里被初始化为 heap_base+0x10 ,对于 glibc 2.29,这个位置对应存储的是 tcache_perthread_struct 的 0x220 大小的 tcache_bin 的数量,正常来说,如果我们想调用后门的功能,要让这个 count 为 7 ,然而这也就意味着 0x217 再分配和释放都同 glibc 2.23 一样,我们无法通过 UAF 改 chunk 的 fd 来达到任意地址写的目的,因此我们要通过别的方式修改这个值。
Edit 和 Show 函数分别可以对堆块内容进行编辑和输出。
利用思路
由于 glibc 2.29 中新增了对于 unsorted bin 链表完整性检查,这使得 unsorted bin attack 完全失效,我们的目标是往一个地址中写入 large value ,这种情况下就可以选择 tcache stashing unlink attack。
首先我们可以通过UAF来泄露 heap 和 libc 地址。具体方式是分配并释放多个chunk使其进入 tcache ,通过 Show 函数可以输出 tcache bin 的 fd 值来泄露堆地址。释放某个 small bin size 范围内的chunk七个,在第八次释放时会先把释放的堆块放入 unsorted bin 。通过 Show 函数可以泄露出 libc 地址。
我们首先通过 UAF 将 __malloc_hook 链入 tcache 备用。然后分配并释放六次 0x100 大小的chunk进入 tcache 。通过 unsorted bin 切割得到 last remainer 的方式得到两个大小为 0x100 的chunk。再分配一个超过 0x100 的块使其进入 small bin 。按照释放顺序我们称之为 bin1 和 bin2 。修改 bin2->bk 为 (heap_base+0x2f)-0x10 ,调用 calloc(0xf0) 触发 small bin 放入 tcache 的处理逻辑,由于 tcache 中有 6 个块,因此循环处理只会进行一次,这样也避免了 fake_chunk 因 bk 处无可写地址作为下一个块进行 unlink 时 bck->fd=bin 带来的内存访问错误。最终改掉 heap_base+0x30 的值绕过检查。
利用步骤
下面在调用 calloc 前下断点,可以看到此时 tcache[0x100] 有 6 个堆块,small bin 的分配顺序为 0x000055555555c460->0x55555555cc80->0x000055555555901f ,在 calloc(0xf0) 调用后, 0x000055555555c460 会被返回给用户, 0x55555555cc80 被链入tcache,而由于没有多余位置,跳出循环, 0x000055555555901f 不做处理。
calloc 分配完成之后的结果和我们预期一致, 0x000055555555901f 作为 fake_chunk 其 fd 也被改写为了 libc 地址
由于沙箱保护,我们无法执行 execve 函数调用,只能通过 open/read/write 来读取 flag 。我们选择通过调用后门函数修改 __malloc_hook 为 gadget(mov eax, esi ; add rsp, 0x48 ; ret) ,以便 add 的时候将 rsp 改到可控的输入区域调用 rop chains 来 orw 读取 flag 。
完整 exp 如下:
0x06 建议习题:
2018 HITCON children_tcache
2018 BCTF houseOfAtum
2019 HTICON Lazy House
2020 XCTF no-Cov twochunk
Last updated