1. 被一道“没有free”的菜单题按在地上摩擦之后
我第一次被House of orange这套东西毒打,是在某次CTF训练里碰到一道很正常的菜单题:add、edit、show,唯独没有delete。当时我的第一反应是“没有free就没有UAF,没有UAF这题怎么打”,然后就开始瞎试各种溢出手法,试到比赛结束也没打通。
赛后看writeup,看到“伪造top chunk进入unsorted bin”和“改写_IO_list_all”这两个字眼,整个人是懵的。因为在我当时的认知里,unsorted bin是free出来的chunk才会进去的地方,怎么不free也能进?_IO_list_all这种平时根本不会碰的全局符号,和堆利用又有什么关系?也就是从那时候起,我才意识到自己对glibc文件结构体的理解几乎为零。后来花了一整周把_IO_FILE结构体和House of orange彻底啃了一遍,回过头再看这类题目,才觉得豁然开朗。
这篇文章就是把当时那套东西整理出来。适合已经有一定堆利用基础(至少知道fastbin、unsorted bin、chunk结构),但一遇到FSOP就发懵的人。我会从_IO_FILE结构体本身讲起,拆清楚它各个字段的语义,再一步步还原House of orange的完整利用链。这样你看完不只是会背攻击步骤,而是能理解每一步“为什么非要这么做”。
House of orange这套组合拳本质上要解决两个核心问题:第一,在没有free的情况下如何把一个chunk送进unsorted bin;第二,如何利用glibc的_IO_FILE机制,把数据上的篡改变成一次完整的控制流劫持。这两个问题,恰好对应了_IO_FILE结构体的两大攻击面:字段语义和vtable分发。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. _IO_FILE结构体逐字段拆解:每个字节都可能成为攻击面
2.1 64位glibc 2.23下的完整定义
在glibc里,我们平时用的FILE *其实指向的是_IO_FILE_plus结构体。_IO_FILE是它的主体,后面还跟着一个vtable指针。先看主体长什么样,我直接贴glibc 2.23里的核心定义(略去了条件编译部分),并用表格标出64位下的偏移:
c复制struct _IO_FILE {
int _flags; // 0x00
char* _IO_read_ptr; // 0x08
char* _IO_read_end; // 0x10
char* _IO_read_base; // 0x18
char* _IO_write_base; // 0x20
char* _IO_write_ptr; // 0x28
char* _IO_write_end; // 0x30
char* _IO_buf_base; // 0x38
char* _IO_buf_end; // 0x40
char* _IO_save_base; // 0x48
char* _IO_backup_base; // 0x50
char* _IO_save_end; // 0x58
struct _IO_marker *markers; // 0x60
struct _IO_FILE *_chain; // 0x68
int _fileno; // 0x70
int _flags2; // 0x74
_IO_off_t _old_offset; // 0x78
unsigned short _cur_column; // 0x80
signed char _vtable_offset; // 0x82
_IO_lock_t *_lock; // 0x88
_IO_off64_t _offset; // 0x90
struct _IO_codecvt *_codecvt; // 0x98
struct _IO_wide_data *_wide_data; // 0xa0
struct _IO_FILE *_freeres_list; // 0xa8
void *_freeres_buf; // 0xb0
size_t __pad5; // 0xb8
int _mode; // 0xc0
char _unused2[15 * sizeof (int) - 4 * sizeof (void *) - sizeof (size_t)]; // 0xc4
};
结构体整体大小是0xd8。真正的_IO_FILE_plus在_IO_FILE后面紧跟着一个指针:
c复制struct _IO_FILE_plus {
FILE file; // 0x00 ~ 0xd8
const struct _IO_jump_t *vtable; // 0xd8
};
vtable指针的地址在0xd8,这个偏移在后面伪造结构时几乎每次都用得上。
2.2 read侧指针组和write侧指针组
这么多字段里,最核心的是两组指针,它们分别管理读缓冲区和写缓冲区。
read侧有_IO_read_ptr、_IO_read_end、_IO_read_base,大体语义是:读操作从_IO_read_ptr指向的位置取数据,取到_IO_read_end就认为缓冲区耗尽。实际使用中,这三个指针的值并不要求满足任何强约束,正常的fread会按照它们计算剩余字节数。而在FSOP的很多变体里,攻击者并不依赖这些字段的真实语义准确,只要让某个特定判断成立就行。
write侧是_IO_write_base、_IO_write_ptr、_IO_write_end,其中_IO_write_ptr > _IO_write_base表示缓冲区里有待写入的数据。这个条件在House of orange里非常重要,因为_IO_flush_all_lockp在决定要不要调用_IO_OVERFLOW时,第一条判断就是它。
2.3 _chain字段和全局文件链表
_IO_FILE结构体里还有个_chain字段,偏移0x68。它把进程中所有的FILE结构体串成一个单向链表。链表的头就是全局变量_IO_list_all。
为什么需要关注它?因为程序正常退出时,glibc会遍历_IO_list_all这条链,对链上的每一个FILE做清理操作。如果攻击者能把_IO_list_all篡改成自己伪造的结构体地址,那么遍历就会按我们设计的链表走下去。更关键的是,_IO_list_all是libc里一个可写的全局符号,只要有了任意写原语就能动它。House of orange的第二步,本质上就是把一个“写什么不可控、写到哪可以设计”的写原语,精准地作用在_IO_list_all上。
2.4 vtable和_IO_jump_t
_IO_FILE_plus末尾的vtable指向_IO_jump_t,这是一个函数指针表。glibc里所有对FILE的高层操作,最终都是通过vtable分发到具体实现。比如fwrite最终会调到__xsputn,fflush最终会调到__overflow,fclose最终会调到__finish。
c复制struct _IO_jump_t {
size_t __dummy; // 0x00
size_t __dummy2; // 0x08
_IO_finish_t __finish; // 0x10
_IO_overflow_t __overflow; // 0x18
_IO_underflow_t __underflow;// 0x20
_IO_underflow_t __uflow; // 0x28
_IO_pbackfail_t __pbackfail;// 0x30
_IO_xsputn_t __xsputn; // 0x38
_IO_xsgetn_t __xsgetn; // 0x40
_IO_seekoff_t __seekoff; // 0x48
_IO_seekpos_t __seekpos; // 0x50
_IO_setbuf_t __setbuf; // 0x58
_IO_sync_t __sync; // 0x60
_IO_doallocate_t __doallocate; // 0x68
_IO_read_t __read; // 0x70
_IO_write_t __write; // 0x78
_IO_seek_t __seek; // 0x80
_IO_close_t __close; // 0x88
_IO_stat_t __stat; // 0x90
_IO_showmanyc_t __showmanyc;// 0x98
_IO_imbue_t __imbue; // 0xa0
};
__overflow位于vtable+0x18,这一项在经典FSOP中是最常被盯上的目标。原因在于_IO_flush_all_lockp里会直接调用它,而且参数是(fp, EOF),如果我们能控制vtable里的__overflow为system,那么fp传入的第一个参数恰好能当作字符串指针来用。
3. vtable与FSOP:程序退出前的那几毫秒
3.1 _IO_flush_all_lockp的调用链
程序正常退出时会走exit -> __run_exit_handlers -> _IO_cleanup,_IO_cleanup内部会调用_IO_flush_all_lockp。这个函数的职责是遍历_IO_list_all链表上的每一个FILE,把用户态缓冲区中尚未写出的数据flush到内核。它的核心逻辑如下(伪代码):
c复制int _IO_flush_all_lockp(void) {
fp = (_IO_FILE *) _IO_list_all;
while (fp != NULL) {
if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)
|| (_IO_vtable_offset(fp) == 0 && fp->_mode > 0 &&
fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base))
&& _IO_OVERFLOW(fp, EOF) == EOF)
result = EOF;
fp = fp->_chain;
}
}
简单说:先检查这个FILE的写缓冲区是否有滞留内容,如果有,就调用_IO_OVERFLOW去冲刷。然后顺着_chain看下一个FILE。整个过程里,_IO_list_all是遍历的起点,_chain是遍历的路径。
3.2 要满足什么样的条件才会触发_IO_OVERFLOW
如果你想劫持_IO_OVERFLOW,伪造的FILE至少要满足其中一个判断:
- 模式一:
fp->_mode <= 0且fp->_IO_write_ptr > fp->_IO_write_base - 模式二:
fp->_mode > 0且_IO_vtable_offset(fp) == 0,并且fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base
模式二还牵扯_wide_data指针,构造起来复杂一些,所以经典House of orange通常会走模式一。你只需要把伪造FILE的_mode设成0或负数,同时让_IO_write_ptr略大于_IO_write_base就行。这两个指针在伪造结构里可以随便填,只要保证ptr大于base且两者都落在可读地址范围内即可。
更妙的是,这个检查对第一个FILE不通过也没关系,因为前面说了,遍历是顺着_chain走的。如果第一个FILE不满足条件,就直接跳到下一个。House of orange利用的正是这一点:让第一个被遍历的伪FILE“带病通过”检查,或者干脆不走overflow分支,而是把控制流引到第二个真正伪造好的FILE上。
3.3 伪造vtable的本质
经典FSOP里,伪造vtable有两种思路:一是把vtable指针整个指向堆上我们伪造的函数指针表,表中对应__overflow的位置写上system地址;二是让vtable指向glibc里某个已经存在的合法表,通过精心构造使得表内某个槽位原本就是可利用的函数,比如_IO_str_jumps这类“自带好东西”的表。
在glibc 2.23及更早的版本里,第一种思路是直接可行的——glibc没有校验vtable指针必须落在哪里,只信任它然后直接索引调用。这就是为什么很多CTF题在2.23环境下只需要一个任意写和一个可控堆块,就能靠FSOP打通。第二种思路主要是为了对抗2.24之后加入的vtable校验,这个我放到后面单独说。
4. 第一步:伪造top chunk,硬生生挤出unsorted bin
4.1 为什么非要用top chunk
正常情况下,chunk进unsorted bin只有两种途径:被free,或者作为top chunk的remainder被切下来。题目不提供free,第一种途径就断了;但第二种途径有机会利用。你想,每次malloc请求超过top chunk剩余空间时,glibc会调用sysmalloc向系统要内存。在sysmalloc的某个分支里,如果旧top chunk满足一定条件,它会被当作空闲chunk整理进unsorted bin。问题来了:旧top chunk的size是我们能改的吗?答案是可以——只要存在堆溢出,而且溢出点能覆盖到top chunk的size字段。
这就是House of orange第一阶段的出发点:不靠free,而是靠“伪造top chunk的size,骗sysmalloc把旧top chunk丢进unsorted bin”。
4.2 sysmalloc对旧top的处理逻辑
看一下glibc 2.23里sysmalloc处理旧top chunk的代码,这段逻辑决定了我们能怎么玩:
c复制if (old_top == initial_top(av) && old_size == 0) {
// 初始状态,直接分配新堆段
} else if ((unsigned long)(old_size) >= MINSIZE
&& prev_inuse(old_top)
&& ((unsigned long)old_end & pagemask) == 0) {
// 旧top chunk满足条件:大小够大、prev_inuse位置1、结尾页对齐
unsorted_chunks(av)->bk = old_top;
old_top->fd = unsorted_chunks(av);
old_top->bk = unsorted_chunks(av);
set_head(old_top, old_size | PREV_INUSE);
// 随后通过mmap分配新内存给用户
}
走到这个分支意味着三件事:第一,旧top chunk会被双链进unsorted bin;第二,它的size由我们伪造的old_size决定;第三,sysmalloc会走mmap分配新地址,绕过原本“扩展top chunk”的逻辑。
4.3 页对齐陷阱与size计算
上面代码里最容易被忽略的约束是((unsigned long)old_end & pagemask) == 0,也就是旧top chunk的结束地址必须页对齐。old_end = old_top + old_size,其中old_top是top chunk的起始地址。攻击者修改size时,必须保证old_top + newsize是0x1000的倍数。
很多初学House of orange的人在这里踩坑,因为在网上看的writeup里size改的是0xc01或者0xb01这种值,于是死记硬背。实际上,具体size值取决于运行时old_top的地址。我给你一个计算思路:
text复制newsize = 对齐到页(oldsize所在结束地址) - old_top_addr
为了满足prev_inuse(old_top),新size的最低位必须保持为1。所以常见做法是把newsize构造成类似0xc01的奇数——最低位1表示pre_inuse,减去这个1后,剩余部分的结束地址刚好页对齐。
这里最容易出问题的还有一点:修改后的top chunk剩余大小不能太小,否则old_size >= MINSIZE不满足。MINSIZE在64位下是0x20左右,实际利用中建议留出至少0x500以上的空间,因为后面还要靠这个unsorted bin chunk去做attack,太小的话不够分。
另外,触发这个分支还有一个隐性的前提:你这次malloc请求的size必须大到让sysmalloc认为“扩展堆不如直接mmap”。经验值是请求超过mp_.mmap_threshold(默认0x20000),或者top chunk剩余空间严重不足导致sysmalloc内部计算后走向mmap分支。House of orange的经典题目里,攻击者通常会一次性malloc一个0x1000甚至更大的请求,确保走的是mmap分支。
4.4 一个最小化的伪造示例
假设场景:堆上有一个可以溢出0x20字节的漏洞点,top chunk在地址T,当前size是0x21000。我们在溢出时把top chunk的size字段改成0xc01,并且确认T + 0xc01是页对齐的。然后调用malloc(0x1000):
- sysmalloc发现旧top剩余0xc00,远不够0x1000;
- 判断旧top满足大小、prev_inuse、页对齐三个条件;
- 旧top被拆下,以size=0xc00的大小挂入unsorted bin;
- 系统通过mmap分配新的一块内存给这次malloc请求。
就这么一个操作,我们凭空在unsorted bin里拿到一个0xc00大小的chunk。Heap利用里“从无到有”构造unsorted bin chunk的经典手法,到这里算是完成了。
5. 第二步:unsorted bin attack改写_IO_list_all
5.1 unsorted bin的链表操作
当我们拿到一个unsorted bin chunk后,后续的malloc会从unsorted bin里优先找合适的chunk。unsorted bin是一个双向链表,每个chunk的fd和bk分别指向前后邻居。在_int_malloc的unsorted bin处理逻辑里,每取下一个chunk时会执行这样几步:
c复制victim = unsorted_chunks(av)->bk; // 从链表尾部取
bck = victim->bk; // 记录前一个节点
...
unsorted_chunks(av)->bk = bck;
bck->fd = unsorted_chunks(av); // 把bck的fd指向链表头
关键在这句bck->fd = unsorted_chunks(av):如果我们可以控制victim->bk,也就是控制bck指向哪块内存,那么这句赋值就相当于一个“往任意地址写入unsorted_chunks(av)值”的写原语。写入的值我们控制不了,是链表头地址,但写到哪可以由victim->bk决定。这就是经典的无限制写不可控值的原语,unsorted bin attack。
5.2 篡改bk的那一瞬间发生了什么
现在我们把5.1里的原理套用到攻击场景。前一步我们已经拿到了一个0xc00大小的unsorted bin chunk。接着再通过堆溢出,把这个chunk的bk字段改成:
c复制bk = _IO_list_all - 0x10
为什么是减0x10?因为刚才那行赋值是bck->fd = ...,bck取的是victim->bk,而fd字段在malloc_chunk里的偏移是0x10(chunk头占0x10字节,前8字节是prev_size,后8字节是size,fd在偏移0x10)。所以bck指向_IO_list_all - 0x10时,bck->fd恰好落在_IO_list_all上。
之后我们再触发一次会踢出这个chunk的malloc。当_int_malloc处理到这个victim时,会执行:
c复制bck->fd = unsorted_chunks(av);
于是_IO_list_all这个全局指针就被写成了unsorted_chunks(av)。在常见的libc 2.23环境里,这个地址落在main_arena附近。具体等于哪个偏移,不同版本、不同内核下略有差异,但大体在main_arena + 0x60左右。你实际调试时,在gdb里打印unsorted_chunks(av)或者直接看_IO_list_all被写后的值就能确认。
这里还有个很容易被忽略的细节:victim->fd也需要满足一致性,否则malloc会报malloc(): memory corruption。所以篡改的时候一般保持fd不变,只改bk。同时,为了后续利用,很多人会顺手把victim->size改成0x61这种small bin大小,这一步具体作用放到下一章讲。
5.3 为什么选_IO_list_all这个目标
攻击目标的选择逻辑其实很直接:我们需要一个程序退出时会被自动遍历、且遍历路径上能触发函数指针调用的全局对象。_IO_list_all完美满足。它本身就是一个链表头指针,溢出后整个链表的遍历起点就掌握在手里;链上每个节点的_chain字段又能控制下一步走向。对比其他全局符号,比如__malloc_hook、__free_hook,虽然也是可写的,但它们需要某个具体的malloc/free调用才会被触发,而House of orange的场景里后续不一定有可控的malloc/free时机。_IO_list_all则是程序退出时自动触达,时机可控、触发条件是遍历链表的必然动作。
6. 第三步:伪造_IO_FILE_plus链条,接管退出流程
6.1 main_arena+0x60处的第一个“伪FILE”
现在_IO_list_all被改写成了main_arena附近的地址。程序退出时,_IO_flush_all_lockp将会把这个地址当成一个_IO_FILE结构体来遍历。也就是说,从main_arena+0x60开始的一段内存,被硬生生地解读成一个FILE结构。
这个“伪FILE”的_flags、_IO_read_ptr、_IO_write_base这些字段,分别对应main_arena里的某些内部状态。想让这个伪FILE自己直接触发_IO_OVERFLOW通常是很难的,因为main_arena里的值不可控且不稳定。但这并不致命,别忘了遍历是链式的。只要第一个伪FILE的_chain字段指向下一个我们可控的结构体就行。
_chain在FILE结构里的偏移是0x68,所以对于以main_arena+0x60为起点解读的伪FILE,_chain位于main_arena+0x60+0x68 = main_arena+0xc8。这个位置恰好和small bin的某些头部很接近。如果我们之前把victim的size改成了0x61,那么它就会被挂进small bin的某个bin里,而这个bin的链表头指针正好落在main_arena+0xc8附近。妙就妙在,small bin链表头的fd字段,经过内存布局的错位后,恰好能被伪FILE的_chain读到。
于是整个链式遍历就变成了:第一个伪FILE从main_arena里的small bin头部读到_chain,_chain指向堆上我们控制的chunk;然后遍历器把那个chunk再当作下一个FILE结构体来解读。
6.2 _chain的指向与small bin的联动
我先把small bin的布局关系画个大致对应,方便理解这个错位是如何发生的:
- 我们把victim的size改成0x61,触发前一步的unsorted bin attack时,victim会被从unsorted bin里摘下,放进small bin 8(管理0x60大小chunk的bin)。
- small bin 8的链表头指针在main_arena内的偏移,经过
bin_at宏计算后,会让其fd字段落在main_arena+0xd0附近。 - 而伪FILE的
_chain字段在main_arena+0xc8。这两个地址相差不到0x10,并且由于glibc对bin头结构的处理方式,_chain读到的内容可以被设计成恰好等于small bin 8的fd值,也就是victim的地址。
这看起来有点绕,但本质上就是利用头部结构的字节错位,让_chain这个字段“意外”地指到了我们堆放伪造FILE结构体的chunk上。
这一步有一个很关键的条件:victim的size必须改为small bin范围内,且这个size要对应一个我们能预测的bin索引。经典选择0x61是因为0x60大小的chunk落入small bin后,它的链表头和main_arena+0xc8的错位关系最舒服,这也是为什么网上几乎所有的House of orange payload里都会出现0x61这个神奇数字。
6.3 第二层伪造FILE的布局
当遍历器顺着_chain跳到victim这个大chunk时,它会把这个chunk的用户数据区域当作_IO_FILE_plus结构体。接下来我们需要在这个chunk里伪造一个完整的FILE结构。核心是满足两个要求:
第一,让_IO_flush_all_lockp的判断走到_IO_OVERFLOW分支。按照3.2的结论,我们需要在伪造结构里设置:
_mode置0或负数;_IO_write_ptr>_IO_write_base。
第二,让vtable指向可控的函数指针表,并且表中__overflow槽位指向system。在glibc 2.23下,vtable指针可以直接指向堆上伪造的表,因为那时没有校验。
伪结构的大致样子如下:
c复制// 假设伪造数据从 chunk 用户区偏移 file_off 开始
fake_file = chunk_user + file_off;
*(fake_file + 0x00) = something; // _flags,可以随意
*(fake_file + 0x20) = some_addr; // _IO_write_base
*(fake_file + 0x28) = some_addr + 0x10; // _IO_write_ptr,大于write_base
*(fake_file + 0x68) = 0; // _chain,置0结束链表
*(fake_file + 0xc0) = 0; // _mode,0表示模式一
*(fake_file + 0xd8) = fake_vtable; // vtable指向伪造表
fake_vtable[0x18/8] = system; // __overflow = system
当_IO_OVERFLOW(fp, EOF)被调用时,实际会执行vtable[0x18/8](fp, EOF),也就是system(fp)。也就是说,函数第一个参数恰好是fp这个指针。那fp指向哪里?它指向我们伪造的FILE结构体的起始地址,也就是victim chunk的用户区加上某个偏移。如果我们在伪造FILE的起始处就写入"/bin/sh\0",那system(fp)就等效于system("/bin/sh")。
这种“传入结构体首地址当字符串指针”的技巧是FSOP的精髓。很多人背payload时会疑惑为什么_flags字段要写/bin/sh,就是因为第一个参数永远等于FILE结构体地址,把字符串放在结构体头部是最省事的选择。
6.4 从退出到system("/bin/sh")的完整路径
我把整条链路串起来,方便你对照理解:
text复制1. 堆溢出伪造top chunk size
2. malloc触发sysmalloc,top chunk进入unsorted bin
3. 堆溢出篡改unsorted bin chunk的bk为 _IO_list_all - 0x10
4. 再次malloc触发unsorted bin attack,_IO_list_all被写成main_arena+0x60附近
5. 攻击chunk被放入small bin 8
6. 程序退出,_IO_flush_all_lockp从_IO_list_all开始遍历
7. 第一个伪FILE(main_arena+0x60处)条件不满足,但不报错,顺_chain跳到victim
8. victim被当作第二个FILE结构体,其伪造的vtable+0x18指向system
9. _IO_OVERFLOW被触发,system(fp)执行,fp起始处是"/bin/sh"
每一步单看都只是对某个指针的篡改,但串起来以后,利用链的每一环都严密咬合:前一步的副作用正好为后一步提供条件。这也是House of orange被称为“利用链艺术品”的原因。
7. glibc 2.24后的vtable校验:House of orange还灵吗
7.1 IO_validate_vtable的检查逻辑
glibc官方显然也意识到FILE结构体是个巨大的攻击面,所以在2.24版本加入了vtable校验。核心函数是IO_validate_vtable:
c复制static inline const struct _IO_jump_t *
IO_validate_vtable (const struct _IO_jump_t *vtable)
{
uintptr_t section_vtable = (uintptr_t) &_IO_vtable;
uintptr_t section_end = section_vtable + _IO_VTABLES_LEN;
if (vtable < section_vtable || vtable >= section_end)
_IO_vtable_check ();
return vtable;
}
简单说,vtable指针必须落在libc的__libc_IO_vtables段范围内。以前那种“vtable直接指到堆上伪造的函数指针表”的做法,在2.24之后就废了——只要越界,就会调用_IO_vtable_check,大概率直接abort。
所以House of orange的前两步(伪造top chunk、unsorted bin attack改_IO_list_all)在2.24及以后依然能走通,但第三步里“vtable指向堆”这条路被堵死了。
7.2 用_IO_str_jumps续命的基本思路
vtable不能指向堆,那就只能在合法的vtable段里找现成的表。攻击者的目光转向了_IO_str_jumps、_IO_wfile_jumps这些本来就存在于libc中的vtable。它们的问题是:这些表是只读的,你不能把表里的__overflow改成system。
但_IO_str_jumps里的__overflow指向_IO_str_overflow,这个函数内部会根据FILE结构体里的某些字段,对一个缓冲区做拷贝操作,并且在特定条件下会调用malloc、free甚至memcpy。攻击者可以通过精心伪造FILE结构体,让_IO_str_overflow在内部触发出一个可控的指针解引用或写操作,再叠加pointer guard的解密绕过,最终仍然能够劫持控制流。这条路衍生出了很多新技巧,比如_IO_str_overflow配合_wide_data伪造、_IO_wfile_jumps配合wide_data劫持等等,细节比2.23时代的直接篡改vtable复杂得多。
如果你要在新版本glibc下继续玩FSOP,建议先把_IO_str_jumps这条线单独啃一遍。它和House of orange的主要区别在于:House of orange教你怎么把遍历引到伪造FILE上,而_IO_str_jumps教你在vtable被限死之后,怎么利用合法vtable内部的逻辑完成最后的控制流劫持。两者正好是一个承上启下的关系。
8. 实战中的几个“坑”与调试经验
8.1 别硬背偏移,gdb是你最好的朋友
我发现很多人学House of orange时最大的问题是拿writeup当八股文背,换一道题、换一个libc版本就抓瞎。这里我想强调一个我自己的习惯:在调试脚本里加上gdb,遇到任何“理论上应该成功但实际崩了”的情况,第一件事就是打印关键地址。
至少要把这三个东西打出来确认:
text复制p _IO_list_all
p &main_arena
x/8gx main_arena
_IO_list_all被攻击后到底指向哪,直接x/gx &_IO_list_all看一眼就知道。main_arena里各个bin头部的实际偏移,也能通过打印比较。不同glibc版本之间,main_arena的偏移可能差几个字节,这种差异靠背writeup是背不出来的,只有打印当前进程的真实地址才可靠。
8.2 常见报错与排查思路
我列几个自己踩过、也看别人反复踩的坑,按出现频率排序:
| 现象 | 最可能的原因 | 排查方向 |
|---|---|---|
malloc(): memory corruption |
篡改bk时把fd也弄坏了,或者victim的size没改对 |
检查chunk头前后指针一致性 |
assert(old_top ...)失败 |
伪造的top chunk size不满足页对齐 | 打印old_top地址,重新计算newsize |
| 遍历FILE时段错误 | 伪造FILE的_chain指向了非法地址 |
检查_chain字段是否落在了small bin头部可读范围 |
触发了_IO_vtable_check |
vtable指针不在合法段内 | 确认是否用了2.24+的glibc,决定是否需要走_IO_str_jumps |
_IO_OVERFLOW没被触发 |
_mode不为0或_IO_write_ptr <= _IO_write_base |
检查伪FILE结构里的mode和两个write指针 |
8.3 练习建议:从哪几道题入手
如果你想把House of orange彻底练熟,我建议按这个顺序来:
第一道,找原版House of orange题目,环境是glibc 2.23,先把整个利用链原样复现一遍,重点体会第一步和第二步的衔接。第二道,找一道无free但只有一个edit功能、且存在堆溢出的菜单题,尝试自己设计伪造top chunk的size,不要去抄writeup里的固定值。第三道,把同一道题放到glibc 2.24以上的环境里重打,研究vtable校验带来的变化,顺便把_IO_str_jumps的路子走通。
我个人练完三道的最大感受是:House of orange真正的难点不在“伪造结构体”本身,而在于你要对glibc内部的数据流有足够的敏感度,知道哪些字段在什么时候会被读取、被当作什么类型使用。_IO_FILE结构体里几十个字段,攻击面远不止_IO_OVERFLOW一个入口;fwrite路径上的__xsputn、fclose路径上的__finish,都是同样性质的分发点。理解了这个分发机制,你就不会只拘泥于House of orange这一种打法,遇到新的glibc加固,也能在合法vtable里找到新的落脚点。
