“House of Orange” 是在 CTF(网络安全夺旗赛)和真实漏洞利用中非常经典且高级的一种堆溢出利用技术。它的名字来源于 2016 年 HITCON CTF 比赛中的一道同名题目。
为什么会有 House of Orange?(它的核心亮点)
在常规的黑客攻击中,如果你想利用内存里的“堆(Heap)”干坏事,通常需要程序里有一个free()函数(把内存还给系统的函数)。但有些程序写得很“死”,它只允许你申请内存(malloc),根本没有提供释放内存(free)的功能。
House of Orange 的出现就是为了打破这个限制:它能在没有free函数的情况下,强行制造出一个被释放的内存块,从而完成后续的攻击。
基础知识铺垫(你必须知道的 3 个概念)
为了看懂这个魔法,你需要知道 Linux 系统分配内存时的三个设定:
- Top Chunk(顶层块):
想象系统最初给了你一块巨大无比的蛋糕(堆内存)。每次程序调用malloc申请内存,系统就从这块大蛋糕上切一块给你。切剩下来的那一块巨大的、还没有被分配出去的内存,就叫Top Chunk。 - sysmalloc(系统扩容):
如果你某次狮子大开口,要申请一块巨大的内存,但当前的 Top Chunk 已经不够切了怎么办?系统会触发一个叫sysmalloc的机制,重新向操作系统申请一块新的大蛋糕。 - Unsorted Bin(垃圾桶):
在触发sysmalloc申请新蛋糕时,系统是一个很节约的人,它不会把原来那块没用完的旧 Top Chunk 直接扔掉,而是把它放进一个叫Unsorted Bin的垃圾桶(回收站)里,留着以后给那些申请小块内存的需求用。
重点来了:只要一块内存进入了 Unsorted Bin,它就等同于被free(释放)了!这就是 House of Orange 的突破口。
House of Orange 的攻击四部曲
现在我们来看看,黑客是如何一步步无中生有,拿下服务器权限的。
第一步:篡改 Top Chunk 的大小(骗过系统)
程序里存在一个堆溢出漏洞。黑客利用这个漏洞,在往某块已经申请的内存里写数据时,故意写得太长,越界覆盖到了紧挨着它的 Top Chunk 的头部数据。
Top Chunk 的头部记录着它自己的大小(Size)。黑客通过溢出,把原本比如有 131072 字节(0x20000)大小的 Top Chunk,强行修改成了一个很小的值,比如 4096 字节(0x1000)。
新手注意:这个假大小不能随便瞎写,必须满足系统的检查条件,主要是:
- 必须大于最小限制(通常是 0x20)。
- 必须和系统的内存页(Page)对齐,通常大小要是 4096 的倍数(末尾是 000)。
- 它的“前一个块正在使用”标志位(PREV_INUSE)必须是 1。
第二步:触发扩容,获得“释放”的内存
现在系统以为当前的 Top Chunk 只剩 4096 字节了。这时,黑客操控程序,强行申请一块大于 4096 字节的内存(比如申请 4097 字节)。
系统一看:哎呀,Top Chunk 只有 4096 了,不够切啊!于是系统乖乖地触发了sysmalloc去要新蛋糕,顺手就把我们那个被篡改过大小的旧 Top Chunk扔进了 Unsorted Bin 里。
至此,黑客在没有调用任何free函数的情况下,成功获得了一块被释放的内存!
第三步:Unsorted Bin Attack(改写关键指针)
旧 Top Chunk 进垃圾桶后,黑客通过漏洞再次修改这块垃圾桶里的内存,把它内部的指针(bk指针)指向系统内核中一个极其敏感的地方——_IO_list_all。
(_IO_list_all是 Linux 系统用来记录所有输入输出流,比如标准输入、标准错误输出的链表头。)
当系统下次再去整理垃圾桶(Unsorted Bin)时,会触发一个漏洞机制,不小心把垃圾桶在系统里的地址,强行写到了_IO_list_all里面。这就好比黑客把系统内部的高铁调度中心,强行指向了黑客自己搭建的一条野鸡铁轨上。
第四步:FSOP 劫持程序流(最终绝杀)
这是最复杂但也是最致命的一步,被称为FSOP (File Stream Orientation Programming)。
黑客在堆内存里提前伪造好了一个假的“文件流结构体(_IO_FILE)”和一套“假的执行函数表(vtable)”。因为第三步已经把_IO_list_all指向了堆里,系统现在会把黑客伪造的数据当成合法的文件操作流。
接着,黑客故意在系统中制造一个内存崩溃错误(比如再次破坏堆的结构)。当系统发现堆坏了,想要通过标准错误流(stderr)打印一句“Malloc: memory corrupted!”这样的报错信息时,它会去读取_IO_list_all。
这一读就中计了!系统顺着黑客伪造的结构体,找到了黑客伪造的函数表,本来是想执行“打印报错”的函数,结果却执行了黑客提前写好的system("/bin/sh")。
瞬间,黑客拿到了服务器的 Shell 控制台,游戏结束。
总结一下流程
对于 0 基础的你,只需要在脑海里建立这样一条逻辑链:
- 没有 free?-> 用堆溢出修改 Top Chunk 大小,骗它说蛋糕不够了。
- 触发 sysmalloc!-> 申请大内存,强迫系统把旧 Top Chunk 扔进回收站(等同于 free)。
- 改写 _IO_list_all!-> 利用回收站的机制,把系统的关键 IO 链表指向黑客控制的区域。
- 报错触发后门!-> 故意制造错误让系统打印信息,系统在打印时使用被篡改的 IO 链表,最终执行了黑客的恶意命令。
补充一点历史背景:这种技术在 Glibc 2.23 和 2.24 版本中非常流行且无解。但在后来的 Glibc 2.26+ 甚至更高的版本中,系统增加了各种安全检查(比如引入了 tcache 以及对 vtable 进行了严格校验),原汁原味的 House of Orange 已经很难直接打通了,但这依然是学习高级堆漏洞利用必不可少的里程碑式技术。
例子
为了让你直观地看到这个“魔法”是如何在代码层面发生的,我们通过一个极简的 C 语言示例,带你走完 House of Orange 最核心的前两步:如何利用堆溢出,无中生有地制造出被释放的内存(Unsorted Bin)。
在真实的 Linuxptmalloc内存管理器中,要骗过系统并不容易。我们直接来看代码和内存布局的真实变化。
1. 存在漏洞的 C 代码(靶场环境)
假设我们有下面这段存在堆溢出漏洞的 C 代码:
#include <stdio.h> #include <stdlib.h> int main() { // 1. 程序分配了一小块内存 // malloc(0x10) 实际在内存中会分配 0x20 字节(包含 0x10 的头部控制信息) char *p1 = malloc(0x10); // ========================================== // 此时的内存布局: // [ p1 chunk 头部 (0x10 字节) ] // [ p1 数据区 (0x10 字节) ] <--- p1 指针指向这里 // [ Top Chunk 头部 (Size: 0x20fe1) ] <--- 紧挨着 p1 // [ Top Chunk 巨大的剩余空间... ] // ========================================== // 2. 黑客利用堆溢出漏洞,越界修改紧挨着的 Top Chunk 大小 // 注意:这里的 0x0fe1 不是随便写的,必须满足系统的严格检查 unsigned long *top_chunk_header = (unsigned long *)(p1 + 0x10); *top_chunk_header = 0x0fe1; // 3. 强行申请一块大内存,触发系统扩容 (sysmalloc) char *p2 = malloc(0x1000); return 0; }2. 黑客视角的详细拆解
动作一:精心计算并修改 Top Chunk 大小
在代码的第 2 步,我们把 Top Chunk 的 Size 从默认的0x20fe1(大约 135 KB)改成了0x0fe1(4065 字节)。
在ptmalloc的底层机制中,这个伪造的大小必须满足三个严苛条件,否则程序会直接崩溃(Segmentation Fault):
- 必须大于最小限制:大于
MINSIZE(通常是 0x20)。0x0fe1满足。 - 前一个块必须是使用中:最低位(PREV_INUSE)必须是 1。
0x0fe1的二进制最低位是 1,满足。 - 必须与内存页(Page)对齐:这是最难的一点。公式是
(Top_Chunk_首地址 + Top_Chunk_Size) & 0xfff == 0。
- 假设堆的起始地址是
0x555555602000。 - p1 占用了
0x20字节,所以 Top Chunk 的首地址是0x555555602020。 0x555555602020 + 0x0fe1 = 0x555555603001。(注意:实际计算对齐时会忽略标志位,即加0x0fe0,结果是0x555555603000)。0x555555603000的末尾是三个 0(即 4096 的倍数),完美对齐内存页!
动作二:触发sysmalloc榨干 Top Chunk
在代码的第 3 步,程序调用了malloc(0x1000)。
系统内核接到请求后开始思考:
- 客户要
0x1000字节。 - 我去看看 Top Chunk 还有多少... 咦?只有
0x0fe0字节了(扣除标志位)。 0x0fe0 < 0x1000,蛋糕不够切了!- 系统被迫调用
sysmalloc向操作系统申请新的一大块内存。 - 关键漏洞爆发:秉承着不浪费的原则,系统把旧的这个
0x0fe1大小的 Top Chunk,直接丢进了 Unsorted Bin(回收站)里。
至此,虽然整个 C 程序里连一个free()函数都没有写,但黑客已经在内存里拿到了一块合法的、被释放的 Unsorted Bin Chunk。
3. 后续的绝杀(Python Exploit 伪代码)
拿到这块进入垃圾桶的内存后,黑客会再次利用漏洞向里面写数据。在真实的 CTF 比赛(如使用 Pwntools)中,构造出来的攻击载荷(Payload)大致长这样:
# 假设我们可以再次向旧的 Top Chunk(现在的 Unsorted Bin)写入数据 # 1. 伪造 Chunk 头部信息 payload = p64(0) + p64(0x61) # 2. Unsorted Bin Attack 核心:改写 bk 指针 # 把垃圾桶的后向指针指向系统 IO 链表头 _IO_list_all 的地址减去 0x10 payload += p64(0) + p64(_IO_list_all - 0x10) # 3. 伪造 _IO_FILE 结构体 (FSOP) payload += p64(0) + p64(1) # 绕过底层的 assert 检查 payload += p64(0) * 7 # 填充无关数据 payload += p64(system_addr) # 偷偷塞入 system 函数的地址 # 4. 伪造 vtable (虚函数表) # 把系统本该调用的 _IO_OVERFLOW (比如打印报错信息) 强行指向我们的 system 函数 payload += p64(fake_vtable_addr) # 发送攻击载荷覆盖内存 send(payload) # 再次发起一个会导致堆崩溃的 malloc,触发系统向终端打印报错,瞬间拿到 shell malloc(0x10)一旦系统因为堆结构被破坏而尝试调用_IO_OVERFLOW打印错误信息,就会顺着被我们污染的_IO_list_all,读到伪造的虚函数表,最终将原本用于报错的字符串"/bin/sh"作为参数,执行了system("/bin/sh")。