
想学堆利用却不知道从哪下手的人我一般会先扔给他一个东西how2heap。这是shellphish团队维护的开源教程项目把堆利用里各种经典手法做成了一个个能编译、能跑、能打印结果的小程序。每个程序都配了一段简短的说明告诉你这个利用为什么会生效、分配器内部发生了什么。比起一上来就啃《glibc malloc源码解析》how2heap更像是带着你看现场先看到现象再顺着代码去理解机制。这篇是学习系列的第一篇我挑三个最基础、也最能建立“堆手感”的场景讲透first_fit、fastbin_dup、unsafe_unlink。适合刚入门pwn、对malloc/free有基本概念但还没系统接触过堆利用的读者。1. how2heap 到底在讲什么1.1 这个项目解决什么问题堆利用的核心难点不是“知道有漏洞”而是“知道分配器会怎么响应你的操作”。你向glibc申请一个chunk它可能在top chunk上切一块可能从fastbin里拿一个旧的也可能把多个相邻空闲块合并之后再切给你。不同的bin组织方式、不同的分配路径导致同样的代码在不同libc版本下表现完全不一样。how2heap就是把这些行为差异固化成一堆小场景。每个场景对应一个或一组c文件比如first_fit.c、fastbin_dup.c、unsafe_unlink.c。你编译运行后程序会直接打印出关键指针和内存内容不需要你开着调试器一行行看。想深入的时候再开gdb断点放在malloc和free上观察调用前后的bin状态变化。很多人把how2heap当“利用模板库”来用遇到一个漏洞类型就翻对应的文件抄。但我的建议是反过来先不看利用只看程序在一开始构造了什么样的堆布局再看free和malloc之后这个布局怎么被改动。这样你积累的不只是“某个攻击手法”而是“分配器在面对一系列操作时的反应模式”。后者才是做题和实战里真正有用的东西。1.2 怎么才算真正学会一个场景我见过不少新人刷how2heap的方式编译、运行、看到程序输出“This is dangerous”感觉“哦过了”然后关掉去看下一个。这样刷十遍也留不下多少东西。我自己的判断标准有三个。第一能不能在不看源码的情况下口述出这个场景里每个malloc和free之后堆上各个chunk的fd/bk指针分别指向哪里。第二能不能解释清楚代码里那几行关键构造语句为什么非那样写不可——比如为什么要伪造size字段、为什么fd要指向某个具体位置。第三能不能说出这个手法在更高版本libc下会不会失效、失效在哪一步以及有没有对应的绕过。这三点都过一遍才算真正“学会”了一个场景。下面三个例子我都会按这个标准展开讲。2. first_fitfirst fit 分配背后的内存复用2.1 代码到底干了什么first_fit.c在旧版how2heap里是长这样的新版目录下改动不大核心逻辑一致#include stdio.h #include stdlib.h #include string.h int main() { fprintf(stderr, This file doesnt demonstrate an attack, but shows the nature of glibcs allocator.\n); char* a malloc(512); char* b malloc(256); char* c; fprintf(stderr, 1st malloc(512): %p\n, a); fprintf(stderr, 2nd malloc(256): %p\n, b); free(a); free(b); c malloc(512); fprintf(stderr, 3rd malloc(512): %p\n, c); c malloc(512); fprintf(stderr, 4th malloc(512): %p\n, c); return 0; }第一步malloc两个chunk。a是512字节b是256字节。第二步依次释放a和b。第三步再malloc一个512字节的chunk程序会告诉你它拿到了哪块地址。第四步又malloc一个512字节再看一次。关键就在第三次和第四次分配的结果。实际运行起来第三次malloc返回的是b的地址第四次malloc返回的才是a的地址。这里很多人会懵我释放的是a、b两块为什么第三次malloc(512)不去用那个大小正合适的a反而用了较小的b原因就是分配器的first fit策略搜索空闲链表时它从头部开始找找到的第一个能满足请求大小的chunk就直接拿下来用。b已经满足512字节的需求就不会继续往后找。2.2 为什么第二次 malloc 拿到的还是 a你可能会追问b字节数不是小于512吗为什么能满足这里要注意chunk的大小和请求大小不是一回事。malloc(256)实际分配的chunk包含了头部16字节和用户数据区在64位系统上整个chunk会按16字节对齐实际size是272字节左右。而b的size字段算上prev_size和size在bin里是以size_t表示的。释放之后b作为一个空闲chunk它可用的区域是包含用户数据和头部在内的整块。所以一个256字节的请求实际产生的空闲chunk大约是272字节仍然满足512的请求吗不对272是小于512的。这里需要看实际代码我上面的例子为了讲清概念做了一点简化。真实first_fit.c里第二次malloc的大小通常是512不是256。我再修正一下让你看准确的逻辑char* a malloc(512); char* b malloc(512); free(a); free(b); char* c malloc(512);这种情况下a和b是相邻的两个等大chunk。释放后a和b分别进入fastbin或者tcache取决于libc版本。tcache是单链表、LIFO结构所以后释放的b会先被取走。第三次malloc(512)拿到b第四次malloc(512)拿到a。这是first fit在tcache/fastbin下的表现。而到了small bin场景情况又不一样。老版本how2heap之所以用这个文件是因为在早期glibc如2.23里没有tcache释放的chunk进入fastbinfastbin也是LIFO所以同样出现“后释放的先被复用”的现象。代码注释里强调的就是这个你没法预测malloc会返回哪块内存所以依赖释放顺序和分配策略的攻击必须精确掌握这些规则。2.3 这个技巧在真实漏洞利用里怎么用first_fit本身不是攻击但它是一切“堆布局”的基础。你写利用时经常要预测我释放了A和B再申请一个大chunk拿到的是A还是B这在后面做overlap、伪造chunk、控制fd指针的时候非常关键。最常见的用途是泄露地址。比如有一个UAF漏洞你先让目标chunk进入unsorted bin然后malloc一个特定大小的chunk分配器回给你一个指向unsorted bin中fd/bk位置的指针读出来就是main_arena附近的地址。如果你不理解first fit的扫描顺序就不知道什么时候该申请多大的chunk才能命中自己想要的空闲块。实操里我的建议是不要靠猜跑一遍。把这个文件编译出来分别用glibc 2.23、2.27、2.31跑一次观察三次输出差异。这就是学习堆利用最好的起点——先建立“不同版本行为不同”的直觉。3. fastbin_dup从双重释放到任意地址分配3.1 双重释放为什么危险很多人第一次听说double free第一反应是把同一块内存释放两次系统会报错吧在早期glibc里确实不报错而且后果远比“报错”严重。free的本质是把chunk插入到某个bin链表中。对fastbin来说这是一个“头插法”操作新释放的chunk会变成链表的第一个节点原链表头变成它的fd。如果你把同一个chunk释放两次这个chunk就会出现在链表里两次。之后连续几次malloc可能会拿到两个指向同一地址的指针。对漏洞利用来说这就是任意写和堆重叠的起点你通过其中一个指针改数据另一个指针还会指向同一个地方一改全改。更危险的是fastbin是单链表没有像small bin那样的双向校验。只要你能控制某个空闲chunk的fd下一次malloc就可能返回一个任意地址比如栈地址、GOT表地址。3.2 一步步拆解 fastbin_dup.c看一个典型版本的核心代码int main() { fprintf(stderr, Allocating 3 buffers.\n); int* a malloc(8); int* b malloc(8); int* c malloc(8); fprintf(stderr, 1st malloc(8): %p\n, a); fprintf(stderr, 2nd malloc(8): %p\n, b); fprintf(stderr, 3rd malloc(8): %p\n, c); free(a); free(b); free(a); fprintf(stderr, Now allocate 3 buffers again.\n); int* d malloc(8); int* e malloc(8); int* f malloc(8); fprintf(stderr, 4th malloc(8): %p\n, d); fprintf(stderr, 5th malloc(8): %p\n, e); fprintf(stderr, 6th malloc(8): %p\n, f); }释放顺序是a、b、a。运行后第四次malloc拿到a第五次拿到b第六次又拿到a。也就是说d和f指向同一块内存你先写d再写f实际上是在改同一个地方。为什么释放a两次没有被拦截在libc 2.31之前fastbin的double free检查其实是有的它只检查“当前释放的chunk是不是和fastbin头指向的chunk相同”。释放a之后fastbin头是a释放b之后头变成b再释放a时头是b不是a所以检查通过。这个“中间隔一次”的操作就是绕过double free检查的关键。到了libc 2.31之后tcache引入了key字段同一chunk连续double free会被更严格地识别但通过“先释放别的chunk隔开”的思路依然有效。3.3 为什么中间必须隔一个 free 操作把这个问题单独拿出来讲是因为很多人在实操中改了释放顺序就翻车。比如代码改成free(a); free(a); free(b);第二次释放a时fastbin头就是a检查直接报错程序崩溃。这里面的原理就是fastbin的单链表头插法。fastbin头永远指向最后一个被释放的chunk。double free检查只是比较“当前释放的chunk地址”和“fastbin头地址”是否相等。中间插入一个free(b)把头指针换掉就让第二次free(a)躲过了检查。这个绕过的代价是fastbin链表变成了a - b - a的环状结构。释放两次a但不相邻a就会在链表里出现两次。之后分配时fastbin按LIFO出链d拿到ae拿到bf又拿到a。所以如果你需要两个指向同一地址的合法指针这个环就是你的基础。我在做实验时习惯在每次free之后用gdb看一眼fastbin状态命令是pwndbg heap bins fast你会直观看到链表节点的变化比只靠打印输出的理解深得多。4. unsafe_unlink堆块摘除时的任意写4.1 unlink 宏到底做了什么unlink是glibc在合并空闲chunk时执行的一个宏作用是把一个空闲chunk从双向链表中摘除。small bin和unsorted bin都是双向链表每个空闲chunk的fd指向链表中的前一个chunkbk指向后一个chunk。摘除操作大概是这样的FD P-fd; BK P-bk; FD-bk BK; BK-fd FD;拆开来看就是让前一个chunk的bk指向后一个chunk让后一个chunk的fd指向前一个chunk这个chunk就从链表里被跳过了。问题在于如果P是一个伪造的chunkFD和BK也都是伪造的指针那这个宏就会往任意地址写入两个值。这就是unsafe_unlink的利用核心。glibc后来加了防护unlink前会检查P-fd-bk是否等于P以及P-bk-fd是否等于P。这个检查的意思是你声称自己在链表里那链表里的前后节点必须也都认识你。攻击者想要绕过就必须让FD-bk和BK-fd这两个位置恰好都指向P。这听起来复杂但构造起来有一个巧妙的套路。4.2 逐步构造 fake chunk看一个简化的unsafe_unlink场景。假设代码有这样一个结构struct chunk_structure { uint64_t prev_size; uint64_t size; uint64_t fd; uint64_t bk; };攻击者在一个可以控制的内存区域比如栈上伪造了一个fake chunk然后通过某种方式比如堆溢出修改一个真实空闲chunk的fd和bk让它们在unlink时指向这个fake chunk。关键构造是fake_chunk[0] 0; fake_chunk[1] 0x100; fake_chunk[2] (uint64_t) fake_chunk; fake_chunk[3] (uint64_t) fake_chunk;这里fake_chunk[2]和[3]都指向fake_chunk本身。这样fake_chunk-fd等于fake_chunkfake_chunk-bk也等于fake_chunk。当unlink执行到检查时FD-bk就是fake_chunk-bk它的值就是fake_chunk也就是P检查通过。BK-fd同理。之后FD-bk BK执行等于往fake_chunk-bk写入一个值BK-fd FD执行等于往fake_chunk-fd写入一个值。由于fake_chunk的fd和bk指向自己这个写入就会修改fake_chunk所在的栈内存。如果把这个fake_chunk放在某个变量前面unlink就能把那个变量改成任意值实现任意地址写。实际how2heap代码里利用这个写操作把victim指针改成了一个指向栈地址的指针之后的写入就变成改写栈上的值。4.3 保护检查与绕过思路我上面写的构造方式针对的是检查存在的情况。但不同libc版本对unlink的检查严格程度不一样学习时要分清。在较老的glibc里只有FD-bk P和BK-fd P两个检查。利用上面的自引用构造就能通过。但在更严格的环境下还会检查chunk的size字段是否合法、P的前后chunk是否真的是空闲状态等。那些场景需要额外伪造相邻chunk的prev_inuse位和size字段复杂度会上升。学习unsafe_unlink最好的方式是搭配调试器观察unlink执行前后内存的变化。我给新人推荐一个流程先编译运行how2heap/unsafe_unlink.c确认程序输出然后gdb在free或合并处下断点打印fake_chunk的内存布局最后手动模拟一遍unlink宏的赋值过程用纸笔写出FD、BK、P分别是什么赋值后内存哪个字节变了。这比反复读宏定义有效得多。5. 调试插件与常见排查方法5.1 推荐工具链学how2heap光靠printf输出是不够的。程序打印的是结果但堆的中间状态需要你自己去看。我用的调试环境是Ubuntu 18.04/20.04虚拟机装多个libc版本备用gdb搭配pwndbg插件必要时用patchelf切换二进制依赖的libcpwndbg里我最常用的几条命令heap bins heap bins fast heap bins unsorted x/20gx addressheap bins会列出所有bin的当前内容。fastbin看单链表unsorted bin看双向链表。x/20gx直接看chunk内存比如查看一个chunk的fd和bk指向哪。5.2 判断 bin 状态的实操命令很多新手卡在“为什么我free了之后malloc没有拿到预期地址”这时候第一步就是看bin。举个例子你free了一个chunk然后查看fastbin发现里面没有它那可能是它进了tcacheglibc 2.26默认有tcache或者是释放时与相邻chunk合并进了unsorted bin。判断方法是看chunk的size字段最低位。这个bit叫prev_inuse表示前一个chunk是否在使用中。如果释放时发现前一个chunk是空闲的就会触发合并chunk不会进入fastbin而是进入unsorted bin。tcache的产生则看释放次数每个size的tcache最多放7个chunk第8个才会进fastbin或unsorted bin。实操时我的一条经验是在free之后马上用pwndbg执行heap bins别等程序跑完再回头看。因为很多利用手法的窗口期很短错过就看不到了。5.3 常见卡点排查与版本差异卡住的时候先确认你用的libc版本。how2heap仓库里不同目录对应不同glibc版本有的文件在glibc_2.31目录下有的在glibc_2.25目录下。同样一个double free在不同版本下触发的检查不同输出也不同。如果你拿高版本libc跑一个为2.23写的示例轻则行为不一致重则直接崩溃。第二个常见问题是编译参数。how2heap的Makefile默认不开PIE这方便观察地址。但你自己写练习题时可能开了PIE导致地址随机化影响分析。建议初学阶段统一关掉gcc -no-pie -g -o test test.c第三个坑是malloc_trim和malloc_consolidate。某些操作比如malloc一个很大的chunk会触发fastbin合并把fastbin里的chunk整理进unsorted bin这会让你的fastbin状态瞬间变样。所以调试时不要只看当前一条指令要留意程序执行到哪一步时bin发生了变化。6. 怎么搭建自己的实验环境并保持学习节奏6.1 从零跑通第一个示例我建议你把环境搭在虚拟机或者Docker里别用宿主机直接折腾。原因是堆利用实验经常要切换libc版本宿主机动glibc很危险。Docker是最省事的方案一条命令拉一个ubuntu:18.04镜像在里面装build-essential和gdb再git clone how2heap仓库即可。docker run -it --cap-addSYS_PTRACE --security-opt seccompunconfined ubuntu:18.04 /bin/bash apt update apt install -y build-essential gdb git git clone https://github.com/shellphish/how2heap.git cd how2heap make注意docker run后面加的--cap-addSYS_PTRACE和--security-opt seccompunconfined不加的话gdb断点会断不住ptrace被容器默认策略拦了。这个我踩过坑第一次跑起来发现breakpoint加不上去一脸懵。6.2 学习顺序与配套练习how2heap的场景很多建议按难度分批。第一批我推荐先啃first_fit、fastbin_dup、unsafe_unlink也就是这篇讲的三个第二批再看house_of_spirit、poison_null_byte、overlapping_chunks第三批再碰house_of_force、unsorted_bin_attack、large_bin_attack这些。每看完一个场景去找一道对应知识点的CTF题目练手。比如看完fastbin_dup就找一道fastbin double free的题看完unsafe_unlink就找一道unlink的题。光看不练理解停留不到一分钟练一道题之前那些“为什么fd要指向这里”的疑问会瞬间通掉。我在带新人时常常说一句话堆利用的学习曲线不是线性上升的而是台阶式的。前期很多概念堆在一起感觉什么都看不懂但只要把first_fit到unlink这一段走扎实后面的house系列反而会顺很多因为它们都是在这几个基础操作上做组合变形。最后再分享一个小技巧不要只跑how2heap里的原版代码试着改动里面的malloc大小、释放顺序、次数再造一些自己的变体。比如把fastbin_dup的释放顺序从a、b、a改成a、c、a观察结果会不会变。这种“自己改实验参数”的折腾过程比重复跑十遍原版更能帮你建立对堆分配器的直觉。学到后面你会发现真正难的从来不是某个attack的名字而是你能不能预测allocator下一步会做什么。