
1. 项目概述一次堆利用技术的深度复盘最近在复盘一些经典的CTF题目特别是BUUCTF平台上那道“网鼎杯 2018 第三场”的wdb_2018_3rd_pesp。这道题可以说是一个堆利用的“万花筒”它没有局限于某一种单一的技巧而是将realloc_hook劫持、unlink攻击以及通过bss段进行辅助写入这三种在真实漏洞利用中极具代表性的技术巧妙地融合在了一道题目里。对于想系统性地理解堆漏洞利用链如何构建、不同技术如何衔接与互补的逆向工程和二进制安全爱好者来说这道题的价值远超其本身的分值。它不是让你简单地调用一个system(“/bin/sh”)而是逼迫你去思考当一种路径被限制时如何利用程序本身的结构和内存布局迂回地达成目标。今天我就以这道题为引子结合我调试过程中的笔记把这三种技术的核心原理、在此题中的具体应用场景以及它们之间的组合逻辑进行一次彻底的梳理和记录。无论你是正在入门堆利用的新手还是想巩固知识体系的老手相信这篇详尽的复盘都能带来新的启发。2. 题目环境与核心逻辑分析2.1 程序功能与漏洞定位首先我们需要把程序跑起来看看它提供了什么功能。这是一个典型的菜单式程序经过逆向分析其主要功能如下Create Note: 申请一个堆块chunk。程序会要求输入一个size然后使用malloc(size)分配内存。值得注意的是它允许分配的最大size是0x100即256字节。分配成功后会将堆块的指针存储在一个全局数组位于bss段中并允许用户向这个堆块写入内容。Show Note: 显示指定索引的堆块内容。就是简单的打印操作。Edit Note: 编辑指定索引的堆块内容。这里就是漏洞所在程序在编辑时会重新读取一个size然后使用read函数向堆块中写入这个size长度的数据。关键点在于它没有检查这个新输入的size是否小于或等于堆块最初申请时的大小。这就导致了典型的“堆溢出”Heap Overflow漏洞。我们可以通过编辑一个堆块写入超过其原有容量的数据从而覆盖其后相邻堆块的内存内容。Delete Note: 删除释放指定索引的堆块。操作是将对应堆块free掉并将全局数组中的指针置零实现了UAFUse-After-Free的防护看起来没有直接利用点。程序的保护机制用checksec查看通常开启了Full RELRO防止GOT表改写、NX栈不可执行、Canary栈溢出保护和ASLR地址空间布局随机化。这意味着我们无法直接修改GOT表也无法执行栈上的代码堆地址和libc地址是随机的。2.2 漏洞利用的核心挑战与思路漏洞很清晰一个堆溢出。但光有溢出还不够我们需要将它转化为任意地址写或代码执行。在开启了ASLR和Full RELRO的情况下传统思路如覆盖GOT表行不通。现代glibc堆利用的主流目标转向了劫持程序的控制流通常通过覆盖malloc_hook、realloc_hook或free_hook这些位于libc中的函数指针来实现。这道题的巧妙之处在于它通过限制malloc的大小最大0x100和内存布局引导你综合运用多种技术直接覆盖__malloc_hook由于__malloc_hook通常位于libc中一个偏移固定的地址我们需要先泄露libc基址。但题目可能通过布局使得从溢出的堆块到__malloc_hook之间存在不可控的地址如main_arena结构体中的其他字段直接溢出覆盖可能会破坏关键数据导致程序崩溃。引入realloc_hook__realloc_hook与__malloc_hook相邻。有时利用realloc函数内部的逻辑可以绕过一些检查或者为ROP链提供更多的寄存器控制。在这道题中它可能作为一个更“干净”的写入目标或者是利用链中必要的一环。unlink攻击当溢出可以覆盖一个已释放堆块处于bin中的元数据时unlink攻击可以让我们实现一次“写指针”操作即将一个地址的值改写为另一个地址。这常被用于修改bss段上存储堆指针的全局数组从而获得一个“任意地址读写”的原语。写入bss段bss段存储着我们的堆指针数组。如果能修改这个数组中的某个指针我们就能让程序的Edit或Show功能作用于我们指定的任意地址从而实现强大的读写能力。题目的最终出口往往是结合上述一种或多种技术将__malloc_hook或__realloc_hook的值覆盖为one_gadgetlibc中一段能直接启动shell的简短指令序列的地址然后触发malloc或realloc调用从而获取shell。3. 核心技术点一unlink攻击原理与在此题中的应用3.1 unlink的底层机制unlink是glibc在合并空闲堆块如free时前后合并或malloc时从bin中取出时执行的一个宏操作。它的目的是将一个双向链表中的节点摘除。假设有一个空闲块P它在双向链表中的结构如下... - [前驱块FD] - [当前块P] - [后继块BK] - ...P的chunk头中fd指针指向FDbk指针指向BK。unlink(P)的核心操作是FD-bk BK; BK-fd FD;即让前驱的后继指向后继让后继的前驱指向前驱这样P就从链表中被移除了。3.2 如何利用unlink实现任意写攻击的关键在于伪造一个“空闲块”P。我们通过堆溢出在一个正在使用中的堆块设为B后面伪造P的chunk头size和fd/bk并让P的size域标记前一个块即真实的B块为“空闲”状态。同时我们需要确保B块本身处于被程序使用的状态即allocated。当我们free(B)时或者在某些情况下malloc处理到相邻块时glibc会检查B的下一个块P根据B的size找到的size中的PREV_INUSE位。如果该位为0表示P的前一个块即B是“空闲”的glibc就会尝试将B和P合并。合并的第一步就是尝试将“空闲块”B从它所在的bin链表中unlink出来。如果我们在伪造P的fd和bk时精心构造如下设我们想在unlink时写入的目标地址为target_addr例如bss段上的指针数组。设我们想让target_addr处被写入的值是value。那么我们可以构造P-fd target_addr - 0x18P-bk value - 0x10这里的0x18和0x10偏移是因为unlink宏中FD-bk和BK-fd的写操作FD和BK是fd和bk指向的地址而bk和fd是位于FD和BK地址偏移0x18和0x10处的字段。这是glibc中malloc_chunk结构定义决定的。当unlink执行FD-bk BK时计算如下FDtarget_addr - 0x18FD-bk位于FD 0x18target_addr写入的值BKvalue - 0x10所以最终效果是*(target_addr) value - 0x10。同理BK-fd FD也会产生一次写操作。我们需要确保这两次写操作不会导致程序崩溃例如写入不可写地址。注意现代glibc2.26以上尤其是2.32以上对unlink加入了更严格的检查例如要求P-fd-bk P且P-bk-fd P。这要求我们的伪造必须更精确通常需要借助程序bss段上已有的指针来构造满足条件的fd和bk这也是为什么unlink攻击常与修改bss段指针结合使用。3.3 在此题中构造unlink在这道题中我们的target_addr很自然地选择为bss段上存储堆块指针的全局数组。假设数组是ptr[]索引i处存储着堆块B的指针ptr[i] chunk_B。布局堆块首先申请两个连续的小堆块比如chunk_A和chunk_B确保它们在内存中相邻。溢出伪造通过Edit功能对chunk_A进行堆溢出覆盖chunk_B的chunk头。我们将chunk_B的size改大并清除PREV_INUSE位标记前一个块A为“空闲”同时在chunk_B的用户数据区开始处伪造一个空闲块P的fd和bk指针。构造指针我们需要让P-fd和P-bk指向合法的、可写的地址并且满足glibc的检查。一个经典的技巧是让P-fd ptr[i] - 0x18P-bk ptr[i] - 0x10。这样P-fd-bk就是*(ptr[i] - 0x18 0x18) ptr[i]这正好是chunk_B的地址。同理P-bk-fd也是chunk_B的地址。这就通过了unlink的完整性检查。触发unlink然后我们free(chunk_B)。由于我们覆盖了chunk_B的size并清除了PREV_INUSE位glibc会认为chunk_A是空闲的并尝试将A和B合并。由于A实际上正在被使用它的指针还在ptr数组中glibc会尝试将“空闲块”A从它所在的bin中unlink出来。根据我们伪造的P的fd/bkunlink操作最终会执行*(ptr[i]) ptr[i] - 0x10。结果现在ptr[i]不再指向chunk_B而是指向了ptr数组自身偏移-0x10的位置。这意味着通过Edit或Show索引i我们实际上是在读写ptr数组附近的内存我们获得了一个在bss段上的“任意地址读写”能力。4. 核心技术点二通过bss段实现任意地址读写4.1 为什么是bss段在unlink攻击成功后我们获得了一个指向bss段内部的指针。bss段通常存储全局变量和静态变量如本题中的堆指针数组ptr。这个区域是可读可写的并且地址在程序运行期间是固定的相对于程序基址不受ASLR影响。这使得它成为一个理想的中转站或攻击跳板。4.2 三种写入bss段的方法论题目提到了三种方法这其实是三种不同的利用链出发点或中间状态它们最终都可能服务于同一个目标劫持hook。方法一unlink直接修改指针如上节所述这是最经典的方法。unlink操作直接修改了bss段上的堆指针使其指向bss段内部。此后Show(modified_index)可以泄露bss段附近的内容可能包含libc指针如果附近有stdout、stdin等结构体或其他有用信息。Edit(modified_index)可以向bss段任意位置写入数据。我们可以利用这个能力修改ptr数组中另一个索引的指针值让其指向__malloc_hook或__realloc_hook。这样我们再通过Edit这另一个索引就能直接修改hook的值了。方法二利用bss段上的已有指针进行部分写有时bss段上可能已经存在一些指向libc的指针例如stdoutGLIBC_2.2.5。如果我们能通过堆溢出或其他方式修改这个指针的低字节由于ASLRlibc地址随机化但低12位相对固定就有可能将其指向__malloc_hook附近。然后利用程序功能去读写这个被修改的指针同样能实现hook的覆盖。这种方法通常需要精确的偏移计算和对内存布局的深刻理解。方法三将bss段作为ROP链或shellcode的存储区在更复杂的利用中如果存在可执行区域本题NX开启此路不通或者需要构造复杂的ROP链时bss段广阔的可写空间可以作为指令或链数据的存储区。我们通过任意写能力将shellcode或ROP gadgets写到bss段然后通过栈迁移或其他控制流劫持手段跳转过去执行。在本题目以hook劫持为主要目标的背景下这种方法可能不是最直接的但它体现了bss段在利用链中的多功能性。4.3 本题中的bss段利用链构建在本题目中结合unlink我们采用方法一通过unlink让ptr[i]指向bss段内部例如ptr[0] - 0x10。使用Edit(i)向这个地址写入数据。因为ptr[i]现在指向bss段我们写入的数据可以修改ptr数组的其他条目。计算__malloc_hook或__realloc_hook与某个已知libc地址如通过Show泄露的main_arena地址的偏移。通过Edit(i)将ptr[j]另一个未使用的索引的值修改为__malloc_hook的地址。现在使用Edit(j)就可以直接向__malloc_hook写入one_gadget的地址了。最后触发一次malloc调用例如再创建一个note程序就会跳转到one_gadget执行获取shell。实操心得在计算bss段内部偏移和hook地址时一定要在调试器如gdb/pwndbg中确认。bss段地址可以通过vmmap或查看全局符号获得。修改指针时注意写入的数据是地址8字节要确保写入操作不会因为字符串截断如遇到\x00而失败。通常pwntools的p64()或p32()函数能很好地处理打包。5. 核心技术点三realloc_hook的妙用与one_gadget适配5.1 realloc_hook与malloc_hook的关系__malloc_hook和__realloc_hook是两个相邻的函数指针位于libc的.data段。当调用malloc时如果__malloc_hook非空则跳转到该钩子函数执行。realloc函数内部在开始时也会检查__realloc_hook。有趣的是在malloc的实现中有一段代码逻辑如果__realloc_hook被设置它会先调用__realloc_hook。而__realloc_hook和__malloc_hook的地址非常接近。有时直接覆盖__malloc_hook为one_gadget可能会因为栈环境不满足one_gadget的约束条件例如要求raxNULL[rsp0x30]NULL等而失败。5.2 利用realloc调整栈环境realloc函数本身在调用hook之前会进行一些参数设置和栈操作。这可能会改变调用one_gadget时的栈布局和寄存器状态。因此一种常见的技巧是将__malloc_hook覆盖为libc中realloc函数开始处的某个地址例如realloc4这个地址会跳过realloc开头的一些push指令直接进入一个能设置寄存器状态的片段。将__realloc_hook覆盖为one_gadget的地址。当调用malloc时流程变为malloc-__malloc_hook(realloc4) - ... (执行部分realloc代码调整rax/rsp等) -__realloc_hook(one_gadget)。通过选择realloc中不同的偏移可以微调执行到__realloc_hook时的CPU上下文从而满足某个one_gadget的触发条件。5.3 在本题中应用在本题目中我们通过bss段任意写获得了覆盖hook的能力。我们可以尝试先直接覆盖__malloc_hook为one_gadget。如果失败通常会在one_gadget处崩溃说明条件不满足。改为尝试覆盖__realloc_hook为one_gadget同时将__malloc_hook覆盖为realloc的某个合适偏移。这个偏移需要通过调试反复尝试。常用的方法是使用pwntools的gdb.attach()调试在malloc被调用时观察寄存器和栈的状态然后计算realloc的哪个位置能帮我们“整理”出合适的现场。有时甚至不需要精确计算可以暴力尝试realloc开头的一些常见偏移如realloc2,realloc4,realloc8, ...,realloc24。配合多个不同的one_gadget总有一个组合能成功。注意事项libc版本不同realloc的代码和one_gadget的约束条件也不同。one_gadget可以使用工具one_gadget来查找。在本地调试时务必使用与远程服务器相同版本的libc否则偏移和代码逻辑的差异会导致利用失败。6. 完整利用链串联与实操演示让我们把上面的所有步骤串联起来形成一个完整的攻击链。假设环境是glibc 2.23unlink检查较松libc基址已通过泄露main_arena地址获得。6.1 步骤详解堆风水布局create(0x80, bA*8) # chunk 0 create(0x80, bB*8) # chunk 1 create(0x10, bguard) # 防止与top chunk合并chunk 2申请两个大小相同且连续的中等块0x80用户区加上chunk头是0x90以及一个隔离块。构造unlink# 假设ptr[0]指向chunk0, ptr[1]指向chunk1 payload p64(0) p64(0x81) # 伪造chunk0的size并标记prev_inuse0 payload p64(ptr_array_addr - 0x18) # fd - ptr[0] - 0x18 payload p64(ptr_array_addr - 0x10) # bk - ptr[0] - 0x10 payload payload.ljust(0x80, bC) # 填充到chunk0的用户区 payload p64(0x80) p64(0x90) # 覆盖chunk1的prev_size和sizesize的PREV_INUSE位保持为1 edit(0, len(payload), payload)通过编辑chunk0溢出并伪造chunk1的prev_size和size同时在chunk0用户区末尾伪造一个空闲块的fd/bk。触发unlink劫持bss指针delete(1) # free chunk1触发向后合并导致unlink执行后ptr[0]将被修改为ptr[0] - 0x10。利用bss段任意写修改另一个指针指向hook# 现在edit(0)就是在写 ptr_array_addr - 0x10 开始的内存 # 我们要修改ptr[2]假设索引2未使用指向 __malloc_hook target malloc_hook_addr # 计算ptr[2]在bss段中的地址 ptr2_addr ptr_array_addr 2*8 # 通过edit(0)向 (ptr_array_addr - 0x10) offset 写入 target # 需要计算 offset ptr2_addr - (ptr_array_addr - 0x10) offset ptr2_addr - (ptr_array_addr - 0x10) edit(0, offset8, bA*offset p64(target))现在ptr[2]指向了__malloc_hook。覆盖hookone_gadget_addr libc_base one_gadget_offset # 方法A: 直接写malloc_hook edit(2, 8, p64(one_gadget_addr)) # 方法B: 写realloc_hook和malloc_hook # realloc_hook_addr malloc_hook_addr - 0x8 # edit(2, 16, p64(one_gadget_addr) p64(libc_base realloc_offset))触发shellcreate(0x20, btrigger) # 这次malloc调用会跳转到one_gadget p.interactive() # 享受shell6.2 调试技巧与常见问题堆布局查看在pwndbg中heap命令可以查看堆块状态bins查看各类bin。在unlink前后观察ptr数组地址的值变化至关重要。检查保护确保你的unlink伪造满足当前glibc版本的检查。glibc 2.26及以后版本需要满足P-fd-bk P和P-bk-fd P。one_gadget条件使用one_gadget工具查找后在gdb中于malloc调用前断点查看寄存器状态。如果不满足尝试更换one_gadget或使用realloc调整。偏移计算所有偏移libc基址、hook地址、bss段地址最好在调试器中动态获取而不是硬编码。使用pwntools的elf和libc符号查找功能更可靠。大小与对齐注意malloc参数是用户请求大小而chunk大小需要对齐通常是2*SIZE_SZ的倍数。prev_size和size字段都是8字节64位。7. 总结与高阶思考这道wdb_2018_3rd_pesp题目像一位严格的教练将堆利用中几个核心且相关的技术点——unlink、bss段利用、hook劫持——编排在一套连贯的动作中。它告诉我们真实的漏洞利用很少是单一技巧的炫技更多的是根据现场环境内存布局、保护机制、可用功能灵活组合多种技术步步为营最终达成目标。从unlink实现一次精妙的指针改写到利用bss段这个“基地”获得稳定的读写原语再到最后通过realloc_hook微调栈环境以适配one_gadget每一步都环环相扣。解决这类题目的关键除了对每种技术原理的透彻理解更在于动态调试的能力观察内存变化、验证构造数据、测试利用效果。我个人的习惯是在关键步骤如溢出后、free前、unlink后、覆盖hook前都下断点并打印相关内存和指针确保一切按计划进行。最后随着glibc版本的迭代unlink等机制的防护越来越强但hook劫持依然是CTF和高版本libc下非常主流的利用思路。理解这道题就为理解更复杂的现代堆漏洞利用打下了坚实的基础。下次遇到堆题不妨先问问自己有没有溢出能不能泄露地址有没有可以修改的全局指针hook能不能写到把这几个问题理清攻击路径往往就清晰了。