☰
House of Orange:无free堆题中从top chunk劫持到FSOP的完整利用链
2026/10/12 8:17:28 网站建设 项目流程

年初打某场线下赛,被一道菜单题卡了一晚上:add和show两个功能,堆溢出给得明明白白,唯独没有delete。没有free,意味着我熟悉的unsorted bin attack根本找不到触发点——你没机会把一个正常chunk释放进bin里,改bk更是无从谈起。后来看前排队伍的wp才意识到,这题的标准解法是house of orange。

house of orange这套组合拳,本质上是三件事:劫持top chunk的size让malloc在分配失败时“借刀释放”旧top;用unsorted bin attack把一次写操作落到_IO_list_all上;最后构造IO_FILE结构,在程序退出或报错时通过FSOP链把执行流带到system("/bin/sh")。它是无free菜单题的经典路数,也是理解glibc堆管理器“内部动作”和IO_FILE vtable校验机制的绝佳样本。这篇文章就把整条链路从头到尾拆开讲,顺便把我踩过的坑也一并写了。

1. 无free菜单题的困局与House of Orange的解题思路

1.1 常规攻击为什么全线失效

在PWN堆题里,最顺手的模型永远是“add + edit + show + delete”四件套:先打一个fastbin或tcache的double free,或者构造unsorted bin attack,改掉__malloc_hook或者__free_hook就收工。可一旦题目砍掉delete,整套打法瞬间瘫痪。

原因很简单:glibc的bins里只会出现被释放的chunk。没有free,程序自己永远不会把chunk归还给堆管理器,你拿着溢出漏洞也只能改写已分配chunk的内容,改不了fd/bk,因为那些指针根本不存在。就算强行把某个chunk的头部改坏,最多也就是让后续malloc报错崩溃,谈不上可控利用。

所以这类题的第一个思路转折点在于:谁的释放不需要程序员手动调用?答案是top chunk。当malloc发现top剩余空间不够时,会在sysmalloc里做一次特殊的“内部释放”,把旧top塞进unsorted bin,然后重新划一片堆区当新top。攻击者如果能提前改坏top chunk的size字段,就等于凭空制造了一次free。

1.2 house of orange的完整链路

这个技巧的利用链可以概括成四个阶段:

  1. 劫持top chunk size:通过堆溢出把top的size改成一个较小且“看似合法”的值,比如0xc1。
  2. 触发sysmalloc的隐式释放:申请一个超大块,让malloc走sysmalloc路径,旧top被释放进unsorted bin,得到libc地址;同时伪造fencepost上的新top size,为下一轮做准备。
  3. unsorted bin attack:再次触发释放与分配,在unsorted chunk出链时篡改fd/bk,把_IO_list_all写成main_arena+88。
  4. FSOP getShell:程序退出或malloc报错时,glibc遍历_IO_list_all链表,调用vtable里的函数指针,最终执行system("/bin/sh")。

整个链路没有依赖题目自带的free,也没有用到任意地址写原语,唯一的输入就是几次add的参数和溢出payload。这就是house of orange最漂亮的地方:它让glibc自己的内存管理逻辑变成了攻击者的手。

1.3 为什么需要组合拳而不只是unsorted bin attack

很多新手会问:unsorted bin attack本身只能写一个固定值到任意地址,又不能直接控制RIP,为什么要和IO_FILE扯上关系?

因为unsorted bin attack的写入内容固定是main_arena+88附近的地址值,我们只能控制写到哪里,不能控制写入什么。单纯写__malloc_hook为main_arena+88没有任何意义;写__free_hook也一样,程序又没有free调用。所以需要一个“把固定写操作升级为代码执行”的跳板,_IO_list_all恰好是这样一个跳板:它被写坏后,会导致glibc把main_arena里残留的数据当作IO_FILE结构体指针使用,而这些数据我们可以在前面的堆操作里“提前布置”成可控的假文件结构。这一环扣一环,缺一不可。

2. 让sysmalloc“代劳”free:top chunk劫持的底层逻辑

2.1 溢出点与top size的物理位置

先看堆布局。64位下,一个chunk的用户区从chunk地址+0x10开始,用户数据区长度与size的关系是:chunk_size = 用户申请大小 + 0x10,且按0x10对齐。top chunk则比较特殊,它是arena最末端的大块空闲区,size字段就写在它自己的chunk头里。

大多数菜单题里,add函数如果允许写满甚至写超过申请大小的数据,就能从最后一个已分配chunk一路写到top的size字段。也就是说,攻击者只需要在最后一次add的content末尾追加8字节,这8字节就会覆盖到top的size上。这是整个house of orange的入口漏洞。

实际操作时,第一步通常先申请一个普通大小的chunk,比如add(0x50),然后content写0x50字节占满,再加8字节溢出写p64(0xc1)。为什么这样写,下一节详细说。

2.2 sysmalloc分支:什么时候会释放旧top

当用户请求大小为nb时,malloc首先检查top剩余大小old_size是否够分。判断条件是:

if (old_size >= nb + MINSIZE) { // 直接切top,正常分配 }

如果不够,进入sysmalloc。sysmalloc里有一整段针对old_top的处理逻辑。核心伪代码大致如下:

if (old_size >= MINSIZE) { // 把旧top整块释放进unsorted bin _int_free(av, old_top, 1); // 用old_top的结束位置重新搭一个fencepost当新top av->top = (mchunkptr)old_end; }

也就是说,只要top剩余大小大于最小chunk尺寸(64位下MINSIZE是0x20),sysmalloc就会先把旧top“丢进回收站”,然后才去申请新的内存区。这就是我们要利用的“借刀释放”。

释放之后,旧top变成unsorted bin里的一个空闲chunk,它的fd指针会被系统填成main_arena+88(unsorted bin的头地址)。如果我们有show函数能打印这个旧top的用户区内容,就能顺路把libc基地址泄露出来。

2.3 修改top size的硬性条件

想把top size改成一个能成功触发sysmalloc释放流程的值,必须同时满足几个条件,缺一个就可能崩:

条件要求原因
最小值size >= 0x20小于0x20连MINSIZE都不满足,直接走其他分支
对齐按0x10对齐malloc内部对chunk size有对齐要求,不对齐会触发assert
PREV_INUSE位一般置1让_int_free认为前一个chunk已分配,避免前向合并
小于后续请求size < nb + MINSIZE只有top不够分,才会进sysmalloc释放流程

经典写法p64(0xc1)就是标准解:低三位的标志位里最低位是PREV_INUSE,置1表示前块使用中;去掉标志后的实际size是0xc0(192字节),按0x10对齐,大于0x20,完全满足要求。有些wp写0xd1、0xe1也都可以,只是后面布局时要跟着调。

2.4 fencepost与新top:为什么还要伪造第二轮

很多第一次接触这个技巧的人会困惑:旧top被释放后,新top是谁?

答案是sysmalloc在旧top原来的位置算出一个fencepost,用旧top的size计算出“旧top结束位置”old_end = old_top + old_size,然后把old_end处当作新top的chunk头。这个新top的size字段,实际上就是原来old_end那8字节的内容——攻击者是完全可控的。

这就是经典exp第二步的由来:第一次add(0x1000)触发释放后,返回的大块chunk的content可以覆盖到fencepost区域,于是顺手在新top的size位置写上0x61,让新top只剩0x60大小。这样下一次add(0x60)时,又会走一遍sysmalloc释放流程,把这个只有0x60的小top也释放进unsorted bin。这个小块size刚好是后续unsorted bin attack需要的尺寸。

简单说,第二次释放的目的是在unsorted bin里制造一个大小可控的小chunk,而不是单纯为了泄露libc。

3. Unsorted bin attack为什么盯着_IO_list_all不放

3.1 攻击的本质:一次固定值的写操作

unsorted bin attack的触发条件很经典:当malloc从unsorted bin里取一个chunk出来用,而这个chunk的bk字段被攻击者改写成了target - 0x10时,unlink流程会执行:

victim->bk->fd = unsorted_chunks(av);

翻译成人话就是:往target这个地址写入main_arena+88的值。写入值不受我们控制,永远是unsorted bin头地址;控制权在“写到哪里”。

这个原语看起来非常弱,但恰好能用来破坏_IO_list_all。因为_IO_list_all是glibc维护IO_FILE链表头指针的全局变量,把它改成一个指向main_arena+88的地址,等程序走到FSOP流程时,glibc会把main_arena+88处的数据当成一个_IO_FILE*指针来解引用。

3.2 为什么不优先考虑__malloc_hook

很多事情能做和值得做是两回事。理论上unsorted bin attack也可以写__malloc_hook,触发点在下次malloc,但实际利用价值很低:

  • 写入值是main_arena+88,这个值不是one_gadget地址。
  • 想让main_arena+88附近恰好是一个one_gadget或system地址,需要极其苛刻的堆布局。
  • 后续malloc如果正好从那个地址分配chunk并写数据,倒是能二次改写成one_gadget,但house of orange的场景里,请求的size往往由题目功能固定,不好凑。

_IO_list_all就不一样。它被写坏后,glibc内部会遍历链表,把链表上的“假FILE结构”当作真对象来调用,只要布局凑好,稳定可控,不需要任何二次写原语。

3.3 为什么需要0x60大小的小chunk

house of orange经典exp里,目标尺寸是0x60。原因藏在IO_FILE遍历的细节里。

_IO_list_all被写成main_arena+88后,FSOP流程会把main_arena+88当作fp,也就是一个_IO_FILE*。这个地址附近的字节会被解释成IO_FILE的各个字段:_IO_write_base、_IO_write_ptr、_chain、vtable全是main_arena里的数值。

攻击者没法直接改main_arena,但可以通过small bin的写入间接控制它:当我们把一个0x60大小的chunk释放进small bin时,glibc会把small bin链表的fd/bk写到对应的bin数组位置,而这个位置经过偏移计算,正好落在main_arena+88附近。于是main_arena里的某些“字段”就被替换成了我们可控堆块的地址。这相当于用堆管理器自己的链表维护动作,替我们把假FILE结构体的关键指针安插到了正确位置。

这也是house of orange最精巧的部分:不只是伪造IO_FILE,而是把伪造工作拆成了几个堆管理器自动完成的步骤。

4. FSOP落地:把IO_FILE伪造到能直接getshell

4.1 _IO_list_all遍历与触发时机

FSOP的全称是File Stream Oriented Programming,核心入口是_IO_list_all这个全局链表。glibc在程序退出(exit)、异常终止(abort)或部分malloc报错路径里,会调用_IO_flush_all_lockp,遍历_IO_list_all上每个FILE结构体,对带缓冲的文件执行flush。

flush过程中,如果发现某个FILE的_IO_write_ptr > _IO_write_base,就会调用它的__overflow方法,也就是vtable偏移0x18处的函数指针。一旦我们能控制这个vtable,就相当于控制了一次间接调用。

在house of orange的经典链路里,_IO_list_all已经被写坏,第二次add或程序退出时就会触发这条路径,此时fp指向的是main_arena+88附近的伪造结构。

4.2 vtable替换与_IO_str_jumps

64位下,_IO_FILE结构体的末尾有一个vtable指针,指向一组IO函数指针表。如果vtable被替换成_IO_str_jumps的地址,那么__overflow对应的就是_IO_str_overflow函数。

_IO_str_overflow是glibc用于“字符串流”扩容的函数。它的大致逻辑是:如果当前缓冲区不够,就调用fp->_s._allocate_buffer(new_size)来重新分配内存。问题在于,_s._allocate_buffer这个字段在文件结构体里是一个函数指针——如果我们把假FILE里该字段改成system的地址,再把另一个字段改成"/bin/sh"的地址,那么_IO_str_overflow就会执行system("/bin/sh")。

vtable校验这块,传统house of orange基于glibc 2.23,当时还没有IO_validate_vtable;到了2.24以后,glibc加了vtable地址段检查,要求fp->vtable必须落在__libc_IO_vtables段内,_IO_str_jumps本身就在这个段里,所以2.24依然可以打。2.26以后tcache出现并且vtable检查更严,链路就需要调整。

4.3 假FILE结构的关键字段布局

一个可用的伪造IO_FILE结构体,关键位置要填这些内容:

偏移(64位)字段写入值
0x00 ~ 0x17chunk头/占位按堆块布局填
0x18_IO_write_base指向"/bin/sh"字符串的地址
0x20_IO_write_ptr大于_IO_write_base的值,触发flush
0x28+其他数据维持合法布局,避免校验崩溃
0xd8附近_chain指向下一个假FILE或NULL
vtable偏移vtable指针_IO_str_jumps的地址

house of orange的具体利用里,这些“字段”常常不是直接写的,而是main_arena+88处本来就有的一些值,经过small bin插入后恰好变得可用。所以实战中不建议死记偏移,而是用gdb把触发FSOP时fp实际指向的内存dump下来,再对着_IO_FILE结构体定义逐个核对。

4.4 libc版本差异一览

libc版本vtable校验tcachehouse of orange可用性
2.23及以前无无经典打法,稳定
2.24IO_validate_vtable无_IO_str_jumps仍在vtables段,总体可用
2.26+检查加强有链路需调整,经典打法失效

这也是为什么所有house of orange的wp都会强调“先查libc版本”。同一个exp在2.23的libc上弹shell,换到2.24的libc可能就因为vtable校验直接崩了。

5. 一段通用exp骨架:从泄露地址到弹出shell

5.1 题目功能抽象与泄露思路

为了方便说清payload,我把题目抽象成最简单的三个函数:add(size, content)申请并写入,show(idx)打印某个已分配chunk的内容,均无delete。漏洞点是add的content长度可以超过size,导致溢出。

泄露libc的手段很自然:第一次add(0x1000)触发sysmalloc后,旧top已经进入unsorted bin,fd被写成main_arena+88。旧top原来是哪个chunk?它虽然被释放了,但它的前一个chunk(我们第一次add的0x60小chunk)仍然是已分配状态,show它时读取的是它自己的内容,读不到旧top的fd。所以需要想个办法让show能够打印到unsorted bin chunk的fd。常见做法是,第一次add的chunk不要和top相邻,或者在第二次add之后show一个能通过偏移落到旧top区域的chunk索引。这属于题目细节,但有个相对通用的技巧:第一次溢出后,第二次add返回的大chunk内容其实就出现在旧top之后,如果程序对已分配chunk的内容不做长度限制,show这个新chunk即可读到unsorted bin的fd。

示例exp骨架如下:

from pwn import * context.arch = 'amd64' def add(size, content): ru(b'> ') sendlineafter(b'add', b'1') # 按题目菜单调整 sendlineafter(b'size', str(size)) sa(b'content', content) def show(idx): sendlineafter(b'> ', b'2') sendlineafter(b'index', str(idx)) return ru(b'\n') # step1: 改坏top chunk size add(0x50, b'A'*0x50 + p64(0xc1)) # step2: 触发sysmalloc释放,旧top进unsorted bin,伪造fencepost新top add(0x1000, fit({ 0x10: p64(0) + p64(0x21), # 预置一个小chunk头,给后续布局用 0x30: p64(0xf1), # 控制fencepost的size 0x50: p64(0x61), # 再伪造下一轮的top size })) # show某个能打到unsorted bin的索引,拿libc基址 leak = show(...) libc.address = u64(leak) - libc.sym['main_arena'] - 88 # step3: 布局unsorted bin attack + 假IO_FILE payload = fit({ # 在0x60的unsorted chunk上布置假IO,关键偏移按libc计算 0x...: libc.sym['_IO_list_all'] - 0x10, # 作为bk 0x...: p64(libc.sym['_IO_str_jumps']), 0x...: p64(libc.sym['system']), 0x...: binsh_addr, }) add(0x60, payload) # 触发FSOP ru(b'> ') sendline(b'3') # exit p.interactive()

5.2 三个add的payload逻辑拆解

第一个add(0x50, b'A'*0x50 + p64(0xc1))是入口。0x50是用户区大小,实际chunk 0x60,从用户区末再往后8字节,正好是top的size字段。

第二个add(0x1000)是整条攻击链的发动机。它同时完成了三件事:触发旧top释放进unsorted bin,泄露libc;在返回的大chunk内容里覆盖fencepost,伪造新top size为0x61;提前埋下一部分后续需要的chunk头。fit里的偏移需要根据题目给的堆布局用gdb现场算,不同题目差别很大。

第三个add(0x60)是扣扳机。0x60的请求会让malloc把unsorted bin里那个0x60的小chunk取出来,出链时执行unsorted bin attack,把_IO_list_all写成main_arena+88。同时,这个0x60的chunk自身的数据区又被预置成假IO_FILE的各个字段,等FSOP遍历时就是现成的弹药。

5.3 调试时的观察点

跑这个exp时,建议在第三个add前后各下一次断点,观察三处关键内存:

gdb-peda$ x/gx &_IO_list_all gdb-peda$ x/20gx main_arena+88 gdb-peda$ x/30gx 0x... # 伪造的IO_FILE所在堆块

重点确认:_IO_list_all是否变成了main_arena+88附近的地址;main_arena+88处是否出现了一个可控堆块的指针;假FILE结构的vtable是否指向_IO_str_jumps。任何一步不对,都不要急着往下打,先对比偏移。

6. 翻车实录:那些让exp失效的隐藏细节

6.1 tcache的干扰与libc版本陷阱

如果题目的libc是2.26及以上,house of orange的经典链路会多出一个变数:tcache。0x60大小的chunk释放后会先进tcache而不是unsorted bin,这直接破坏了“小top释放进unsorted bin,再被取出触发attack”的前提。所以拿到题先别急着写exp,checksec看不了libc版本,用pwninit或ldd确认一下。要是libc是2.27又想在无free题目里用unsorted bin attack,就得考虑先填满tcache或者改用large bin attack的思路,那又是另一套打法了。

6.2 movaps栈对齐:system就差这一下

这是所有FSOP利用里最隐蔽的坑。64位glibc对很多函数调用加了SSE对齐要求,当_IO_str_overflow最终调到system时,如果rsp不满足16字节对齐,system内部执行movaps指令会直接SIGSEGV。现象就是exp跑起来没有任何报错提示,程序直接段错误,gdb里看到崩溃在libc的memcpy或system内部。

解决办法是在假FILE里做函数指针链时加一个ret gadget。比如把_allocate_buffer位置不直接填system,而是填libc.address + ret_gadget,让栈指针先弹齐,接着才真正进入system。每条链的栈偏移不一样,只能靠gdb调。

6.3 偏移计算与gdb调试心得

布局偏移是整个house of orange里最容易错的地方。我自己踩过的坑包括:_IO_list_all - 0x10少算、main_arena+88误写成main_arena、0x60的chunk头占位导致数据区起点差8字节。经验是:永远不要凭记忆写偏移,用gdb算。在第二个add之后,用x/gx查看返回chunk的地址和fencepost地址,把两者的差记下来,再回到exp里把fit的偏移替换成实测值。fp的vtable指向哪、_chain链到哪,也全部以内存dump为准。

6.4 没有show时的替代方案

严格来说,house of orange最标准的形态依赖show来泄露libc。如果题目连show都没有,也不是完全没法打,但难度陡增。常见方向是partial overwrite:unsorted bin的fd低字节被写成本地已知的偏移,盲打远程时爆破1~2字节;或者通过报错信息把地址带出来。这些方案都有运气成分,不推荐作为第一选择。拿到无free无show的题,优先考虑能不能改stdout的IO结构来泄露,或者直接上one_gadget做多阶段攻击,反而更稳。

最后讲一个我自己的调试习惯:每到一个阶段,就先把当前堆布局完整打印出来对比一遍预期。house of orange的每一环都高度依赖堆管理器“自动帮你做某事”,一旦中间哪一步的size、fencepost、small bin指针没对齐,后面全盘崩。所以宁可多花十分钟在gdb里确认fencepost的size,也别直接拿完整的exploit去远程赌。这个习惯救了我好几次,也推荐给所有第一次接触这套技巧的朋友。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询