DEFCON决赛pwn题flippidy:从堆溢出到任意代码执行的全链路分析
每年看DEFCON总决赛的pwn题目总有一种“这是拿来练手的还是拿来长见识的”分裂感。有些题就是纯堆套路拼接看一眼菜单就知道后面要干嘛另一些题则是把逆向、堆管理、逻辑漏洞全部揉在一起逼你在有限时间内做决策。flippidy就是后者而且它是那种做完之后会对堆管理模型产生新认识的题目。这道题来自DEFCON CTF 30 Finals作为决赛题它的核心看点不只是“有一个堆溢出”而是这个溢出点藏得很深触发路径反直觉最后劫持控制流之前还需要你理解堆管理器的内部约定。换句话说它不是刷熟练度的题而是真正考验从逆向定位漏洞到构造完整利用链的综合性题目。这篇文章我会从零开始按“先搞清楚程序在做什么再定位漏洞最后设计利用链”的顺序把flippidy的整体分析过程完整过一遍。适合正在刷pwn题、想提升堆利用能力的CTF选手也适合做漏洞研究、想知道决赛题目怎么把老技术玩出新花样的安全工程师。1. 题目定位与整体分析思路1.1 这是怎样一道题目先说结论flippidy本质上是一个基于堆管理菜单的pwn题但比常见的“增删改查”题目多了一个非常特殊的操作。程序维护了一个消息队列系统支持消息的创建、编辑、查看、删除这些都很常规。但额外有一个“翻转”功能允许你对堆上某一个字节执行按位取反操作而且这个操作的触发还带限制条件。这个设计直接呼应了题目名称。flippidy听起来像 flip 和 something 的组合实际上暗示了整个利用过程的核心动作就是“翻转”。在做逆向分析时如果题目名本身就暗示了某种机制我通常会把它当作最高优先级线索来跟踪。从难度定位来看flippidy属于“需要完整理解堆块布局才能稳定利用”的类型。它不依赖什么冷门glibc内部结构也不偏向某个特定版本的奇技淫巧而是把问题集中在“多写一个字节”和“翻转某个字节”这两个看似很小的动手能力上。决赛题喜欢用这种小动作做出大效果因为每个参赛队伍都会堆利用但能把一个字节玩明白的才是真正理解堆管理的人。1.2 开局先定攻防策略拿到二进制之后我习惯性的第一步永远是 checksec而不是急着拖进反汇编器。对于pwn题来说checksec的输出基本决定了整条利用链的走向。我用通常的命令先过一遍checksec --fileflippidy结果里几个关键项是这样RELRO为PartialStack Canary开启NX开启PIE开启。这意味着几个约束条件栈上不能直接塞shellcode必须走ROP或劫持已有函数指针代码段地址每次加载都变必须先泄露基址Partial RELRO意味着GOT表仍可写但如果走hook劫持其实也不太依赖GOT。这些保护组合非常典型它没有开Full RELRO所以某些场景可以直接改GOT但更常见的决赛题思路是劫持__free_hook或__malloc_hook因为这类目标在libc里只需要泄露一次libc基址就能定位。逆向工具主用IDA动态调试配合pwndbg。pwndbg在堆题调试里不可替代它的heap、bins、vis_heap_chunks这几条命令能直接看到堆块布局比手动计算偏移快得多。1.3 从题目名字读出的线索“flippidy”这个名字值得停下来琢磨。CTF命名通常有三种来源一是对程序内部机制的暗示二是对漏洞利用手法的暗示三是纯粹的随机词。flippidy明显属于第一类。当时看到题目名我的第一反应是快翻一下IDA里的字符串和函数名看有没有跟bit、flip、invert相关的符号。果然程序里有一个名为flip_byte的子函数这个函数的作用是接收一个索引参数然后对指定地址的字节执行~byte操作。也就是说题目从设计上就在指引你往“堆上有某个字节翻转它会造成什么影响”这个方向去思考。这种命名习惯其实是一种良性引导决赛题不希望你浪费时间在逆向入口上而是希望你把精力集中在更核心的利用设计上。2. 逆向工程从入口到漏洞点的定位2.1 程序结构与功能接口flippidy的主循环是一个典型的菜单程序选项大概是五个创建消息、编辑消息、查看消息、删除消息、翻转字节。每个消息对象在堆上维护两个结构一个是消息内容缓冲区另一个是描述消息的元数据块。元数据块里包含三个字段指向内容缓冲区的指针、内容长度、一组状态标志位。我用伪代码把这个结构画出来大概是这样的struct message { char *data; size_t size; uint8_t flags; };这个结构本身没什么问题真正的坑出现在“编辑”这个功能里。创建消息时程序让用户输入数据长度然后根据这个长度分配数据块再分配结构体块。两个块都是通过malloc分配的也就是说它们会落在堆上具体位置取决于glibc的分配策略和当前堆布局。查看消息就是把data指针指向的内容打印出来删除消息则是先释放data块再释放message结构块。从表面代码来看释放顺序是正常的不像是UAF的典型触发方式。2.2 定位核心溢出点漏洞出在编辑消息的长度校验上。函数原型大概是void edit_message(struct message *msg, size_t new_size, char *input);从直觉上看编辑流程应该是先按new_size重新分配缓冲区再把输入拷贝进去。但flippidy的实现不是这样的它为了省一次分配直接复用了原有的data缓冲区前提是new_size不能超过原来的大小。问题就出在这个“不能超过原来大小”的校验上。程序里有一段这样的逻辑size_t allowed msg-size; if (new_size allowed) { allowed msg-size ^ 0xff; // 注意这里 }当时看到这里我第一反应是这个逻辑很怪。如果new_size大于allowed程序没有直接拒绝而是把allowed变成msg-size按位取反后的值。size_t是无符号64位任何一个正常的msg-size取反之后都会变成一个巨大的数例如0x100取反就是0xffffffffffffff00左右。这就意味着一旦请求的长度超过了原始大小程序不仅不会拒绝反而会允许你写入一个超大范围的字节。这个逻辑存在的原因可能是设计者想实现某种“弹性大小”特性但实际效果就是完全放开了写入长度限制。更关键的在于随后的read操作会把输入直接写到msg-data指向的缓冲区长度是更新后的allowed。也就是说只要第一次成功以“超限”方式调用编辑后续的写入长度就变成几乎无限大直接造成堆溢出。这个漏洞点之所以好是因为它不是一个简单的off-by-one而是一个可以完全控制溢出范围的任意长度堆溢出。利用起来非常从容不需要精心计算单字节溢出带来的复杂堆布局变化。2.3 信息泄露与UAF链路flippidy还有一个值得一提的副作用删除消息时程序并没有把message结构体里的data指针清零。也就是说删除之后如果再次调用查看功能程序会按照message结构体里残留的指针去读取已经被释放的内存。这个行为导致了一个标准的UAF。虽然删除时free了data块和message块但堆上的元数据仍然保留了原指针于是我们可以通过UAF来实现信息泄露。具体操作方式是这样的先创建一条消息并写入特定内容然后删除它。由于glibc的free会把释放的chunk放入对应的bins中chunk的fd/bk指针会被写入堆内存而fd/bk中保存的地址往往指向libc内部数据结构。通过UAF调用查看功能就能把这些指针打印出来从而拿到libc地址。这道题对UAF的使用非常克制它没有给完整的任意读写原语而是通过“释放后再查看”这种组合让选手自己挖掘信息泄露路径。从这里也能看出决赛题偏爱考验选手的逻辑组合能力而不是单纯给一个开箱即用的漏洞。2.4 保护机制清单与利用约束把保护机制全部列出来就会形成一个明确的利用边界保护项状态对利用的影响RELROPartialGOT仍然可写但不是必须利用的目标Canary开启栈溢出不可行需要绕过或完全避开栈攻击NX开启不能执行栈上shellcode需要借助libc或ROPPIE开启需要先泄露程序基址但利用重点在libc泄露一次基址即可Fortify未开启标准函数可用无需担心安全检查影响这些条件组合下来的结论很清晰可利用的目标集中在libc内部利用类型主要是“泄露基址加劫持控制流”。由于存在堆溢出和UAF比较自然的路径就是构造chunk overlap然后通过__free_hook或__malloc_hook来劫持执行流。在glibc 2.31环境里__free_hook仍然是可用目标这跟比赛环境常见的libc-2.31.so相关。如果遇到更新的glibc版本比如2.35或更高这个hook已经被移除那就需要考虑exit钩子或其他思路。所以拿到题目第一件事确认环境里跑的是哪个glibc版本是决定利用方案的前提。3. 堆溢出利用的完整链路3.1 堆利用基础chunk、size位与bins要把flippidy的利用链讲清楚得先复习一下glibc堆管理的基本规则。很多刚接触堆利用的选手会把malloc和free想成“分配一个对象”和“释放一个对象”但glibc的真实行为完全不是这样。glibc把堆内存划分成一个个chunk每个chunk的头部有一组元数据包括prev_size和size字段。size字段的低3位不参与大小计算而是分别作为PREV_INUSE、IS_MMAPPED、NON_MAIN_ARENA的标志位。free一个chunk时glibc会先检查它是否可以与前面的相邻空闲chunk合并如果能合并就合并成一个大chunk然后挂到对应的bin里。这个“合并”操作是很多堆攻击的核心因为合并过程会用prev_size和size字段来定位前一个chunk如果这些字段可以被篡改就能制造出地址重叠。可以这样理解glibc的堆管理类似于一个仓库的货架系统每个货架格子有编号和大小说明free相当于把格子标记为空并登记到特定的闲置列表。如果攻击者能篡改货架格子上的编号说明让管理员把两个不相邻的格子当作一个大格子处理就能拿到本不属于自己的存放空间。3.2 利用链第一步溢出构造chunk overlap在flippidy里堆溢出提供了篡改chunk头部元数据的直接能力。我的利用链设计分两步走第一步是构造chunk overlap。先看布局规划。我需要申请三个chunk依次是chunk A、chunk B、chunk C。其中chunk B被放置在chunk A的后面作为被溢出覆盖的目标。实际操作是创建消息A占用一个较小尺寸比如0x100字节的chunk创建消息B再占用一个同样尺寸的chunk利用编辑消息A的溢出功能把数据延伸到chunk B的头部改写chunk B的size字段和prev_size字段。这里的核心是在chunk B位于unsorted bin状态下通过修改它的size字段把它变大或套入一个错误的位置会让glibc在合并或拆分时产生错误的计算。更经典的做法是修改chunk B的PREV_INUSE标志位并伪造前一个chunk的prev_size这样在释放chunk B时glibc会误认为前面有一个空闲大块把它一起合并。合并完成之后回头再释放chunk A你会发现chunk A的指针仍然有效但它指向的内存已经被合并进一个大chunk并被放入bin。这样我们就有了一个“已释放但仍可读写”的chunk也就是overlap。构造overlap的核心目的是让后续的内存分配与释放产生不一致。当glibc从大chunk中切出一块分配给新的请求时旧chunk A的一部分仍然可以由UAF路径读写这就形成了任意地址读写的跳板。3.3 利用链第二步泄露libc基址overlap构造好之后信息泄露就非常顺手了。我用的方式是利用unsorted bin的fd/bk指针。具体步骤free一个较大的chunk让它进入unsorted bin。此时unsorted bin的fd和bk都会指向main_arena内的某个固定偏移位置通过UAF或者overlap读取这个chunk的前8字节就能得到一个libc地址用这个地址减去main_arena对应符号的偏移得到libc基址。只要拿到libc基址__free_hook、system、one_gadget这些符号的地址全部可以通过偏移计算出来。到这一步距离最终劫持控制流只差一个可写的目标。我在本地测试的时候习惯用p system从pwndbg里拿到system具体地址再用泄露出来的libc基址反推偏移是否正确。如果泄露地址和计算出来的地址落在同一个页面对齐范围内基本可以确定偏移找对了。3.4 利用链第三步tcache poisoning与任意分配glibc 2.31的tcache机制是构造任意分配的一个非常高效的原语。tcache是一个单向链表每个bin存放同尺寸的空闲chunk链表指针存放在chunk的fd字段。tcache poisoning的思路是通过堆溢出或UAF修改一个已经释放的tcache chunk的fd指针把它指向目标地址。这样下一次malloc同尺寸的chunk时glibc会把这个伪造的chunk分配给你而再下一次malloc时就会返回目标地址。在flippidy里由于已经实现了overlap修改tcache chunk的fd变得很简单只需要通过UAF写入覆盖到目标chunk的fd字段即可。目标地址的选择上我倾向于选__free_hook - 0x10。原因在于分配得到的内存起始处有一段用户数据区把__free_hook落在用户可写范围内之后直接用编辑功能写入system的地址即可。关于任意分配还有一个细节tcache链表请求的目标地址需要满足对齐要求。glibc要求chunk地址按0x10对齐而__free_hook通常位于libc数据段的一个页内地址天然满足这个对齐所以不需要额外调整。3.5 最后一步触发并获得flag目标地址分配成功后最后的操作就非常机械了通过编辑功能向__free_hook写入system的地址创建一个内容为/bin/sh字符串的chunk删除这个chunk触发free操作实际调用的是system(/bin/sh)。这里要注意free函数的第一个参数是待释放的指针也就是指向/bin/sh字符串的指针。因此把free_hook覆盖成system之后free(ptr)就会变成system(ptr)如果ptr指向的内容恰好是/bin/sh就能直接拿到shell。拿到shell之后flag通常就在当前目录或题目指定的路径下找到并读取即可。对于CTF比赛场景getshell不是目的证明控制流劫持成功才是根本。3.6 “flip”操作在利用中扮演的角色我在前面的利用链里没有直接用到flip_byte这个操作那这个操作到底有什么意义答案是它被用来制造原生的size字段翻转。在程序的设计里flip_byte允许你翻转堆上任意地址的一个字节而题目里的堆布局又允许通过多次翻转来改变关键chunk的size字段低字节。为什么要翻转size字段因为在某些场景下我们不希望依赖编辑功能的大溢出反复修改堆布局。如果只通过翻转一个字节就能把size从0x100变成0xff或者把PREV_INUSE标志位清零就能以非常隐蔽的方式改变glibc对chunk状态的判断。这个操作在利用链中的作用是辅助性的它让布局过程不需要每次都触发超大写入减少崩溃风险。在调试过程中翻转一个字节的副作用远小于一次大规模溢出可以更精确地控制每一步的状态。从题目设计者的角度来看flippidy这个名字暗示的就是这种利用方式不是一股脑地溢出覆盖而是通过精确的字节翻转来逐步改造堆的元数据最终让堆管理器自己把控制权交出来。4. 实操复现与踩坑记录4.1 搭建本地调试环境复现flippidy的第一步是准备正确的运行环境。比赛环境里的libc版本是复现的关键如果本地libc版本和题目不一致偏移全部对不上利用链会直接断掉。我当时的做法是先确认远程环境用的libc文件通常是.so文件在本地用容器创建一个一致的运行环境用patchelf把二进制的解释器和libc路径指向本地版本。patchelf --set-interpreter ./libc/ld-2.31.so ./flippidy patchelf --set-rpath ./libc ./flippidy没有对应环境时直接硬跑题目的exploit十有八九会崩在奇怪的地方。我见过很多人忽略这一步结果浪费了大量时间在“为什么地址不对”这种环境问题上。4.2 从触发崩溃到精确控制调试过程中我习惯把利用脚本拆成两段一段负责构造overlap并停下另一段负责继续劫持控制流。这样可以在每一段的关键位置用pwndbg检查堆状态。有用的pwndbg命令包括heap bins vis_heap_chunks x/20gx main_arenavis_heap_chunks尤其好用它用可视化方式展示当前堆块布局一眼就能看出哪些chunk处于空闲状态、哪些还占着以及chunk头部是否被篡改过。我实测时经常遇到的状态是overlap构造完成后bins里显示的自由chunk大小和预期不符。这时候不要急着继续先停下来对比vis_heap_chunks的输出看一下chunk的size字段到底被覆盖成什么值。4.3 常见问题排查速查表我在复现过程中遇到的和预见到的问题整理成一张速查表供参考症状常见原因解决思路远程执行shell后无法交互使用了非交互式shell调用方式用sendline保持stdin打开或直接通过socket交互泄露的libc地址不稳定libc版本不匹配偏移错误用libc-database或libc.rip识别实际版本崩溃发生在malloc_consolidate堆块重叠后触发了合并逻辑错误优先检查chunk的PREV_INUSE标志位确保前一个chunk仍标记为占用tcache分配失败key字段被改写导致double-free检测触发避免重复释放同一个chunk或用UAF绕过翻转操作导致目标字节意外变化没有确认当前字节的值先读取目标字节计算翻转后的值确保符合预期system(/bin/sh)执行后崩溃参数指针不对或__free_hook地址偏移错误在pwndbg里查看__free_hook当前内容确认写入成功表格里的第三行malloc_consolidate崩溃是我在利用过程中遇到最多的坑。这通常发生在释放一个大chunk而它前面的chunk状态被错误标记为“空闲”或“占用”时。glibc会尝试合并但计算得到的地址根本不是合法的chunk头于是直接报错。解决方式就是回到vis_heap_chunks里确认所有相邻chunk的size字段和PREV_INUSE标志位都与预期一致。4.4 一条血泪教训这次复现让我印象最深的教训是不要在堆状态没看明白之前盲目触发利用链。堆利用是一个状态机每一步都会改变glibc内部状态如果中间某一步出错后续所有操作都会基于错误状态继续最终表现为各种奇怪的崩溃。我第一次跑通完整exp之前卡了两三个小时原因只是overlap构造后多写了一个字节把一个不需要修改的size字段高字节也覆盖了。结果chunk大小变得异常后续的所有分配全部错位。后来用vis_heap_chunks一眼发现修改size字段时只需要动低两位字节高字节应该原样保留改完就恢复正常了。这让我养成了一个习惯凡是涉及覆盖size字段的地方我会在payload里用掩码把高字节固定住或者直接分两步写先写低字节再写高字节避免一次写入误伤相邻数据。5. 从决赛题目反推防御思路5.1 为什么现代堆防护仍然挡不住这类攻击很多人会问glibc不是有tcache的key防护、safe-linking、double-free检测和PIE随机化吗为什么flippidy这种利用链还是能成立答案其实很现实这些防护主要是提高利用门槛而不是消除漏洞本身。比如safe-linking确实让tcache的fd指针被加密但加密使用的密钥是堆地址本身的某个位而攻击者已经拥有堆内读写能力完全可以推算出密钥或直接通过其他途径绕过。堆利用的核心矛盾是glibc为了让性能足够高必须尽量减少每次分配释放时的检查开销。性能与安全之间的平衡决定了它不可能在每个步骤都做完整验证。攻击者利用的就是这些“出于性能考虑省略的检查”。这不是在否定glibc的防护努力而是说明一个现实只要存在内存破坏漏洞堆管理器能做的只是增加攻击难度而非彻底阻止利用。对防御方来说最重要的不是寄希望于堆管理器而是尽可能减少漏洞本身的存在。5.2 工程侧可行的防御方向从flippidy这道题反推真实工程的防御我认为有几条思路是清晰且可落地的第一堆布局的随机化。如果分配器能够在每次运行时不按固定顺序分配chunk攻击者就很难提前规划相邻chunk的布局。目前的主流glibc还没做到这一点但一些新的分配器设计已经在尝试。第二隔离与权限收敛。把敏感操作放到独立的进程或容器中即使攻击者拿到了堆读写能力也难以直接影响到核心组件。堆漏洞利用的最终目标通常是劫持控制流如果能阻断这个目标达成前面再精彩的利用链也只能停留在信息泄露层面。第三主动检测与崩溃监控。在测试阶段和线上环境对异常堆操作、崩溃日志、重复释放模式进行监控能提前发现被利用的迹象。这些思路无法完全杜绝攻击但能把漏洞利用的成本大幅提高。攻防本来就是成本博弈防御的目标是让攻击者觉得“这不划算”。5.3 CTF经验在真实研究中的迁移CTF堆题和真实世界漏洞研究的差别没有想象中那么大。CTF里学到的chunk overlap、tcache poisoning、UAF组合在真实漏洞利用研究中同样常见只不过真实场景里没有精心设计的菜单函数你需要从更大规模的代码里去定位攻击面。我在分析真实CVE时经常先用CTF的思路审视图先确认攻击者能控制哪些输入再确认这些输入能否到达内存操作最后思考它们能影响哪些内存布局。这个流程和做flippidy完全一致。反过来真实世界的漏洞往往更刁钻接口复杂、保护机制齐全、可利用的原语通常非常微弱。这种时候CTF训练出的“从微小异常推导完整利用链”的思维方式就格外有价值。flippidy里那个看似无用的flip_byte操作本质上就是训练你去思考微小字节变化如何在整个系统中被放大成致命效果。做这道题的过程中我自己最大的体会是堆利用的高下之分不在于你背了多少种bin attack的模板而在于你能不能像调试器一样理解glibc在每个分支下的决策逻辑。flippidy里的溢出点一开始看起来根本不像漏洞但当你理解了它在特定输入下会放开长度限制时后续的一切都水到渠成。最后再分享一个我实测后的小技巧做堆题时无论是赛题还是自己搭的测试程序都建议把MALLOC_CHECK_环境变量和mtrace这种工具用起来。它们能帮你在早期就暴露很多内存操作错误避免把问题拖到后期变成难查的崩溃。真正跑通一条利用链之后再回到源头看一下漏洞触发条件你对题目设计意图的理解会比只抄exp的人深得多。