为什么要学习_IO_FILE?
_IO_FILE的特性可以让我们在没有show的情况下完成信息泄露,也可以让我们在没有free()给出的情况下完成getshell。
_IO_FILE内部含有丰富的虚函数,对这些虚函数进行劫持,能够形成类似于修改got表的效果。
同时libc 2.34(2021年出现的)以后的libc版本都没有malloc_hook之类的hook了,以后的版本都必须要运用_IO_FILE相关攻击来完成堆利用最后的步骤了。
一些高版本libc利用,也都需要IO_FILE相关的基础知识,所以确实非常值得一学。本文主要目的在于梳理一下_IO_FILE相关的结构体,并尝试解释一些较低版本的利用手法,不涉及目前的高版本主流利用手法,仅作为下一阶段学习的预备。
01
从I/O操作到_IO_FILE内部细节
I/O流的操作,如stdin stdout stderr,是由FILE结构体所承载的。因为“一切皆文件”。
Linux 和 Unix 系统中,几乎所有的 I/O 操作都通过文件描述符来进行,这包括常规文件、设备、管道、网络套接字等。这种“一切皆文件”的思想提供了一种统一的抽象,使得程序可以使用相同的系统调用(如 read、write、open、close)来处理各种不同的 I/O 资源。
在这种背景下,C 标准库的 FILE 结构体也提供了一种统一的接口来处理各种 I/O 流,包括标准输入、标准输出、标准错误、文件等。FILE 结构体背后封装了底层的文件描述符和缓冲区管理,使得程序员能够用一致的方式来进行 I/O 操作。
_IO_FILE是FILE实际的实现部分,对外不公开透明(opaque),这样易于用户进行使用而无需关注内部细节——如果需要修改 _IO_FILE 的内部实现,可以在不改变外部接口(即 FILE 和相关API)的情况下进行。
那么,_IO_FILE内部是如何实现的呢?
02
_IO_FILE结构体
其实主要就是定义了文件的读写过程中需要用到的开始和结束的地址信息等,比如,write_base,write_end,这是控制写出内容的地址范围的。
如果攻击过程可以更改这两个值,那么,就可以在下次puts()、printf()等输出类函数执行的时候,造成被指定内存上的信息泄露。
其注释翻译如下,重点关注具有【标注】的部分:
struct _IO_FILE
{
int _flags;/*【flag标志位】高两位字是 _IO_MAGIC,在这里是固定的"0xfbad",低两位则决定了程序的执行状态。*/ /* 以下指针对应于 C++ 的 streambuf 协议。*/
char *_IO_read_ptr; /*【读入】 当前读取指针 */
char *_IO_read_end; /*【读入】 读取区域的结束位置。*/
char *_IO_read_base; /*【读入】 回退区和读取区域的起始位置。*/
char *_IO_write_base; /*【写出】 写出区域的起始位置。*/
char *_IO_write_ptr; /*【写出】 当前写出指针。*/
char *_IO_write_end; /*【写出】 写出区域的结束位置。*/
char *_IO_buf_base; /* 保留区域的起始位置。*/
char *_IO_buf_end; /* 保留区域的结束位置。*/
/* 以下字段用于支持回退和撤销操作。*/
char *_IO_save_base; /* 非当前读取区域的起始位置指针。*/
char *_IO_backup_base; /* 回退区域的第一个有效字符指针 */
char *_IO_save_end; /* 非当前读取区域的结束位置指针。*/
struct _IO_marker *_markers;
struct _IO_FILE *_chain;/*【链接】这里就是_IO_FILE之间的chain指针*/
int _fileno;
int _flags2;
__off_t _old_offset; /* 以前是 _offset,但它太小了。(注:原话如此))*/
/* pbase() 的列号加1;0 表示未知。*/
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};
flag标志的作用是什么?事实上,能够在以下文件处找到_flag字段的定义,以及低四位的值所对应的含义。
一般在执行流程中会将_flag和定义常量进行按位与运算,并根据与运算的结构进行判断如何执行。
常见FILE结构体,比如stderr,stdout,stdin之间,有什么样的关联指针?
通过chain指针,FILE(IO_FILE)结构体互相之间形成了链接关系,
_IO_list_all则是指向首个FILE结构体,一般就是stderr
_IO_list_all同时是libc库文件里的一个指针,可以通过偏移获取其值
到这里就很好理解什么是FSOP文件流导向编程攻击了,从攻击者的角度出发,覆写_IO_list_all指针,然后使用chain指针控制执行流。
其与ROP攻击同样是通过某种方式来改变了程序执行流程。
这是_IO_FILE的扩展结构,也是现在真正的主流结构,首先是引用了FILE结构,即_IO_FILE本身。
其变动在于,扩展了一个IO_jump_t类型的虚函数表(vtable,vitural table)指针。虚表,其中记录了本类中所有虚函数的函数指针,也就是说是个函数指针数组的起始位置。
为了方便之后的利用,在此贴出完整的_IO_FILE_plus结构体内部的各字段偏移如下:
0x0 _flags
0x8 _IO_read_ptr
0x10 _IO_read_end
0x18 _IO_read_base
0x20 _IO_write_base
0x28 _IO_write_ptr
0x30 _IO_write_end
0x38 _IO_buf_base
0x40 _IO_buf_end
0x48 _IO_save_base
0x50 _IO_backup_base
0x58 _IO_save_end
0x60 _markers
0x68 _chain
0x70 _fileno
0x74 _flags2
0x78 _old_offset
0x80 _cur_column
0x82 _vtable_offset
0x83 _shortbuf
0x88 _lock
0x90 _offset
0x98 _codecvt
0xa0 _wide_data
0xa8 _freeres_list
0xb0 _freeres_buf
0xb8 __pad5
0xc0 _mode
0xc4 _unused2
0xd8 vtable
在以上的偏移之中,有一个显得比较特别,vtable,它是指向虚表的结构体指针。那么,vtable的内部构造又是怎么样的?
vtable虚表指针所属的IO_jump_t类型的结构如下,这个小小的表,承载了相当多的虚指针,修改这些指针可以实现类似于got表劫持的效果,也是高版本libc利用的常客了。
这个函数表中一共有19个虚函数,分别完成IO相关的功能,由IO函数调用,
只要涉及到io操作,都会间接调用_IO开头的虚函数,比如调用write就一定会间接调用__write,调用puts就一定会间接调用__xsputn等等。
显然,这就可能造成类似于got表劫持攻击的情况:控制了__xsputn为onegadget,调用puts就会经过这一必然被执行的地方,从而getshell。
03
信息泄露:无show条件下泄露libc基址
针对没有show()的情况,可以对__IO_2_1_stdout(别看这一长串,其实就是stdout)这其中一个IO_FILE结构体内部的write地址相关指针进行篡改,并在后面触发puts(),使得该结构体被使用,而且其实同时可以具备任意写能力。
主要参阅以下代码
https://github.com/bminor/glibc/blob/release/2.23/master/libio/fileops.c
为了方便看,再把_flag字段的含义贴在这里一下。
_IO_XSPUTN,实际上的hidden ver是_IO_new_file_xsputn,其实hidden在这里又是类似于对用户透明的意思,_IO_new_file_xsputn才是。
跟进去_IO_new_file_xsputn看,观察其伪造条件,不多说,上图:
事实上,行缓冲那部分的逻辑不一定要到,而我们重点关注这几个部分:_IO_OVERFLOW、new_do_write。。
实际上这里,如果count<do_write,就会提前return,那么就执行不到后面的逻辑了,显然这并不是我们希望看到的。
所以:
满足①即可绕过条件②
综上,可构造_IO_IS_APPENDING、_IO_CURRENTLY_PUTTING 为真,_IO_NO_WRITES为假,write_base和write_ptr则指明write的地址范围。
#include <stdio.h>int main() {
long long int *ptr = stdout;
char str[0x10];
puts("hahaha:");
read(0, str, 0x10);
// 设置flags
*ptr = 0xfbad1800; //0xfbad18XX
// 设置_IO_read_ptr和_IO_read_end
*(ptr + 1) = 0; // _IO_read_ptr
*(ptr + 2) = 0; // _IO_read_end
// 设置_IO_read_base
*(ptr + 3) = 0;
// 设置_IO_write_base和_IO_write_ptr
*(ptr + 4) = (char*)ptr - 0x50; // _IO_write_base
*(ptr + 5) = (char*)ptr - 0x1f; // _IO_write_ptr
puts("hello");
return 0;
}
可以看到是输出了不止hello的内容。
04
操控执行流:FSOP(File Stream Oriented Programming)(before libc2.28)
FSOP,即"文件流导向编程",这与返回导向编程是一种类似的技术,主要是指通过精心构造_IO_FILE结构体,从而完成对控制流的操控。
主要参阅以下代码
https://github.com/bminor/glibc/blob/release/2.23/master/libio/fileops.c
同时也是House_of_Orange作者提出时所选择的fsop方式是,触发_IO_flush_all_lockp的方式,该方式仅需极少的判断即可完成程序的劫持。
3*8=24即__overflow在vtable中的偏移,接下来请看:
ctfwiki上的代码如下:
#define _IO_list_all 0x7ffff7dd2520
#define mode_offset 0xc0
#define writeptr_offset 0x28
#define writebase_offset 0x20
#define vtable_offset 0xd8int main(void)
{
void *ptr;
long long *list_all_ptr;
ptr=malloc(0x200);//创造存放伪造结构体的空间
*(long long*)((long long)ptr)="/bin/sh\0"; //fd,会被当做_IO_OVERFLOW的第一参数写入
*(long long*)((long long)ptr+mode_offset)=0x0;//满足条件
*(long long*)((long long)ptr+writeptr_offset)=0x1;//满足条件
*(long long*)((long long)ptr+writebase_offset)=0x0;//满足条件
*(long long*)((long long)ptr+vtable_offset)=((long long)ptr+0x100);//指向ptr更下方0x100的可控位置
*(long long*)((long long)ptr+0x100+24)=0x41414141;//_IO_OVERFLOW,实战中改为system真实地址
//劫持掉_IO_list_all指向以上伪造的内容
list_all_ptr=(long long *)_IO_list_all;
list_all_ptr[0]=ptr;
exit(0);//触发_IO_flush_all_lockp方法之一:exit
}
编译运行于2.23libc,发现程序最终停在了0x41414141上,而且rdi是/bin/sh,实战中把system真实地址替换掉"aaaa"即可getshell。
查看_IO_list_all此时指向的结构体,确实伪造了需要的条件:
然后看向伪造处的vtable:
面对劫持vtable,该如何进行防御才不会引入太大开销?
其实vtable劫持与正常执行流最大的区别就是,执行的地址不一样,所以应该从地址的检测入手。
与2.23相比,2.24增添了IO_validate_vtable,对虚表的地址进行合法性检查:
而这个偏移值,大概在0x100-0x200左右(0x),不会很大,所以在这里把vtable劫持到heap那么高的地址的方式彻底失效了。
但是,如果这些地址内的虚表的内部,有能利用的点,那就还是能继续进行FSOP的,具体而言有这些基于_IO_jump_t的结构体可以看看。
事实上,跟进会发现_IO_str_jumps、_IO_wstr_jumps拥有这样的潜力,他们其实还是保留着各io操作的虚函数地址,仍然可以进行劫持的,换汤不换药。
阅读https://github.com/bminor/glibc/blob/release/2.24/master/libio/strops.c源码,可以知道:
这个算是"品相"最好的可利用函数,需要满足的条件极其容易进行伪造。当然_IO_wstr_jumps也可以实现函数的调用,但限制条件较多,实战还是选择更为容易利用的。
首先,fp->_IO_buf_base在利用中本来就是非空的,因为需要写入"/bin/sh"。
其次,可以把fp的内部的_s的free_buffer(fp + 0xe8 )给改成system函数地址,甚至是onegadget从而getshell。
所以这种需要更少信息(无需heap地址)
// 必须关闭ASLR保护,且libc版本为2.23-0ubuntu11.3_amd64才行
// 本案例演示调用_IO_str_finish指针函数
#include <stdio.h>
#include <stdlib.h>char bin_sh[8] = "/bin/sh";
int backdoor(char *str) {
system(str);
return 0;
}
int main() {
long long int *ptr = (long long int *)stderr;
// 创建fake_stdout
long long int *fake_stdout = (long long int *)malloc(0x120);
puts("本案例演示调用_IO_str_finish指针函数");
// 下列构造满足执行_IO_str_finish的条件
*(fake_stdout) = 0; // flags
// *(fake_stdout + 1) = 0x60; // _IO_read_ptr 是small bins的时候需要设置
// *(fake_stdout + 3) = 0x7ffff7bc5510; // _IO_read_base是_IO_list_all-0x10 //这里需要调试一下
*(fake_stdout + 4) = 0; // _IO_write_base
*(fake_stdout + 5) = 1; // _IO_write_ptr
*(fake_stdout + 7) = (long long int)bin_sh; // _IO_buf_base
*(fake_stdout + 0x18 / 8) = 0; // _mode
*(fake_stdout + 0x18 / 8 + 1) = 0x7ffff7bc3798; // vtable, 是_IO_str_jumps-8的地址
*(fake_stdout + 0x1d) = (long long int)backdoor; // fp+0x38
// 将stderr的chain替换为fake_stdout
*(ptr + 13) = (long long int)fake_stdout;
// exit(0);
return 0;
}
05
综合利用案例:House_of_Orange(无free,可溢出,libc<=2.28可用)
(以其出现的2.23版本为例)简单来说就是FSOP+Unsorted bin攻击的组合利用,其实算是模板题目了,其容易出现难度的考点反而在于如何泄露libc和heap地址,却使得攻击流程能正常继续,此处先不涉及。
House of Orange是利用Unsorted bin attack+FSOP技术的攻击手法,通过Unsorted bin attack对_IO_list_all进行伪造,然后是通过预先修改每层chain的指向,从而掌握程序的执行流,形成FSOP(文件流的导向编程)
简单来说就是通过前chunk的溢出,操控其bk指针,然后再进行分配,从而能将Unsorted bin分配到希望分配的区域。
由于已经知道libc地址,通过计算偏移,自然可以知道_IO_list_all的存储位置了。
所以我们可以把Unsorted bin的bk指针,通过溢出,设置为_IO_list_all-0x10,那么其data域就会指向_IO_list_all,下次申请的时候就能将Unsorted bin的data域分配到_IO_list_all上。
实际上刚接触这一手法会有个疑问,为什么非得将_IO_list_all劫持到main_arena+88呢?其实,这算是沿用House of Orange作者原来那套做法。下面简单分析一下位置关系。
承接以上步骤,首先将旧有top chunk的size从0xfa1更改为0x60,那么,下次分配时,ptmalloc会检索每个unsorted bin,并且发现这个bin的大小是0x60,小于0x400,于是将其放入small bin[4]里。
small bin[4]意思不是第五个small bin,而是指向的是首个0x60的small bin,从0x20开始每次增加0x10,这样数下去第5个正是0x60,这就是small bin[4]的含义
small bin[4]与main_arena的相对位置是位于main_arena+192。
而如果想要让我们的chain引导下一个_IO_FILE指向main_arena+192,那就需要减去chain的位置0x68,192-0x68,就是main_arena+88,这个偏移值是这样确定的。
所以我们要把_IO_list_all劫持到main_arena+88的位置,只有这样,下一个_IO_FILE结构体才会指向刚刚生成的small bin,此时就可以进行_IO_FILE伪造攻击了。
可以说,这个方式去制造_IO_FILE的伪造条件是比较快的,无需泄露small_bin具体位置,就可以又准又快地去让_IO_FILE的流跳转到我们的可控区域上,应该说没有更快的方法了。
直接上图比较清楚:
最后伪造的堆块,使用了低地址一些的块进行对small bin的覆盖操作来伪造一个恶意的IO_FILE结构体,可以看到small bin data域的开始位置被填充了/bin/sh\x00,fd指向了这里,会被作为_IO_OVERFLOW的首个参数,前文2.23版本下的FSOP已经提到。
然后,将_IO_OVERFLOW劫持为system函数,那么一旦触发_IO_flush_all_lockp,比如exit(0),就会触发整个执行流程,从而getshell。
可以像这样去覆盖small bin的区域,伪造一个_IO_FILE结构体,比如某题的官方writeup伪造如下:
一般这类题型都没有free。所以攻击不成问题,反倒是怎么泄露地址而且恢复程序的正常执行是考点所在。
在没有free功能的情况下,如果我们能够溢出,那么仍然可以制造带有完整fd、bk的Unsorted bin,从而造成libc地址信息泄露、bk劫持控制下一个Unsortedbin堆块区域等。
这里就得通过top_chunk size的篡改来实现unsorted bin的生成了,每次进行分配时,ptmalloc都会检查top_chunk_size,如果不够申请新的块,那就free掉旧top_chunk所属空间,而malloc一个新的大空间给新top_chunk。
只需对top_chunksize进行小小的改动,将0x20fe1改小,就能让其被判断为已经耗尽了。
要注意top_chunk_size伪造也并不是能随便更改的,因为在sysmalloc中对这个值还要做校验,
(1)大于MINSIZE(一般为0X10)
(2)小于接下来申请chunk的大小 + MINSIZE
(3)prev inuse位设置为1
(4)old_top + oldsize的值是页对齐的,即 (&old_top+old_size)&(0x1000-1) == 0
最主要还是第四个条件约束最严格,很自然地想到,要是想满足页对齐,同时满足以上所有条件的话,让新的top_chunk从0x20fe1变为0xfe1即可,实战中经常是这样的做法。
使用前面的块进行溢出覆盖,即可完成以上所述topchunk伪造,
此时申请大于0xfe1的空间,由于空间不足,使得系统以brk的方式来扩展空间(这也是为什么要页对齐的原因,因为brk的申请就是页对齐的)。
同时,原top_chunk被free成了unsorted bin,从而暴露出了unsorted bin特有的跟Libc有固定偏移关系的fd、bk指针,通过减去偏移即可将其泄露出来。
06
未来展望
由于2.34移除了以下符号:
◆__free_hook
◆__malloc_hook
◆__realloc_hook
◆__memalign_hook
所以未来的堆利用,也许将更加离不开_IO_FILE结构体...
IO_FILE在高版本的堆利用还是有非常多的,未完待续...
◆house of emma
◆house of obstack
◆house of apple1/2/3
◆house of lyn/snake
都运用了_IO_FILE结构体做文章。
看雪ID:是气球呀
https://bbs.kanxue.com/user-home-869206.htm
# 往期推荐
球分享
球点赞
球在看
点击阅读原文查看更多