
1. 项目概述从一道经典CTF题看glibc的“微小”破坏力最近在整理一些老CTF的Writeup翻到了WHCTF2017里那道名为“stackoverflow”的题目。乍一看标题很多人会以为这又是一道标准的栈溢出题无非是覆盖返回地址、跳转到后门函数或者布置shellcode。但真正上手分析后才发现这道题的“溢出”和我们常规理解的堆栈溢出完全不同它真正的核心是一个在glibc动态链接库中由“写入一个零字节”这个看似无害的操作所引发的连锁反应。这个漏洞本身并不复杂甚至有些隐蔽但它所展现的利用思路——如何将一个微小的、局部的内存破坏通过程序逻辑和glibc内部状态放大为一次完整的任意代码执行Arbitrary Code Execution, ACE——却极具教学价值。对于从事二进制安全、漏洞研究或者对Linux底层机制感兴趣的朋友来说深入理解这个案例远比单纯地刷几百道标准栈溢出题更有收获。它教会我们在复杂的真实世界软件中漏洞的利用往往不是“大力出奇迹”的覆盖而是“四两拨千斤”的精准撬动。这道题的环境基于经典的x86-64 Linux使用了当时较新的glibc版本。题目提供了一个名为“stackoverflow”的二进制文件以及对应的libc.so.6动态链接库。程序本身的功能很简单但正是这种简单让漏洞的利用过程更加清晰。我们不会在这里讨论如何搭建CTF环境而是聚焦于漏洞原理、利用链的构造以及整个攻击的思维过程。你会发现整个攻击链条就像多米诺骨牌而那个“写入零字节”的操作就是推倒第一块骨牌的手指。2. 漏洞原理深度剖析零字节写入的蝴蝶效应2.1 程序功能与漏洞点定位首先我们得搞清楚这个程序是干什么的以及漏洞在哪里。使用file和checksec命令对目标二进制进行初步分析可以知道它是一个64位、动态链接、没有开启PIE位置无关可执行文件和Canary栈保护的ELF可执行文件。没有PIE意味着代码段的地址是固定的这为我们后续计算一些偏移提供了便利。用IDA Pro或Ghidra进行反编译程序的逻辑很快清晰起来。程序的主体是一个简单的循环它允许用户进行两种操作一是“malloc”一块指定大小的内存二是“free”之前分配的一块内存。当然为了贴合“stackoverflow”这个名字它还有一个隐藏的、触发溢出的功能但这个“溢出”并非发生在栈上。关键的漏洞点出现在free功能的实现中。当用户执行free操作时程序会提示输入要释放的内存块索引然后调用free()函数将其释放。问题在于在释放之后程序并没有将指向该内存块的指针置为NULL这是一个典型的“Use-After-Free”UAF或“Double Free”漏洞的温床。但本题的利用路径更为巧妙它没有直接利用UAF而是利用了glibc在管理释放的内存尤其是fastbin时的一个内部操作。具体来说当我们连续释放两块特定大小的内存比如都是0x60字节时它们会被加入到名为“fastbin”的单向链表中。fastbin是glibc用于快速分配小内存块的一种机制。此时第一块被释放的内存我们称为chunk A的fdforward pointer前向指针会指向第二块被释放的内存chunk B。而chunk B的fd指针则指向fastbin链表更深处的某个地址可能是0也可能是其他值。漏洞触发的关键在于当我们再次申请一块相同大小的内存时glibc会从fastbin的头部即最近释放的块取出chunk A返回给我们。此时fastbin链表的头部就变成了chunk B。而glibc在将chunk A从链表中摘除时会执行一个关键操作它试图将chunk A的fd指针的值写入到chunk B的某个位置以完成链表的重连。在正常情况下这只是一个指针的赋值。但在本题精心构造的场景下chunk A的fd指针指向了一个特殊的地址使得这个写入操作恰好向目标地址写入了一个零字节。2.2 glibc fastbin 机制与零字节写入的触发为什么是“零字节”这需要深入fastbin的内存布局和glibc 2.23版本本题使用的版本的源代码。一个被释放并放入fastbin的chunk其用户数据区的起始位置被glibc当作fd指针来使用。也就是说如果我们在chunk A的用户数据区布置数据这些数据在它被释放后就会被解释为fd指针。假设我们有三个连续的内存块布局在堆上chunk P chunk A chunk B。其中chunk P是我们无法直接控制的但它的size字段对我们至关重要。chunk A和chunk B是我们可以通过程序分配和释放来控制的。我们的目标是修改chunk P的size字段。通过精心计算堆的布局我们可以让chunk A的fd指针即我们写入chunk A用户区的数据指向chunk P的size字段所在的地址减去某个偏移。这个偏移是多少呢在64位系统中一个chunk的size字段位于chunk头部的第8-15字节前8字节是前一个chunk的size但这里不展开。当glibc执行*fb fd;这样的操作来重连fastbin链表时fb是chunk B的地址它实际上是在向fb指向的地址写入一个8字节的值即fd的值。如果我们让fd的值等于某个地址比如target_addr那么写入操作就是向fb指向的地址写入target_addr。但如果fb指向的地址是target_addr - 8呢那么这次8字节写入就会覆盖从target_addr - 8到target_addr的8个字节。如果target_addr恰好是chunk P的size字段地址那么target_addr - 8就是chunk P的prev_size字段地址。我们写入的8字节数据其高4字节会覆盖prev_size低4字节会覆盖size的低位。这里的魔法来了如果我们精心构造fd的值使得其低4字节即写入到size字段的部分是0x00那么我们就成功地将chunk P的size字段的最低有效字节改成了0。例如原size是0x91写入一个零字节后可能变成0x90。size字段的改变是后续一切利用的基石。这就是所谓的“零字节写入”漏洞Null Byte Off-by-One。它之所以危险是因为它只修改了一个字节而且是改成零这在很多情况下比如字符串操作容易被忽视但却足以破坏glibc堆管理器的内部数据结构的一致性。注意这里的“零字节”指的是我们写入的8字节指针值的低字节为0从而覆盖目标字节为0并非指我们只写入了1个字节的数据。操作本身是一次8字节的写入但产生的关键效果是目标地址的一个字节被清零。2.3 从size字段篡改到堆布局控制将chunk P的size从0x91改为0x90这细微的差别意味着什么在glibc中chunk的size字段包含了当前块的大小以及一些标志位如前一个块是否在使用中P。size的最低三个比特被用作标志位所以实际大小是size ~0x7。0x91和0x90的实际大小都是0x90字节但PREV_INUSE (P)标志位不同。0x91的二进制是10010001最低位为1表示前一个chunk物理相邻的上一个正在使用中。0x90的二进制是10010000最低位为0表示前一个chunk是空闲的。这个标志位的改变欺骗了glibc。当下一次操作涉及chunk P时比如释放chunk P后面的块glibc会认为chunk P前面的那个块我们称为chunk P-1是空闲的并尝试进行“向后合并”backward consolidation。合并操作需要找到chunk P-1的起始地址这个地址是通过chunk P的地址 - chunk P-1的size计算得来的。如果我们能控制chunk P-1的size字段通过堆喷等技术在更早的地方布置数据我们就可以让这个计算得到一个我们期望的地址从而将一个伪造的chunk我们完全控制其内容的区域纳入到空闲堆块链表中。通过这种“伪造空闲chunk”的技术我们最终可以实现“堆重叠”heap overlapping即让两个或多个逻辑上独立的内存块在物理地址上发生重叠。例如我们让一个大的空闲块包含了另一个正在使用的小块。这样通过操作大块我们就能读写小块内部的数据从而破坏程序的关键信息。3. 利用链构造步步为营的控制权夺取理解了漏洞原理接下来就是如何将它串联成一条完整的利用链最终实现任意代码执行。整个过程环环相扣每一步都需要精确计算。3.1 第一阶段堆风水与初始布局利用的第一步是进行精确的“堆风水”Heap Feng Shui即通过有顺序的分配和释放操作将堆内存布局成我们期望的样子。这就像在玩一个内存积木游戏。填充tcache如果存在在现代glibc2.26中需要先填满对应大小的tcache bin让后续释放的块进入fastbin。本题环境是glibc 2.23没有tcache所以可以跳过。分配铺垫块首先分配若干个大小的内存块比如0x60。这些块的作用是塑造堆的布局确保我们关心的chunk A、B、P能按照预期的顺序和间隔出现在堆上。有时需要分配一些“屏障”块来防止合并。分配关键块依次分配chunk A, chunk B, 以及chunk P。记录下它们的索引或地址。我们需要知道chunk P的地址以及它的size字段在内存中的准确地址。计算偏移计算chunk A的fd指针应该指向哪里。我们的目标是让写入点fb即chunk B的地址加上某个偏移后指向chunk P的size字段地址减8的位置。这需要根据glibc内部fb的准确含义来定。通过调试我们可以确定这个关系并计算出需要放入chunk A的用户数据区的fd值。3.2 第二阶段触发零字节写入布局完成后开始触发漏洞。释放chunk B首先释放chunk B它进入fastbin0x60大小。释放chunk A接着释放chunk A。此时fastbin链表为头结点 - chunk A - chunk B - NULL。chunk A的fd指向了chunk B。篡改chunk A的fd现在我们需要在chunk A被释放后、再次分配前修改它的fd指针。但chunk A已经被释放程序没有直接提供编辑已释放块的功能。这里就需要用到题目另一个“stackoverflow”特性或类似的原语它可能允许我们在某个缓冲区溢出而这个缓冲区恰好覆盖了chunk A的用户区。通过这个溢出我们将chunk A的fd修改为我们之前计算好的特殊值指向chunk_P_size - 8且低字节为0。分配chunk A’程序再次申请一个0x60大小的内存。glibc从fastbin头部取出chunk A返回。此时发生关键操作glibc执行*fb fd其中fb是chunk B的地址fd是我们篡改后的值。这次写入覆盖了chunk P的prev_size和size字段成功将size的最低字节清零例如0x91-0x90。3.3 第三阶段制造堆重叠与信息泄露size字段被篡改后chunk P在glibc眼中变成了前一个块是空闲的状态。释放chunk P后面的块我们分配并释放一个紧跟在chunk P后面的块chunk P1。glibc在释放P1时会检查前一个块即chunk P是否空闲。由于我们把P的P标志位改成了0glibc认为P是空闲的于是触发向后合并。触发向后合并合并操作会尝试找到chunk P的前一个空闲块即chunk P-1。它通过chunk_P_addr - *(chunk_P_addr-8)来计算chunk P-1的地址。这里的*(chunk_P_addr-8)就是chunk P的prev_size字段而这个字段在步骤3.2的第4步中被我们覆盖了我们可以通过溢出等手段提前在prev_size的位置布置一个巨大的值比如0x100。计算伪造地址假设chunk P的地址是0x602250我们布置的prev_size是0x100。那么glibc会认为前一个空闲块的起始地址在0x602250 - 0x100 0x602150。如果我们在地址0x602150附近通过堆喷布置好数据伪造出一个完整的堆块结构包含size字段等glibc就会把这个伪造的块当作一个真正的空闲块并将它和chunk P、chunk P1合并成一个大的空闲块。实现堆重叠这个新合并的大空闲块其范围从伪造的0x602150一直到chunk P1的末尾。而原先正在使用的chunk P0x602250开始就被包含在了这个大的空闲块内部。这就形成了堆重叠。泄露libc地址现在我们拥有一个巨大的空闲块。我们可以将它再次申请出来作为一个大数组。由于它包含了原来chunk P的区域我们可以读取或覆盖chunk P之前和之后的内容。关键的一步是泄露libc的基地址。在glibc的堆管理中main_arena结构体的指针例如unsorted_bin的地址会被写入到空闲的大块small/large/unsorted bin的fd和bk指针中。通过读取我们这个大块中特定偏移的数据就能获得一个指向libc内部的指针。结合题目提供的libc.so.6文件计算出这个指针与libc基地址之间的固定偏移我们就得到了libc的加载地址。3.4 第四阶段最终利用与getshell获得了libc基地址我们就拥有了攻击的“弹药库”——libc中充满了有用的gadget和函数地址。构造ROP链或修改钩子一种常见方法是利用堆漏洞改写__malloc_hook或__free_hook的函数指针。这两个钩子函数指针位于libc的数据段当malloc或free被调用时如果这些钩子非空就会先执行它们指向的函数。我们可以将__free_hook的值修改为system函数的地址。布置payload我们需要将/bin/sh字符串的地址作为参数传递给system。这可以通过在堆上布置字符串并确保调用free时其参数要释放的块地址恰好指向这个字符串来实现。或者更稳定地使用ROP链但需要栈迁移到堆上。触发执行在本漏洞利用中由于我们已经能任意写我们可以直接计算__free_hook的绝对地址libc基址偏移然后通过我们控制的堆写原语将这个地址的内容改为system的地址。同时我们确保一块内存的内容是/bin/sh。调用free最后程序调用free(ptr)其中ptr指向包含/bin/sh的块。由于__free_hook被改写为system实际执行的是system(“/bin/sh”)从而获得一个shell。4. 关键调试技巧与实战心得整个利用过程对调试的依赖性很强。以下是一些在实战中总结出来的关键技巧和心得4.1 使用pwndbg/peda增强GDB纯GDB在分析堆问题时非常吃力。务必使用pwndbg或peda插件它们提供了强大的堆命令heap显示堆的概览。bins显示所有binsfastbins, unsorted_bin, smallbins, largebins的状态。chunk或vis以图形化方式查看某个chunk及其周边内存。find_fake_fast一个非常有用的命令可以帮助你快速寻找可以用来伪造fastbin chunk的地址。在触发零字节写入和后续合并的关键步骤前后反复使用heap和bins命令观察堆状态变化是理解利用是否按计划进行的最直接方法。4.2 精确计算偏移与地址所有计算必须精确到字节。你需要知道二进制文件的加载基址因为没开PIE通常是0x40000064位或0x804800032位。可以用vmmap命令在调试器中确认。libc的加载基址在泄露之前是未知的但泄露后需要准确计算。vmmap命令也能看到。堆块的绝对地址通过调试器在第一次malloc后打印指针获得。__free_hook、system等符号在libc中的偏移使用readelf -s libc.so.6 | grep “__free_hook”和strings -t x libc.so.6 | grep “/bin/sh”等命令在题目提供的libc文件上获取。一个常见的错误是混淆了用户数据区地址和chunk头地址。malloc返回的是用户数据区mem的指针而chunk头在它前面0x10字节64位无tcache。在计算fd指针覆盖目标时务必使用chunk头的地址进行计算。4.3 应对ASLR与堆地址随机化即使程序没有PIE堆的起始地址brk在每次运行时也是随机的ASLR。这意味着你调试时获得的堆地址在远程攻击时是不同的。我们的利用脚本不能硬编码这些地址。解决方案是信息泄露这是必须的一步。通过堆重叠泄露出的libc地址或堆地址我们可以反推出当前堆的布局。相对偏移我们的利用链应该基于相对偏移来构建。例如“chunk B在chunk A后面0x70字节”“伪造的chunk在chunk P前面0x100字节”。在脚本中我们通过泄露得到一个基准地址比如chunk A的地址然后通过加减偏移来计算其他所有需要的地址。4.4 编写稳健的Exploit脚本使用pwntools库编写Python脚本是标准做法。一些要点交互与调试在脚本中合理使用context.log_level’debug’来打印详细的通信信息。对于本地调试可以使用gdb.attach(p)来在关键时刻启动GDB附着调试。封装操作将malloc(size),free(idx)等操作封装成函数使主逻辑清晰。健壮性在发送payload前可以加入sleep(0.1)以避免因网络或处理速度导致的粘包问题。对于关键步骤如触发写入可以添加检查点通过接收程序的反馈来确认是否成功。多版本适配虽然本题是glibc 2.23但了解不同版本差异很重要。例如glibc 2.26引入tcache后fastbin的利用链会有所不同。pwntools的FmtStr、ROP等模块也能帮助构建更复杂的payload。5. 漏洞的现代意义与防御思考WHCTF2017这道题虽然基于一个较老的glibc版本但其揭示的原理在今天依然有参考价值。零字节溢出Off-by-One Null Byte是堆漏洞中的一个经典类型在现实世界的软件中也时有发现。从防御角度看现代编译器和操作系统已经引入了多重缓解措施ASLR PIE地址空间布局随机化和位置无关可执行文件增加了预测地址的难度。Stack Canary保护栈不被溢出但对堆漏洞无效。NX/DEP数据页不可执行阻止了直接在堆栈上执行shellcode迫使攻击者转向ROP。堆保护机制现代glibc加入了更多检查例如unlink时对前后块完整性的验证corrupted size vs. prev_size、对fastbin中chunksize字段的检查malloc(): memory corruption (fast)等使得文中描述的简单利用链在很多新版本上直接失效。沙箱与隔离使用seccomp等机制限制程序可用的系统调用即使拿到代码执行权也无法执行execve来getshell。作为攻击者研究这些老漏洞的价值在于理解漏洞利用的“元技能”如何将微小的内存破坏逐步放大如何理解并操纵复杂系统的内部状态如glibc堆管理器以及如何绕过日益增强的防护。这种思维模式在面对新出现的、更复杂的漏洞时是至关重要的。作为开发者则应该从中学到编写安全代码的教训始终对内存操作保持敬畏特别是涉及指针和边界计算时释放指针后及时置空使用更安全的语言或库以及保持依赖库的更新。安全是一个攻防不断演进的过程而理解攻击是构建更好防御的第一步。这道“stackoverflow”挑战无疑是一个绝佳的起点。