CTF堆利用技术深度解析:unlink、fastbin与largebin攻击实战

📅 发布时间:2026/8/11 8:40:10
CTF堆利用技术深度解析:unlink、fastbin与largebin攻击实战 1. 项目概述一次堆利用技术的深度复盘最近在复盘一些经典的CTF堆利用题目BUUCTF平台上的“网鼎杯wdb_2018_3rd_pesp”这道题给我留下了深刻的印象。它不仅仅是一道简单的堆题更像是一个堆利用技术的“小型博览会”将realloc_hook、unlink以及向bss段写入数据这三种在特定场景下极具威力的技术巧妙地融合在了一个题目里。对于想系统提升堆漏洞利用能力的同学来说这道题的价值远超其本身。它迫使你跳出单一的利用链思维去思考在限制条件下如何组合、变通已有的技术最终达成目标。今天我就以这道题为引子结合我自己的解题和调试过程把这三种技术的原理、在此题中的具体应用以及它们之间的关联与差异进行一次彻底的梳理和记录。无论你是刚接触堆利用的新手还是想查漏补缺的老手相信这篇记录都能带来一些新的启发。2. 题目环境与核心逻辑分析2.1 程序功能与漏洞定位拿到一个二进制文件第一步永远是摸清它的功能。这个程序是一个典型的菜单式堆管理器提供了add、delete、show和edit四个功能。add函数允许我们申请最大0x100字节的堆块delete函数在释放堆块后会将指针置零避免了Use-After-FreeUAFshow函数用于打印堆块内容而edit函数则允许我们修改已分配堆块的内容并且没有对输入长度做任何检查。注意这里的edit函数是漏洞的根源。它允许我们向一个已分配的、大小为size的堆块中写入任意长度的数据。这意味着如果我们申请了一个0x20大小的堆块却可以通过edit写入超过0x20字节的数据从而造成堆溢出。这是本题所有利用技术的起点。程序在bss段维护了一个全局数组用于存储我们申请的堆块指针和其对应的大小。这个数组的结构对我们后续的利用至关重要。通过逆向分析我们可以确定每个堆块条目占0x10字节前8字节是指针chunk_ptr后8字节是大小chunk_size。add和delete都会正确地更新这个数组。2.2 安全机制与利用限制在开始构思利用链之前我们必须清楚程序所处的“安全环境”。使用checksec命令查看发现程序是64位开启了Full RELRO和NX保护PIE保护没有开启。Full RELRO这意味着全局偏移表GOT是只读的。我们无法通过修改GOT表中的函数地址来劫持程序流这是最直接的利用方式被堵死了。NX堆栈不可执行。我们无法将shellcode写入堆或栈然后跳转执行。No PIE这是一个好消息程序基地址是固定的。这意味着bss段、代码段等所有段的地址在每次运行时都是已知且不变的。我们可以直接计算bss段上那个堆块管理数组的绝对地址这为我们的利用提供了极大的便利。综合来看由于GOT表不可写我们最终的攻击目标很自然地指向了__malloc_hook或__realloc_hook。这两个是libc中的全局函数指针当它们被设置为非空时会在调用malloc或realloc时首先被执行。而No PIE则让我们可以稳定地操作bss段上的数据。3. 利用链核心unlink攻击详解面对堆溢出漏洞和可控的bss段数组一个经典的利用思路就是unlink攻击。unlink是glibc中用于从双向链表如small bin或large bin中摘除一个空闲块时的内部操作。如果我们可以伪造一个空闲块并诱使glibc对这个伪造块执行unlink就能实现一次任意地址写。3.1 unlink操作的原生逻辑与利用条件在旧版本的glibc本题环境约为2.23中unlink一个空闲块假设为P时会执行以下宏操作FD P-fd; BK P-bk; // 安全检查FD-bk P 且 BK-fd P if (__builtin_expect (FD-bk ! P || BK-fd ! P, 0)) malloc_printerr (corrupted double-linked list); FD-bk BK; BK-fd FD;这段代码的目的是将P从其所在的双向链表中移除。如果我们能控制P的fd和bk指针那么在通过安全检查后就会执行FD-bk BK和BK-fd FD这相当于向FD0x18和BK0x10的位置写入我们可控的值。为了通过安全检查FD-bk P且BK-fd P传统的利用手法是在一个已知地址比如bss段上的数组附近构造一个假的chunk结构P。我们让P-fd指向目标地址 - 0x18让P-bk指向目标地址 - 0x10。这样FD-bk就位于(目标地址-0x18)0x18 目标地址我们让这个位置存储P的地址同理BK-fd位于(目标地址-0x10)0x10 目标地址也存储P的地址。这样就能绕过检查。通过这个操作最终会向目标地址写入目标地址-0x18。这通常用于修改一个存储指针的全局变量比如bss上的堆指针数组使其指向自身附近从而获得一个指向已知可写区域的指针为进一步的读写操作奠定基础。3.2 本题中的unlink实战构造在本程序中bss段上的堆管理数组假设地址为bss_array是我们理想的目标。我们的计划是布局堆块申请多个堆块。例如chunk0大小0x80chunk1大小0x80chunk2大小0x80。目的是让chunk0溢出覆盖chunk1。伪造空闲块在chunk0中构造一个伪造的chunk数据。我们需要设置这个伪造块的size并将其prev_inuse位设为0假装前一个块是空闲的。更重要的是我们要在chunk1的user data区域开头布置一个假的chunk head包括prev_size和size。prev_size要设置为伪造块的大小size的PREV_INUSE位要设为0。触发unlink通过堆溢出从chunk0覆盖chunk1的chunk header。我们将chunk1的size改小例如从0x91改为0x90并确保其PREV_INUSE位为0。然后释放chunk1。glibc在释放chunk1时发现其物理相邻的前一个块根据prev_size和PREV_INUSE位判断是“空闲”的就会尝试将前一个块即我们伪造在chunk0中的那个块从它所在的bin中unlink出来并与chunk1合并。设定fd/bk指针在我们伪造的块位于chunk0的user data区中我们将fd设置为bss_array[idx] - 0x18将bk设置为bss_array[idx] - 0x10。这里的bss_array[idx]就是存储chunk0指针的全局数组条目地址。结果当unlink发生时安全检查通过最终会执行*(bss_array[idx]) bss_array[idx] - 0x18。这意味着原本指向chunk0的全局指针现在指向了bss段上、它自身地址减0x18的位置。我们获得了一个指向bss段的指针可以通过编辑chunk0来修改bss段上的其他数据例如修改其他堆块指针从而实现任意地址读/写。实操心得在构造unlink时最容易出错的地方是对齐和size字段的设置。一定要用gdb的heap命令如pwndbg的heap仔细查看释放前后每个chunk的header内容确保prev_size、size的PREV_INUSE标志位完全匹配。另外注意fd和bk指向的地址必须可写否则在unlink写操作时会段错误。4. 利用链演进向bss段写入数据的三种方法通过unlink我们获得了修改bss段的能力。接下来的核心目标就是向bss段的特定位置写入libc地址或one_gadget地址最终劫持hook。这里题目巧妙地展示了三种不同的写入思路。4.1 方法一利用全局指针数组的索引读写这是最直接的方法。unlink之后我们拥有一个指向bss段的指针假设叫bss_ptr。这个bss_ptr指向的是bss_array中某个条目附近。计算偏移通过gdb我们可以精确计算出bss_ptr与bss_array中其他堆块指针条目之间的偏移。例如bss_ptr可能指向bss_array[0] - 0x18那么bss_ptr 0x18就是bss_array[0]bss_ptr 0x28就是bss_array[1]以此类推。修改其他指针通过编辑chunk0现在其内容从bss_ptr开始我们可以修改bss_array[1]、bss_array[2]等指针的值。我们可以将它们修改为libc中的地址比如__malloc_hook的地址。实现任意读写修改成功后我们通过show(1)就能泄漏__malloc_hook处的libc地址从而计算出libc基址和one_gadget地址。再通过edit(1)就能将one_gadget写入__malloc_hook。触发最后调用一次malloc即可触发shell。这种方法逻辑清晰但需要对bss段的布局有非常精确的把握。4.2 方法二利用fastbin attack劫持bss段指针这是一种更精巧的方法利用了fastbin的单链表特性。创造fastbin chunk在完成unlink获得bss段写能力后我们先释放一个大小属于fastbin范围例如0x70的堆块假设是chunkA。它会被放入fastbin链表。修改fd指针通过bss段写能力我们修改bss_array中指向chunkA的指针将其fd域即chunkA用户数据区的前8字节修改为一个bss段上的伪造chunk地址。这个伪造地址需要满足fastbin的size检查即其size域位于伪造地址-8的位置必须是一个合法的fastbin size如0x7f。我们可以在bss段上提前布置好这个size。申请伪造chunk连续两次申请相同大小的fastbin chunk。第一次会取回真正的chunkA第二次就会取回我们伪造在bss段上的那个“chunk”。此时我们获得了一个指向bss段的指针。写入数据通过编辑这个伪造的bss chunk我们可以直接修改bss段上任意位置的数据包括将某个函数指针如__malloc_hook的地址写入bss_array的另一个条目。后续操作之后就可以像方法一一样通过show和edit那个条目完成对__malloc_hook的读写。这种方法绕过了直接计算偏移的麻烦通过堆管理机制本身将控制流引导至bss段。4.3 方法三利用largebin attack间接写入这是本题展示的最复杂也最有趣的一种方法它利用了largebin链表的特性。准备largebin chunk我们需要先释放一个较大的堆块大小大于0x400使其进入unsorted bin随后再申请一个更大块迫使它被整理进largebin。假设这个块为chunkL。触发unlink后修改largebin chunk指针在unlink获得bss段写能力后我们修改bss_array中存储chunkL指针的条目将其内容改为一个bss段上的目标地址比如target_addr。触发largebin attack此时largebin中记录着chunkL的地址。我们再进行一系列特定的堆操作例如再释放一个比chunkL稍小的块到unsorted bin然后申请一个比chunkL大的块会触发glibc将unsorted bin中的块插入largebin。在这个过程中glibc会更新largebin中chunkL的fd_nextsize和bk_nextsize指针。关键点在于如果chunkL的bk_nextsize是可写的我们通过修改指针让它指向了bss段的target_addr那么glibc会执行chunkL-bk_nextsize-fd_nextsize chunkL。这行代码会向target_addr 0x20的位置写入chunkL的地址一个libc中的地址。结果我们成功地将一个libc地址写入了bss段的target_addr0x20处。如果我们将target_addr精心设置为__malloc_hook附近某个能通过size检查的位置那么我们就能获得一个指向libc的指针进而修改__malloc_hook。这种方法不直接写hook而是先写一个libc地址到bss或hook附近再通过其他操作去利用这个地址步骤更为迂回。5. 最终武器realloc_hook与one_gadget的搭配艺术无论采用上述哪种方法向bss段写入地址我们最终都需要劫持一个hook来执行shellcode。本题最常用的目标是__malloc_hook。然而直接往__malloc_hook里填one_gadget常常会因为栈环境不满足约束条件而失败。这时__realloc_hook的价值就体现出来了。在libc中__realloc_hook在__malloc_hook之前被检查。更重要的是realloc函数开头有一段调整栈帧的代码。如果我们同时控制__realloc_hook和__malloc_hook可以玩出一个非常稳定的技巧写入realloc_hook将__realloc_hook设置为one_gadget。写入malloc_hook将__malloc_hook设置为libc中realloc函数开头某条指令的地址。这条指令通常形如push r14; push r13; ...它会改变栈布局。触发流程当我们调用malloc时首先检查__malloc_hook非空于是跳转到我们设置的realloc地址执行。realloc函数一开始又检查__realloc_hook于是跳转到我们的one_gadget执行。关键在于跳转到realloc时栈指针rsp的位置相比直接跳转到one_gadget时发生了变化通常会上移这个变化恰好可能满足one_gadget对栈上特定位置值的约束条件比如[rsp0x30] NULL从而极大地提高了one_gadget的成功率。实操心得寻找合适的realloc偏移是个经验活。通常不是跳转到realloc的绝对起始地址而是reallocXX的位置。这个XX值需要通过调试和尝试来确定不同的libc版本可能不同。在pwndbg里可以用disass realloc查看汇编尝试跳过一个或多个push指令的地址。6. 完整利用脚本构建与调试心得将以上所有步骤串联起来我们就能编写出完整的Exploit脚本。以unlink方法一直接修改指针为例大致步骤如下初始化连接程序计算好bss_array的绝对地址因为No PIE。堆布局申请chunk0, chunk1, chunk2大小均为0x80实际chunk size为0x90目的是保护top chunk防止合并。构造unlink在chunk0中布置伪造块的fd/bk指向bss_array[0] - 0x18和-0x10。在chunk1的user data开头布置假的prev_size0x90和size0x90PREV_INUSE0。通过edit(0)溢出覆盖chunk1的size为0x90确保PREV_INUSE0。释放chunk1触发unlink。此时bss_array[0]指向bss段自身。泄漏libc地址通过edit(0)修改bss_array[1]的指针为__malloc_hook的地址或main_arena附近的地址。使用show(1)泄漏出libc地址计算基址和one_gadget地址。写入hook通过edit(0)修改bss_array[2]的指针为__realloc_hook的地址。通过edit(2)向__realloc_hook写入one_gadget地址。通过edit(0)修改bss_array[3]的指针为__malloc_hook的地址。通过edit(3)向__malloc_hook写入realloc的某个偏移地址如libc.sym[realloc] 8。触发调用add申请一个块触发malloc进而触发realloc_hook执行one_gadget获得shell。调试是整个过程中最耗时但也最重要的部分。一定要善用gdb的heap相关命令、break在关键函数如free、malloc、unlink宏、以及watch监控关键内存地址的变化。每次堆操作后都检查一下相关chunk的header和bss段数组内容是否符合预期。7. 常见问题与排查技巧实录在复现这道题和类似堆题时以下几个坑点非常常见unlink时发生崩溃提示“corrupted double-linked list”原因fd-bk ! P或bk-fd ! P。说明你伪造的fd和bk指针没有满足unlink的安全检查条件。排查用gdb在触发free即unlink前断下分别打印P、P-fd、P-bk、P-fd-bk、P-bk-fd的值。确保P-fd-bk P且P-bk-fd P。99%的问题出在这里的地址计算错误。成功unlink后后续edit操作导致段错误原因unlink后获得的bss_ptr可能没有指向一个合法的、可写的内存区域或者通过这个指针计算其他bss条目地址的偏移错了。排查在unlink后立即用gdb查看bss_array的内容确认指针是否被成功修改为bss段地址。然后手动计算bss_ptr与bss_array[1]等目标的偏移并在脚本中打印验证。one_gadget执行失败原因栈环境不满足one_gadget的约束条件如[rsp0x30] NULL,[rsp0x50] NULL,[rsp0x70] NULL等。解决尝试多个不同的one_gadget。使用realloc_hook调整栈帧这是最有效的方法。如果不行可以考虑更复杂的ROP链但本题通常不需要。fastbin attack时malloc返回非预期地址原因fastbin的size检查失败。伪造的chunk的size域位于fake_chunk_addr - 8必须与当前fastbin链表的大小严格匹配例如0x20,0x30, ...,0x80。注意size的最低字节flag bits也要考虑。排查在申请之前用gdb查看fake_chunk_addr - 8处的8字节内容确认其值是否等于预期的size如0x7f。largebin attack写入地址不正确原因largebin attack的触发条件比较苛刻需要精确控制largebin和unsorted bin中块的大小关系以及指针的修改时机。排查仔细阅读glibc源码中关于largebin插入的部分通常是malloc.c中的_int_malloc函数。用gdb跟踪整个过程中各个bin的状态变化确保chunkL确实在largebin中并且插入新块时触发了fd_nextsize/bk_nextsize的更新逻辑。这道题像一把钥匙打开了堆利用中多种中级技术的大门。它告诉我们面对复杂的保护机制和限制条件单一的利用技术往往力不从心需要根据实际情况灵活组合。从最基础的堆溢出到unlink获得初始写能力再到选择不同的路径向目标地址写入数据最后用realloc_hook技巧稳定触发shell每一步都充满了对glibc内部机制的理解和运用。