House of Orange:无free堆利用的经典攻击链
House of Orange这个名字放在CTF里总有一种“万圣节限定”的唬人感但实际上它属于PWN堆利用中里程碑式的进阶技巧。我现在还清楚地记得第一次在一道没有free功能的题目里折腾半天毫无进展最后被某导师点了一句“你可以让它自己把top chunk交出来”才真正打开这扇门。说白了这套打法解决的核心问题非常朴素没有free怎么把堆块送进unsorted bin更进一步怎么在不开shell的情况下把IO_FILE结构链上的控制流劫持干净。整个过程会串起top chunk伪造、unsorted bin attack、FSOP三条线如果你已经会基本的堆溢出和unlink那这篇文章可以帮你把这些零散知识点拧成一条完整的攻击链。1. 这个技巧到底解决了什么难题1.1 没有free的堆利用卡在哪很多CTF题目为了增加难度会故意去掉free功能或者把free限制得死死的。吃透house of orange之前我和不少人一样遇到这种题第一反应是找malloc_hook、找one_gadget、恨不得自己手写一个free。但glibc的堆管理器其实早就给了我们一个隐藏的“免费释放通道”只是大多数时候没人注意它。正常情况下我们只有add和edit功能堆块一旦分配出来就在那里躺着永远回不到bin里。这种局面下堆漏洞再明显也没法组织起有效的攻击因为unsorted bin、fastbin这些我们最依赖的“弹药库”根本得不到补充。house of orange的核心思路就是通过伪造top chunk的size字段然后发起一个不合理的malloc请求逼glibc进入sysmalloc分支在扩展堆之前替我们把旧top chunk释放进unsorted bin。这一步一旦成立后面的故事就顺理成章了。unsorted bin里有了chunk就能泄露libc地址有了libc地址就能定位_IO_list_all、global_max_fast这些关键符号再配合unsorted bin攻击把目标地址改写最后触发FSOP完成getshell。总结下来就是用一个看似普通的malloc请求撬动整条攻击链。1.2 四条主线组成的完整攻击链如果只看单个知识点house of orange好像什么都是“旧东西”——改top chunk size是堆风水unsorted bin attack是经典的任意地址写IO_FILE伪造是FSOP的常规操作。但真正高明的地方在于把四件事串成一条链伪造top chunk的size让它无法满足下一次malloc请求。触发sysmalloc让glibc把旧的top chunk送进unsorted bin。利用unsorted bin里的chunk泄露libc并构造一次unsorted bin attack把关键全局变量改写。伪造IO_FILE_plus结构和vtable指针通过malloc出错路径触发IO链表遍历最终调用system。这里每一步单独拿出来都有人会但能像这样串联起来并且在真实题目里灵活变换偏移和布局才是这个技巧真正的门槛。很多人卡住不是因为某一个机制不懂而是不知道这些机制之间怎么咬合在一起。1.3 适合在什么版本环境下学习house of orange最经典的验证环境是glibc 2.23也就是Ubuntu 16.04默认带的libc版本。原因有两个一是2.23中vtable没有做完整性校验伪造vtable直接指到自己的可控内存就能过检查二是_IO_list_all、_IO_flush_all_lockp这些符号和函数在2.23里的调用路径非常清晰适合教学。另外还需要提前确认题目程序大概率开了Partial RELRO甚至Full RELRO这些不影响本技巧因为我们不会去改got表但NX必须开启因为最终目标是栈上或堆上的代码执行而不是直接注入shellcode。PIE开不开都行开了就多一步泄露基址。栈保护更是无所谓因为我们根本不碰返回地址。整体来说这套打法对保护机制的依赖程度非常低这也是它成为经典的重要原因。2. 四个底层机制拆开揉碎讲清楚2.1 top chunk的size为什么改一个字节就能翻天top chunk是堆顶部的那一大块空闲内存程序后续所有新的malloc请求在bin里找不到合适的chunk时都会从top chunk上切割。top chunk和其他普通chunk一样也有自己的size字段。正常情况下我们不会去管它但如果你拥有一次堆溢出能越界改写top chunk的size局面就完全不同了。修改top chunk size有一个关键约束它不仅要满足8字节对齐而且还得让glibc觉得这是一个“合法”的top chunk。实际利用中常见做法是把size的高位字节清零只保留一个较小的合法尺寸比如经典的0xc1。这个值里最低位的P位前一个chunk在使用中要保留为1所以实际可用的chunk大小是0xc0192字节。这里顺便解释一下为什么常见用例喜欢选0xc1而不是更小或更大的值。太小的话后续从unsorted bin里切分时留给攻击布局的空间不够太大又可能不满足某些glibc版本的校验条件导致分配直接走mmap分支。0xc0正好属于small bin的范围后续可以配合small bin链的布置和_IO_list_all附近的内存布局对上。当然具体选什么值最终取决于题目给你的溢出长度和你后续想怎么构造0xc1只是一个比较顺手的默认值。2.2 sysmallocglibc自己帮我们把top chunk“free”了当你伪造完top chunk的size之后下一步就是发起一个超过top chunk剩余空间的malloc请求。比如top chunk现在只有0xc0字节你直接一个malloc(0x1000)bin里又没有合适的chunk可以满足posix_memalign之类的入口就会走_int_malloc再走到sysmalloc分支。sysmalloc这个函数的本职工作是扩展堆要么用brk扩大数据段要么用mmap映射一块新的内存。但在真正请求新内存之前它有一段非常“贴心”的逻辑如果旧的top chunk尺寸还合法就会先把旧top chunk“就地”释放到unsorted bin里然后再去扩展堆。注意这个释放是glibc自己做的不需要你的程序有任何free函数。这一步的威力在于它给了你一个伪造的“免费释放原语”。你只需要控制top chunk的size和下一次malloc请求的大小就能决定哪些内存会被送进unsorted bin。这就等同于在没有free功能的程序里凭空制造了一个free操作而且是完全可控的。实操中有一个点必须注意malloc请求的大小不能太大。如果请求的大小超过了sysmalloc判断的阈值比如超过了系统内存映射的相关限制glibc会直接走mmap分支而不是先进unsorted bin整理old_top。那样的话你的fake top chunk就浪费了分配结果也不会回到你期望的bin链里。所以常见操作是把请求控制在0x1000这种不会触发mmap的大小。2.3 unsorted bin attack一次固定值的任意地址写unsorted bin attack的原理可以从_int_malloc取出unsorted chunk的代码片段里看出来。当malloc从unsorted bin里取下一个victim时它要做两件事把victim从链上摘下来然后把链重新接好。问题出在这一段bck victim-bk; unsorted_chunks(av)-bk bck; bck-fd unsorted_chunks(av);第三行bck-fd unsorted_chunks(av)把unsorted_chunks(av)的值写入bck 0x10的位置。如果你通过堆溢出等手段把victim-bk伪造成了某个目标地址减0x10那么这次赋值就会向目标地址写入一个固定的值——unsorted bin链头指针在64位环境下就是main_arena0x78左右。这个原语的特点是“写什么”你说了不算但“写到哪里”完全由你控制。house of orange里这个固定值有两大用途一是改写global_max_fast把fastbin的允许范围扩展成超大值方便后续做fastbin attack或者直接在malloc_hook附近构造fake chunk二是配合IO_FILE结构伪造把_IO_list_all指到我们可以精确控制的内存区域。更妙的是一次攻击可以同时影响两个目标因为某些情况下你并不需要单独写两个地址布局得当就能让一个写入动作同时起到双重作用。我用一句话帮大家记忆unsorted bin attack的核心就是“把victim-bk指到target-0x10然后等着glibc帮你向target写一个高地址”。2.4 IO_FILE与vtable从缓冲区结构到控制流IO_FILE结构体是glibc里所有标准输入输出流的基础。每个打开的流比如stdout、stderr在内存里都对应一个_IO_FILE_plus结构。这个结构里前半部分是_IO_FILE字段后半部分有一个vtable指针指向一个函数指针表。正常程序在缓冲区刷新、流关闭等时刻会通过vtable调用对应的处理函数。FSOP——也可以理解为对IO系统的攻击——就是通过劫持这个vtable指针把正常的函数调用变成攻击者想要的函数调用。要对这个结构动手通常得先控制_IO_list_all这个全局链表。因为glibc在刷新所有流的时候会从_IO_list_all开始遍历整个链表逐个处理。在house of orange的经典版本中最后的触发路径是这样的当你构造的某个malloc请求触发错误时glibc会调用malloc_printerr打印错误并准备abort而在2.23里这个错误处理路径会走到_IO_flush_all_lockp遍历_IO_list_all指向的链表。如果你已经通过unsorted bin attack把_IO_list_all指向了精心伪造的IO_FILE结构并且vtable也被改成了可控地址那么遍历到伪造节点时就会调用伪造的vtable槽位从而执行system。伪造IO_FILE时有一个非常关键的小技巧把结构体起始的8字节也就是_IO_FILE的_flags字段直接写成字符串/bin/sh。因为最终调用的是类似system(fp)这样的形式第一个参数恰好就是指向这个结构的指针把结构体开头变成命令字符串就能顺理成章地执行system(/bin/sh)。3. 手把手实操一个经典流程3.1 环境准备与工具选择我建议你直接在glibc 2.23的环境里练习可以用对应版本的docker镜像或者直接找Ubuntu 16.04的虚拟机。题目程序我建议用一个只提供add和edit功能的迷宫题来练手重点是确认两件事一是有一个堆溢出点能够越界改写相邻chunk和top chunk的size字段二是没有free功能或者free被限制得没法直接用。工具方面pwntools用来写expgdb配合pwndbg或gef用来观察堆结构。个人强烈建议在练习过程中多开一个gdb窗口因为house of orange的链路比较长每一步的堆状态都应该亲眼看一看。具体来说你会频繁用到这些命令heap bin p main_arena x/20gx _IO_list_all x/20gx main_arena这些命令能帮你确认unsorted bin里到底有没有chunk_IO_list_all被改成了什么值main_arena附近的内存长什么样。不要等到整个exp跑挂了才开始看堆那是排错效率最低的方式。3.2 第一步构造假的top chunk并泄露libc整个流程的第一步是泄露libc基址。假设你已经有能力通过edit溢出改写top chunk的size字段典型操作如下先通过add分配两个普通chunk。利用溢出把第一个chunk的数据区填满然后越界修改top chunk的size比如改成0xc1。调用malloc(0x1000)触发sysmalloc让旧的top chunk进入unsorted bin。再次malloc一个小chunk比如malloc(0x20)系统会从unsorted bin里切出一块。切完之后unsorted bin里剩下的chunk的fd和bk会指向main_arena附近malloc返回的这块数据里前8字节就是unsorted bin链表的指针。只要程序有show功能能把这块数据打印出来你就能拿到main_arena相关的地址。然后用libc_base leaked_addr - libc.symbols[main_arena] - offset之类的方式算出libc基址。这里要注意一个细节你拿到的可能是main_arena0x78这种偏移所以最好用gdb先确认真实地址和偏移关系不要想当然地认定泄露出来的就是main_arena的本体。3.3 第二步计算关键符号与偏移泄露libc之后你需要确认三个关键地址_IO_list_all、global_max_fast、以及main_arena在libc里的实际位置。这些小符号在同一个libc文件里都能直接查到io_list_all libc_base libc.symbols[_IO_list_all] global_max_fast libc_base libc.symbols[global_max_fast] main_arena_addr libc_base libc.symbols[main_arena]但是符号表里的main_arena和你在堆管理器中实际看到的main_arena可能不是同一个地址。这个问题很经典因为glibc里main_arena符号和实际堆管理使用的arena首地址往往存在固定偏移。写exp前去gdb里确认一次真实地址然后在脚本里把这部分额外偏移写死。我第一次调的时候就在这里栽了跟头花费了很长时间才发现算出来的global_max_fast根本没写对位置。另外_IO_list_all和main_arena的偏移关系在不同版本里也有差异。虽然2.23系列的差别不算大但不同构建版本的libc还是可能有细微出入。建议整个练习过程中使用同一个libc文件并且在自己环境里重新计算不要直接抄网上旧exp里的硬编码偏移。3.4 第三步伪造small bin chunk与IO_FILE结构接下来是攻击链最核心的布置阶段。你需要在一个可控的内存区域里同时准备好两部分数据一部分是让unsorted bin attack能够执行的fake chunk另一部分是让FSOP能够落地的IO_FILE_plus结构。fake chunk的size字段通常选择0x60或0x61这个规模因为这样它最终会被链进small bin的某个固定索引里方便你控制main_arena里对应bin链上指针的内容。这里我给出一个常用的布局思路fake_chunk_start 目标可写区域 offset 0x00: prev_size (通常为0) offset 0x08: size 0x61 offset 0x10: fd 0 offset 0x18: bk io_list_all - 0x10这样构造完当这个fake chunk出现在unsorted bin里并且被取出时因为它的bk被设置成了io_list_all - 0x10unsorted bin攻击就会把main_arena附近的值写入io_list_all。伪造IO_FILE_plus时重点控制几个字段尤其是让结构体起始8字节为/bin/sh并且把vtable指针指向一个你能写入system地址的位置。一个常见做法是把整个fake structure放在堆上vtable也放在堆上让vtable偏移处的内容等于system地址和合适的IO函数槽位。不要忘记在2.23中vtable本身不校验范围所以这个操作非常自由。3.5 第四步触发FSOP拿到shell结构布置好之后最后一步是触发。触发条件说到底就是让glibc进入错误处理路径走到_IO_flush_all_lockp。在house of orange的经典场景里你只需要再一次发起一个无法完成的malloc请求让top chunk再次触发sysmalloc然后程序报出malloc(): top chunk is too small之类的错误错误处理就会进入IO刷新路径。此时伪造的_IO_list_all已经被遍历vtable里的system槽位被调用参数恰好是指向伪造结构开头/bin/sh的指针shell就这样弹出来了。整个链路从表面上看就是连续几次普通的malloc但每一次malloc背后都在悄悄改变堆管理器的内部状态。3.6 一个可参考的exp骨架下面我贴一个简化版的exp骨架只保留核心步骤和一些关键注释具体偏移需要你根据实际题目和libc重新计算#!/usr/bin/env python3 from pwn import * context.arch amd64 context.log_level debug io process(./pwn) elf ELF(./pwn) libc ELF(./libc.so.6) def add(size, data): io.sendlineafter(b , b1) io.sendlineafter(bsize, str(size).encode()) io.sendafter(bdata, data) def edit(idx, data): io.sendlineafter(b , b2) io.sendlineafter(bindex, str(idx).encode()) io.sendafter(bdata, data) def show(idx): io.sendlineafter(b , b3) io.sendlineafter(bindex, str(idx).encode()) return io.recvuntil(b----) # 1. 分配两个chunk并溢出修改top chunk size为0xc1 add(0x50, bA) add(0x50, bB) edit(0, bA * 0x50 p64(0xc1)) # 2. 触发sysmalloc让old_top进入unsorted bin add(0x1000, bC) # 3. 申请小chunk从unsorted bin里切出并泄露libc add(0x20, bD) leak show(2)[:8] libc_base u64(leak) - 0x3c5178 # 根据实际offset调整 log.info(libc_base: hex(libc_base)) io_list_all libc_base libc.symbols[_IO_list_all] main_arena libc_base libc.symbols[main_arena] global_max_fast libc_base libc.symbols[global_max_fast] # 4. 布置后续所需的fake chunk和IO_FILE结构 payload b payload payload.ljust(0x68, b\x00) payload p64(0x61) payload p64(0) payload p64(io_list_all - 0x10) payload b/bin/sh\x00 ... edit(1, payload) # 5. 触发unsorted bin attack add(0x1000, bT) # 6. 触发malloc错误路径 add(0x10, bE) io.interactive()注意这只是骨架不同题目需要调整的不仅是偏移还包括fake chunk放哪个位置、vtable指针指哪块内存、以及从哪个chunk开始溢出。骨架的作用是让你对整体节奏有个感知不要把它当万能模板直接套。4. 版本差异当vtable开始被校验之后4.1 glibc 2.24引入了校验规则house of orange太“恶名昭彰”glibc官方当然不会坐视不管。在2.24版本里增加了一个关键校验函数IO_validate_vtable。它会在调用vtable槽位之前先检查vtable指针是否落在合法的vtable区间内。如果你像2.23那样直接把vtable指到一个堆上的可控地址程序会直接崩溃报出Fatal error: glibc detected an invalid stdio vtable。这个改动对FSOP类攻击来说是釜底抽薪因为几乎所有经典payload都依赖“vtable随意指”。于是后续几年PWN圈的注意力转向了如何在合法vtable区间里做文章。4.2 绕过思路合法vtable区域的利用比较经典的绕法是把vtable指针改到已有的合法跳转表比如_IO_str_jumps或_IO_wstr_jumps。这些跳转表本身在libc的只读数据段里属于合法区域能通过校验。但是它们的函数指针指向的是glibc内部的正常函数直接利用它们并不能执行system。真正的突破口在于vtable校验只检查指针范围不会检查你具体跳到的是哪个槽位。所以你可以把vtable指针精确地指向合法跳转表内部的某个位置让最终取出来的函数指针不等于原本的IO函数而是落在一些有用的libc函数地址上。结合IO_FILE字段的布局让调用时参数恰好指向/bin/sh就能在绕过校验的同时完成getshell。这个思路听起来有点绕实操起来更是需要反复调偏移。我在练习时最深的体会是不要一开始就想着精确控制每一个字节而是先用gdb观察一下当你把vtable设置成某个合法区域的中间位置时最终_IO_OVERFLOW取到的函数地址到底落在哪个符号上。看清楚真实的调用路径比盲猜偏移高效得多。4.3 2.27之后的新变化从2.27开始IO相关的安全加固又往前走了一步例如增加了很多字段的合法性检查同时对宽字符、编码相关的IO结构也做了更细的校验。单纯沿用house of orange的原始攻击形态会变得非常困难。在较高版本的libc下PWN方向更多会转向其他技术路线比如利用exit_hook、setcontext、或者结合其他漏洞类型完成控制流劫持。所以如果你是刚接触这个技巧建议还是从2.23练起。先把这个体系的逻辑彻底搞明白再去研究后面版本的各种绕过否则容易被各种校验分支搞晕。明白历史版本里“为什么能打”和“为什么被打”对理解堆利用的本质非常有帮助。5. 踩坑与排查笔记5.1 改了top chunk size之后malloc直接走了mmap这是最常见的第一坑。表象是执行了malloc(0x1000)之后并没有像预期那样触发sysmalloc对old_top的释放堆布局也没有任何变化。原因多半是你malloc请求的大小太大超出了sysmalloc判断走mmap的阈值。系统直接mmap了一块新内存根本懒得整理你的old_top。排查方法很简单在gdb里断到sysmalloc或直接在malloc返回后查看bin。如果unsorted bin没有任何新chunk八成就是触发方式不对。这时候把malloc请求调小同时确认top chunk的size值确实已经改成功。另外一个原因也有可能是top chunk size改得不对导致old_size小于MIN_CHUNK_SIZEsysmalloc里判断old_top不合法于是跳过释放直接映射新内存。遇到这种情况重新检查你写入的size值确保它不仅是0xc1这种好看的数字而且确实覆盖到了top chunk的size字段。5.2 崩溃在unlink处通常是fd和bk没配对unsorted bin attack虽然写值固定但取chunk的时候还是要经历一个基本的链表摘除过程。如果victim的fd或bk胡乱设置链表操作可能直接崩掉。最常见的情况是你只设置了bk忘了fd也很重要或者fd的值落在了不可写内存上。在布置fake chunk时建议先在gdb里看清楚这个chunk进入unsorted bin之后fd和bk分别会被赋成什么值。有些场景下不需要手动伪造fd因为chunk从bin里取出来再放回去的过程中glibc会自己改写这些字段。但一旦需要手动指定就要保证fd指向一个可写地址。我个人的习惯是能不伪造fd就不伪造让堆管理器自动帮忙维护尽量减少手动设置带来的崩溃风险。5.3 vtable校验崩溃和最后一个槽位的选择如果你在2.24以上的libc里尝试house of orange最典型的症状是程序打印Fatal error: glibc detected an invalid stdio vtable。这并不代表你的整个攻击链错了只是最后一步vtable这块被拦住了。可以先换回2.23库做通整个流程确保前面几步没问题再单独研究vtable绕过。排错时把问题拆开会轻松很多。即便在2.23里vtable槽位的选择也值得细心验证。你需要确保最终调用的槽位对应的是你写入system地址的地方。常见选择是让vtable偏移若干字节后恰好让原本的_IO_OVERFLOW槽位指向system。这里可以用gdb在_IO_flush_all_lockp下断点单步观察进入伪造IO节点后系统准备调用的函数地址到底是什么。5.4 调试IO_FILE链路的几个gdb命令最后分享几个我常用的调试命令帮你在实际排错时少走弯路# 查看_IO_list_all当前指向 x/gx _IO_list_all # 查看伪造的IO_FILE结构内容 x/40gx 伪造结构地址 # 查看vtable指针和槽位内容 x/20gx vtable地址 # 在FSOP关键函数下断 b _IO_flush_all_lockp b IO_validate_vtable先看_IO_list_all指没指对再看指向的内存如何被解释成IO_FILE结构最后看vtable槽位取出来的是哪个函数。一条链路按这个顺序检查基本能定位九成的问题。老实说我这个技巧练了不止一次才做到不看笔记直接打穿。每次卡住回去翻gdb记录都会发现是某个看似不起眼的偏移多算了8字节。但也正是这些反复折腾让我把top chunk、unsorted bin和IO_FILE这几位老伙计的脾气摸得一清二楚。如果你正在学这个别怕报错一次次的segfault就是最好的老师。