从栈到堆:堆溢出利用与fastbin attack实战解析

从栈到堆:堆溢出利用与fastbin attack实战解析 不知道你有没有过这种感觉栈溢出实验一次就过结果到了实验三对着gdb里的堆地址和checksec刷出来的一大片防护选项完全不知道从哪里下手。我第一次做BUPT软件安全实验三就是这个状态。这门课的实验一到实验三难度是断崖式上升的实验一只需要在栈上精准覆盖返回地址实验二让你理解格式化字符串的任意读写到了实验三题目直接从“栈”跳到“堆”没有返回地址可以覆盖没有经典的溢出到EIP路径很多人卡在这里一两周都很正常。BUPT软件安全实验三本质是堆溢出利用。类似版本在北邮、南邮的软件安全课程里都有题目通常是一个带增删改查功能的堆管理程序表面看起来人畜无害但edit函数存在长度检查缺失可以往一个chunk里写入超出其容量的数据进而污染相邻堆块。实验目标很明确利用这个堆溢出漏洞在课程提供的靶机环境中获得shell或者读取一个受保护的flag文件。这篇文章是我按自己刷实验三的经验整理出来的内容包括堆管理的核心机制、题目程序的分析方法、从构造payload到触发利用的完整链路以及我在调试时踩过的三个典型大坑。正在做类似实验、或者想从栈利用进阶到堆利用的同学可以参考这条路线。1. 实验三到底在考什么从“改返回地址”到“骗过分配器”先说一个关键认知堆溢出利用的思路和栈溢出完全不一样。栈溢出之所以简单是因为函数的返回地址就在栈帧里你只要算好偏移一次性覆盖EIP/RIP程序执行流就跟着你走了。堆里没有这种东西堆上没有固定的返回地址也没有离你最近的函数指针供你直接改写。你溢出覆盖的是相邻的chunk元数据而真正让攻击生效的是malloc和free这两个分配器内部函数的行为。这就引出了实验三的核心考点理解glibc堆分配器的内部规则然后利用这些规则本身。课程实验设计者不是让你暴力覆盖某个地址而是让你看明白“chunk在free之后如何进入bin链表”“再次malloc时如何从bin中取块”“fd指针如何被信任并跟随后续分配返回”这一整条链路。你改的不是程序的控制流而是分配器的“账本”。具体到实验题目程序结构一般是菜单式的。main函数打印一个菜单提供add_note申请堆块、edit_note编辑堆块内容、show_note打印堆块内容、delete_note释放堆块这四个功能。课程会提供一个编译好的32位或64位ELF文件外加题目源码的伪代码提示。你需要在黑盒测试和反汇编分析之后找到edit_note中的越界写入点然后完成一次完整的fastbin attack。很多同学做实验三最大的障碍不是不知道payload怎么写而是脑子里缺少一张“堆布局图”。栈溢出可以顺着栈帧一步步画出来堆溢出的布局却随着malloc/free的调用次序不断变化。所以做实验之前我建议先不看exp而是花半天时间搞懂chunk的结构、bin的分类以及fastbin attack为什么能成立。这部分我放在下一节细讲。2. 动手前必须搞懂的堆管理机制chunk、bins与fastbin attack堆利用里面有一个铁律不懂分配器就没法写利用。实验三最常用的技术是fastbin attack理解它的前提是搞清楚glibc对chunk的管理方式。2.1 chunk的结构每个堆块头上都顶着一个“身份证”在glibc里每一次malloc返回给用户的内存背后都是一个chunk。chunk不仅包含用户数据区前面还有一个固定的头部。32位环境下chunk头是8字节64位环境下是16字节但结构逻辑一样--------------------------- | prev_size前一个chunk大小| --------------------------- | size当前chunk大小标志位 | --------------------------- | fdfree后才生效 | | bkfree后才生效 | | ... 用户数据 ... | ---------------------------size字段的低三位是标志位分别叫PREV_INUSE、IS_MMAPPED、NON_MAIN_ARENA。其中PREV_INUSE表示前一个chunk是否正在使用这个标志在free合并时会被检查实验三里经常要手动伪造它。malloc按用户请求的大小向上对齐到16字节再加上头部空间。比如你请求0x60字节实际分配的chunk可能占用0x70字节含头部这个换算关系在计算溢出长度时非常关键差一个字节都会导致payload错位。2.2 bins链表free之后的chunk都去了哪里当一个chunk被free掉它不会真的归还给操作系统而是被放进一个叫bins的链表结构里。根据chunk大小不同bins分成几类fastbins、unsorted bin、small bins、large bins。实验三里最常打交道的是fastbin。fastbin的设计初衷是性能优化小尺寸chunk默认0x80以下被释放后不合并、不整理直接放入一个LIFO后进先出的单向链表。下次malloc同样大小的chunk时分配器会先从fastbin头部取出一个直接用。它的结构非常简单链表顺序是last-in-first-out每个chunk的fd字段指向下一个空闲chunkNULL表示链表结束。这个简单的数据结构成了实验三利用的支点。因为分配器信任fd指针只要你能修改一个空闲chunk的fd就能让下一次malloc返回任意地址——这就是fastbin attack的核心。2.3 fastbin attack为什么能成立分配器过度的信任我们拆解一次完整的fastbin attack它成立的关键在于fastbin链表是单向的且malloc时只检查取出的chunk地址不会仔细验证这个地址是否真的属于堆区。流程一句话概括先释放chunk A进入fastbin然后通过堆溢出修改A的fd指向目标地址比如__malloc_hook附近的一个伪造chunk接着连续两次malloc同样大小的chunk第一次malloc会返回A因为它在链表头部第二次malloc就会沿着被改过的fd返回那个目标地址。拿到目标地址的写权限之后你就可以在目标地址上写one_gadget或system地址再触发一次malloc或free程序控制流就被劫持了。用一个生活类比fd指针就像一个“推荐人”。你把A放进求职名单Fastbin问A“还有谁可以推荐过来”正常情况下A说“下一个是B”这就是默认的fd链。但如果你篡改了A手里的推荐信让fd指向一个伪造的“备选地址”分配器就会把那个地址当成合法求职者直接发给malloc。malloc不查背景只看推荐信。当然这不是说fastbin attack任意时刻都能成功。glibc对fastbin有一个校验取出的chunk必须和请求大小对应的fastbin链匹配也就是目标地址处伪造chunk的size字段必须落在同一个fastbin的大小范围内。所以实际payload一般不会直接指向__malloc_hook本身而是指向__malloc_hook往前0x23字节处的“假chunk”让size字段恰好是一个合法值。这里面具体用哪个偏移要看目标环境和glibc版本不能照抄。3. 实验环境准备与题目程序分析从checksec到定位漏洞点搞懂了原理下一步是动手。实验三的环境准备有一堆细节稍微不注意后面调试会非常痛苦。3.1 环境选择别在本地乱撞直接用课程指定的libc我一开始犯的错是用自己电脑上最新的glibc版本做实验结果写好了payload本地一跑就崩还以为是exp写错了。后来发现是glibc版本差异新版本引入了tcache机制fastbin attack的利用链和旧版完全不一样。课程实验通常基于glibc 2.23或2.27所以最稳的做法是准备一个Ubuntu 16.04或18.04的虚拟机或者用docker起一个指定libc版本的容器。在你环境就绪之后第一步一定是跑checksec确认目标程序的防护措施NX栈不可执行但这个实验一般不看栈暂时不用管。PIE地址随机化如果程序是no-pie函数地址和GOT表地址固定payload可以写死地址如果开了PIE就需要先泄露基址。RELROPartial和Full的区别在于GOT表可不可写实验三里如果瞄准__malloc_hook这个影响不大。Canary栈保护但漏洞点在堆上Canary基本不参与。课程实验的题目通常是no-pie partial relro NX开启目的就是让你专注堆利用本身不被其他防护干扰。3.2 用反汇编和动态调试找漏洞点edit函数的长度检查形同虚设定位漏洞点一般先用IDA或Ghidra打开ELF或者直接objdump。程序逻辑很简单重点看edit_note和add_note。常见的漏洞模式是add_note里用read读入sizemalloc(size)edit_note里再用read或memcpy写入但写入长度用的是另一个固定值或用户输入值没有和之前的malloc大小关联。结果就是你申请了一个0x60的小chunk却在edit时可以写入0x100字节的内容后面的数据直接覆盖到相邻chunk的头部。如果题目给了源码级的注释是最省事的没有的话建议在gdb里动态验证。我的做法是多次add不同大小的chunk然后用edit写超出范围的内容观察程序是否crash。crash的位置通常能直接告诉你溢出覆盖到了哪个字段。配合pwndbg用heap bins命令可以实时查看各个bin链表的状态这会让你对整个堆布局一目了然。3.3 判断目标是32位还是64位偏移计算完全不同实验题目的位数直接影响payload的写法。32位程序指针是4字节chunk头是8字节payload里需要p3264位程序指针是8字节chunk头是16字节payload里需要p64。更难的一点是64位下写入地址时天然自带两个字节的00比如0x7ffff7dd1b10这个地址payload里会有连续的\x00这要求你选的溢出点不能是strcpy这类以null结尾的函数得用read这类指定长度的读函数。假如题目用strcpy写数据64位下几乎没法直接覆写GOT表地址因为遇到\x00就截断了。北邮和南邮这类课程的实验三一般会提供32位版本作为入门因为32位程序利用简单得多适合讲清楚原理。如果你做的是64位版本我的建议是先把32位版跑通理解了fastbin attack的完整流程再回头处理64位的地址编码问题会顺很多。4. 完整利用链路泄露、伪造、分配、触发这一节是整个实验的核心。我按一个标准的fastbin attack利用流程把每一步拆开讲。这里的程序我用一个通用的秘密服务程序举例细节和课程题目可能略有出入但原理完全一致。4.1 第一步安排堆布局把攻击目标放在可溢出的相邻位置fastbin attack需要一个前提你想要篡改fd的那个chunk必须已经处于free状态。所以布局通常是这样的堆地址从小到大 chunk A用户请求0x60实际占用0x70 chunk B用户请求0x60实际占用0x70 chunk C用户请求0x60实际占用0x70我们先申请三个chunk分别记作A、B、C。然后释放A让A进入fastbin链。此时fastbin链是A - NULL。然后我们利用edit功能去编辑A——注意A已经被free了但很多菜单程序不会把对应指针置NULL或者允许edit已释放的chunk这就是UAF释放后使用漏洞。如果题目没有UAF只有堆溢出那就改为从chunk B往低地址方向溢出去覆盖chunk A的fd但这种情况更复杂。实验三最常见的设计是堆溢出UAF同时存在方便学生练习。4.2 第二步溢出覆盖fd让fastbin链表指向伪造目标假设我们可以在edit A时越界写入因为A和B相邻写入越过A的边界会碰到B的头部或者利用UAF直接编辑已释放的A。我们的目标是修改A的fd字段。先看A释放后的布局A的chunk头prev_size size A的fd指向NULL因为链表只有一个元素 A的bkfastbin中空闲chunk也会保留但fastbin只用fd我们写入的数据要覆盖这个fd。比如写入payload bA * 0x18 # 填充到fd的位置 payload p32(target_addr) # 把fd改为目标地址但这里有个问题不能直接改成__malloc_hook因为fastbin要求分配出来的chunk的size落在对应大小范围内。所以需要一个“曲线救国”的方法不指向__malloc_hook本身而是指向__malloc_hook前面0x23字节的地方。这里提前伪造好一个chunk头让size字段被解析成合法值。常见的做法是目标地址 __malloc_hook - 0x23在这个位置写入伪chunk头时读取size的位置正好是0x7f落在fastbin范围内可以绕过检查。这个0x23偏移不是拍脑袋定的而是根据__malloc_hook附近内存中提前存在的0x7f字节计算出来的。这一段的伪代码大概是payload bA * (0x10 0x8) # 覆盖到fd payload p32(0x71) # 伪造下一块chunk的size保证与fastbin的0x70区间匹配 payload p32(fake_chunk_addr) # fd指向目标地址4.3 第三步连续malloc把目标地址“钓”出来伪造完fd之后fastbin链已经变成了这样A - fake_chunk_addr。接下来触发两次malloc请求大小和之前一样是0x60。第一次malloc分配器从fastbin头部取出A链表头变成fake_chunk_addr。第二次malloc分配器取出fake_chunk_addr并把它作为合法的堆块返回给你。现在你的程序里就拿到了一个指向__malloc_hook附近地址的“chunk”。接下来只需要往这个地址写入你想要的数据。到了这一步利用成功了90%。剩下要写什么取决于实验目标。最常见的是两种一是直接写one_gadget到__malloc_hook这样下一次malloc或者free时hook被触发程序直接spawn出shell二是在堆上布置好ROP链然后在fake chunk处劫持某个函数指针。对课程实验来说one_gadget是最优解因为步骤少稳定性高。one_gadget是从libc里找的“一条指令就能弹shell”的地址但它有约束条件比如[rsp0x30] NULL这种。所以不能随便选要用工具one_gadget逐个试或者根据堆布局调整直到满足条件。4.4 第四步写exp的通用结构一个完整的实验三exp结构一般是这样from pwn import * context.arch i386 context.log_level debug p process(./secret_service) elf ELF(./secret_service) libc ELF(./libc-2.23.so) def add(size, data): p.sendlineafter(, 1) p.sendline(str(size)) p.sendafter(content:, data) def edit(idx, data): p.sendlineafter(, 2) p.sendline(str(idx)) p.sendafter(content:, data) # Step 1: 泄露libc地址计算__malloc_hook # Step 2: 布局 伪造fastbin链 # Step 3: 拿到fake chunk写入one_gadget # Step 4: 触发获取shell当然如果课程环境直接给了libc文件你不需要泄露直接在本地计算好函数偏移即可。如果不开ASLR连基址都不用算直接写死。这也是为什么实验三建议先做32位不开PIE的版本。5. 排错记录三个翻车率最高的现场实验三不是写完exp就能跑的我前后改了三个版本才跑通。下面是三个最容易出问题的地方也是我在调试中花时间最多的环节。5.1 崩溃在freeinvalid pointer / double free症状程序执行到free就abortgdb里报free(): invalid pointer或double free or corruption。原因fastbin里面出现了一个本来不该存在的chunk或者某个chunk的size字段被我改坏了。常见的情况是伪造fd时没有处理相邻chunk的PREV_INUSE标志位或者payload里多写了几个字节覆盖到了下一个chunk的头部。排查方法在crash前用pwndbg的heap bins命令查看fastbin链看每个指针是不是符合预期。再用x/30gx 地址直接查看堆内存确认chunk头里的size值和你payload里写的是不是一致。一个字节的错位都会让glibc校验失败所以paylaod的填充长度必须用调试器一点一点算清楚不能拍脑袋。5.2 malloc返回的不是目标地址fd被改成目标地址但取不出来症状两次malloc之后返回的地址不是__malloc_hook附近而是0x0或者程序直接崩掉。原因fastbin attack里有一个“size校验”。目标地址处的内存被解析成chunk size之后必须和请求的大小处于同一个fastbin区间。如果你直接用__malloc_hook地址这里的size可能是0x0glibc一看不匹配直接返回NULL或者abort。正确的做法是需要找一个“合法size”的偏移这就是前面提到的0x23偏移的由来。解法用gdb的x/20gx __malloc_hook看附近内存找到一段包含0x7f的区域。因为0x7f这个值和fastbin大小区间对应是伪造chunk头的关键。把fd指向__malloc_hook - 0x23该位置解析size时正好落在0x70这个大小区间可以绕过检查。5.3 one_gadget全部失效spawn shell失败症状hook成功写入程序也触发了但只在屏幕上打印一行$出来终端没有任何反应或者shell一弹出来立刻断掉。原因一个是不满足one_gadget的约束条件。one_gadget地址的约束通常是一些寄存器或栈位置的值为NULL如果程序当前状态不满足就会在调用execve之前崩掉。另一个是栈对齐问题尤其是64位程序调用system或execve时对rsp的16字节对齐有要求不满足就崩。解法把one_gadget列表里所有地址都试一遍如果都不行换用“调用system(/bin/sh)”的方式在fake chunk处布置system地址利用free或malloc触发。栈对齐问题可以用一个简单的trick在目标地址处先写一个ret指令再写system地址这样rsp会偏移8字节强制对齐。这个方法在64位堆利用里很常用也可用于实验三。这三个问题解决之后我实验三的exp在本地和远程就都能稳定跑通了。你会发现堆利用的调试过程本质上就是和glibc的堆管理机制“对账”它校验什么你就伪造什么它信任什么你就利用什么。6. 跑通实验三之后值得继续深挖的方向实验三的fastbin attack只是堆利用的一张入场券。跑通之后可以顺着几个方向继续往深走。第一个方向是tcache。新版glibc2.26及以上引入了tcache是一种比fastbin更“好说话”的缓存机制每个线程默认有64个bin每个bin缓存7个chunk且tcache命中时不检查size和key。这意味着tcache poisoning更简单但也带来新的限制。很多课程实验二、实验三如果有不同版本会针对tcache重新设计题目。第二个方向是各种经典堆利用技巧的口诀式区分unlink利用的是空闲chunk合并时的双向链表操作large bin attack可以往任意地址写一个libc地址house of系列则是对特定chunk布局下的精妙操纵。这些技巧在CTF赛题里是高频考点也是从“会抄exp”到“会自己设计exp”的分水岭。第三个方向是调试工具的熟练度。pwndbg的heap bins、find_fake_fast、one_gadget以及gdb里x/命令对内存的精细检查这些都会在实验四、实验五里继续用。我个人的经验是调试堆利用的熟练度取决于你对glibc源码的熟悉度推荐直接读glibc-2.23里malloc.c的_int_malloc和_int_free两个函数读完之后你对整个利用链的理解会彻底不一样。最后多说一句这个实验我刷了三遍才真正跑通。第一遍是照着网上exp抄跑通了也不知道为什么第二遍自己分析程序、自己写payload卡在了size校验那里第三遍才做到不看任何参考独立完成从分析到利用的全过程。如果你现在正卡在实验三不用急着找现成exp先动手把堆布局画出来把fd链走一遍比自己对着代码看一天有用得多。