0x00 写在前面
第一次接触FILE结构体,是在BCTF2018的baby_arena中,利用了FSOP,当时感觉结构体中字段太多了,没有理清楚到底是什么关系。也是时隔非常非常非常久,咸鱼的我终于开始系统的学习一下FILE结构体。本篇基于libc-2.27源码对该版本文件流的数据结构、相关操作以及一些常见的漏洞利用方法进行分析。
0x01 数据结构
-
_IO_FILE_plus
虽然每次都是说FILE结构体,但其实FILE结构体的外层还有一个结构体,叫做_IO_FILE_plus结构体。该结构体在glibc/libio/libioP.h中定义如下:
struct _IO_FILE_plus
{
FILE file;
const struct _IO_jump_t *vtable;
}
中间包含了一个我们常说的FILE结构体,以及_IO_jump_t的一个虚表结构体。
-
FILE/_IO_FILE
在glibc/libio/bits/types/FILE.h中可以看到FILE结构体其实就是_IO_FILE结构体。
typedef struct _IO_FILE FILE;
_IO_FILE结构体在glibc/libio/bits/libio.h中定义如下:
struct _IO_FILE
{
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
/* The following pointers correspond to the C++ streambuf protocol. */
char *_IO_read_ptr; /* Current read pointer */
char *_IO_read_end; /* End of get area. */
char *_IO_read_base; /* Start of puback+get area. */
char *_IO_write_base; /* Start of put area. */
char *_IO_write_ptr; /* Current put pointer. */
char *_IO_write_end; /* End of put area. */
char *_IO_buf_base; /* Start of reserve area. */
char *_IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area. */
char *_IO_save_end; /* Pointer to end of non-current get area. */
......
struct _IO_FILE *_chain;
int _fileno;
......
int _flags2;
......
#ifdef _IO_USE_OLD_IO_FILE
};
struct _IO_FILE_complete
{
struct _IO_FILE _file;
#endif
......
size_t __pad5;
int _mode;
char _unused2[15 * sizeof(int) - 4 * sizeof(void*) - sizeof(size_t)];
};
其中_flags字段标志了该FILE结构体的读写等属性,该字段的前2个字节固定为0xFBAD的魔术头,其具体数值在glibc/libio/libio.h中进行宏定义如下:
/* Magic number and bits for the _flags field. The magic number is
mostly vestigial, but preserved for compatibility. It occupies the
high 16 bits of _flags; the low 16 bits are actual flag bits. */
#define _IO_MAGIC 0xFBAD0000 /* Magic number */
#define _IO_MAGIC_MASK 0xFFFF0000
#define _IO_USER_BUF 0x0001 /* Don't deallocate buffer on close. */
#define _IO_UNBUFFERED 0x0002
#define _IO_NO_READS 0x0004 /* Reading not allowed. */
#define _IO_NO_WRITES 0x0008 /* Writing not allowed. */
#define _IO_EOF_SEEN 0x0010
#define _IO_ERR_SEEN 0x0020
#define _IO_DELETE_DONT_CLOSE 0x0040 /* Don't call close(_fileno) on close. */
#define _IO_LINKED 0x0080 /* In the list of all open files. */
#define _IO_IN_BACKUP 0x0100
#define _IO_LINE_BUF 0x0200
#define _IO_TIED_PUT_GET 0x0400 /* Put and get pointer move in unison. */
#define _IO_CURRENTLY_PUTTING 0x0800
#define _IO_IS_APPENDING 0x1000
#define _IO_IS_FILEBUF 0x2000
/* 0x4000 No longer used, reserved for compat. */
#define _IO_USER_LOCK 0x8000
_IO_read_ptr字段为输入缓冲区的当前地址
_IO_read_end字段为输入缓冲区的结束地址
_IO_read_base字段为输入缓冲区的起始地址
_IO_write_base字段为输出缓冲区的起始地址
_IO_write_ptr字段为输出缓冲区的当前地址
_IO_write_end字段为输出缓冲区的结束地址
_IO_buf_base字段为输入输出缓冲区的起始地址
_IO_buf_end字段为输入输出缓冲区的结束地址
_chain字段为指向下一个_IO_FILE结构体的指针,在gilbc/libio/libioP.h中有如下声明:
extern struct _IO_FILE_plus *_IO_list_all;
该变量为一个单链表的头结点,该单链表用于管理程序中所有的FILE结构体,并通过_chain字段索引下一个FILE结构体,每个程序中该链表的最后3个节点从后往前固定为_IO_2_1_stdin、_IO_2_1_stdout、_IO_2_1_stderr,之前是用户新申请的FILE结构体,每次新申请的FILE结构体会插在该链表的表头。大概长成下面这样:
值得注意的是,在_IO_FILE结构体定义的内部有一个宏
#ifdef _IO_USE_OLD_IO_FILE
,如果不存在_IO_USE_OLD_IO_FILE的宏定义,则会将后面的}以及下一个结构体_IO_FILE_complete的定义头给跳过,即扩充了_IO_FILE结构体,使其拥有了更多的字段。_IO_2_1_stdin、_IO_2_1_stdout、_IO_2_1_stderr的FILE结构体均为扩展后的。比如某次调试中的_IO_2_1_stdout结构如下(从_lock之后到vtable之前的字段均为扩展后的):-
_IO_jump_t
该结构体在glibc/libio/libioP.h中定义如下:
struct _IO_jump_t
{
JUMP_FIELD(size_t, __dummy);
JUMP_FIELD(size_t, __dummy2);
JUMP_FIELD(_IO_finish_t, __finish);
JUMP_FIELD(_IO_overflow_t, __overflow);
JUMP_FIELD(_IO_underflow_t, __underflow);
JUMP_FIELD(_IO_underflow_t, __uflow);
JUMP_FIELD(_IO_pbackfail_t, __pbackfail);
/* showmany */
JUMP_FIELD(_IO_xsputn_t, __xsputn);
JUMP_FIELD(_IO_xsgetn_t, __xsgetn);
JUMP_FIELD(_IO_seekoff_t, __seekoff);
JUMP_FIELD(_IO_seekpos_t, __seekpos);
JUMP_FIELD(_IO_setbuf_t, __setbuf);
JUMP_FIELD(_IO_sync_t, __sync);
JUMP_FIELD(_IO_doallocate_t, __doallocate);
JUMP_FIELD(_IO_read_t, __read);
JUMP_FIELD(_IO_write_t, __write);
JUMP_FIELD(_IO_seek_t, __seek);
JUMP_FIELD(_IO_close_t, __close);
JUMP_FIELD(_IO_stat_t, __stat);
JUMP_FIELD(_IO_showmanyc_t, __showmanyc);
JUMP_FIELD(_IO_imbue_t, __imbue);
#if 0
get_column;
set_column;
#endif
};
如上图所示,所有的FILE结构体的虚表指针均指向虚表_IO_file_jumps,在进行IO操作时,都会调用到该结构体中的函数。
0x02 相关操作
fopen
fopen为stdio库中的函数,其在glibc/include/stdio.h中宏定义如下:
#define fopen(fname, mode) _IO_new_fopen(fname, mode)
由stdio.h宏定义可知,平时我们常用的fopen函数其实为定义在glibc/libio/iofopen.c中的_IO_new_fopen函数,该函数直接调用了__fopen_internal函数。
- __fopen_internal函数中第58-65行声明了一个locked_FILE结构体变量指针new_f,该结构体中主要包含了_IO_FILE_plus和_IO_wide_data两个结构,并为该声明的变量分配了空间。
-
__fopen_internal函数中第72行调用了_IO_no_init函数对新申请的locked_FILE结构体进行了初始化,该函数在glibc/libio/genops.c中定义如下:
其中调用的_IO_old_init函数定义于其上方:
不难看出,_IO_old_init函数主要是对_IO_FILE_plus结构体中的各个元素进行初始化,而_IO_no_init主要是对_IO_wide_data 结构体中的各个元素进行初始化。通过两个结构体的初始化,初步猜测,_IO_FILE_plus结构体中元素及虚表主要用于单字节的文件流处理流程中,_IO_wide_data结构体中的元素及虚表主要用于宽字节的文件流处理流程中。 - __fopen_internal函数中第73行调用了_IO_JUMPS函数对结构体的虚表进行了初始化,_IO_JUMPS函数在glibc/libio/libioP.h中宏定义如下:
#define _IO_JUMPS(THIS) (THIS)->vtable
即将_IO_FILE_plus结构体中的虚表指针赋值为虚表_IO_file_jumps的地址。
-
__fopen_internal函数中第74行调用了_IO_new_file_init_internal函数将新初始化的结构体链入_IO_list_all链表的头部,该函数在glibc/libio/fileops.c中定义如下:
该函数主要实现了将初始化好的_IO_FILE_plus结构体链入_IO_list_all链表头部的功能,其中链入链表的功能主要是由_IO_link_in函数进行实现,该函数定义在glibc/libio/genops.c中。除了实现链入功能外,还对_IO_FILE_plus结构体加入了相应的属性,如CLOSED_FILEBUF_FLAGS(可关闭?)属性以及_IO_link_in函数中的_IO_LINKED已链接属性第115行,对_fileno函数赋值为-1,该字段代表该文件流在_IO_list_all链表中的序号,此处赋值为-1相当于对该字段进行一个非法数值的初始化,后面会有_IO_file_is_open函数专门对_fileno数值是否合法进行check。 - __fopen_internal函数中第78行调用了_IO_file_open函数,开始执行真正意义上的fopen操作,该函数在glibc/libio/fileops.c中定义如下:
versioned_symbol(libc, _IO_new_file_fopen, _IO_file_fopen, GLIBC_2_1);
即_IO_file_fopen函数等价于_IO_new_file_fopen函数,该函数定义于同一文件的第211行(太长了就不一次性全部贴了)。
_IO_new_file_fopen有4个参数,分别是文件指针、文件名、属性、是否为32位,其中第一个参数为前面步骤初始化的_IO_FILE结构体指针,第2、3两个参数为用户在调用stdio.h中fopen函数传入的参数,第四个参数为glibc/libio、iofopen.c中_IO_new_fopen函数调用__fopen_internal函数时传入的常亮1。该段代码除了声明变量外主要进行了2个操作:检查该文件流是否打开、根据调用参数的主属性为该文件流添加flag。
第一个操作通过调用_IO_file_is_open函数来实现,该函数在glibc/libio/libioP.h中宏定义如下:
#define _IO_file_is_open(__fp) ((__fp)->_fileno != -1)
即通过检查FILE结构体的_fileno是否为合法序号来判断检该文件流是否为已打开状态。
第二个操作则是通过mode,即fopen函数第二个参数的第一个字符来确定该文件流的属性,并添加对应的flag。在写入flag字段前,代码中有3个比那里那个来分别存储不同的属性,这三个变量分别是omode、oflags、read_write,其中omode标志文件的读写属性,oflags标志文件的修改方式,read_write标志文件内容的读写方式。有如下对应关系:
mode | omode | oflags | read_write |
---|---|---|---|
r | O_RDONLY(只读) | NULL(无) | _IO_NO_WRITES(不给写) |
w | O_WRONLY(只写) | O_CREAT|O_TRUNC(新建/覆盖) | _IO_NO_READS(不给读) |
a | O_WRONLY(只写) | O_CREAT|O_APPEND(新建/追加) | _IO_NO_READS|_IO_IS_APPENDING(不给读/给追加) |
_IO_new_file_fopen继续往后走,代码如下:
该段代码主要进行了2个操作:通过文件流副属性获取对应的flag、调用_IO_file_open函数打开文件。
第一个操作与主属性的表示相似,副属性有如下的对应关系:
mode | omode | oflags | read_write | _flags2 |
---|---|---|---|---|
+ | O_RDWR(可读可写) | 不变 | &_IO_IS_APPENDING | 不变 |
x | 不变 | O_EXCL | 不变 | 不变 |
b | 不变 | 不变 | 不变 | 不变 |
m | 不变 | 不变 | 不变 | _IO_FLAGS32_MMAP |
c | 不变 | 不变 | 不变 | _IO_FLAGS2_NOTCANCEL |
e | 不变 | O_CLOEXEC | 不变 | _IO_FLAGS2_CLOEXEC |
在将所有附属性遍历完后,会调用_IO_file_open函数用于打开文件并返回句柄,该函数有6个参数,该函数在glibc/libio/fileops.c中定义如下:
该函数中,首先会判断FILE结构体的_flags2是否有_IO_FLAGS2_NOTCANCEL位,即是否含有c的副属性,若有则会调用__open_nocancel函数,若无则会调用__open函数,从这两个函数传入了相同的参数可以看出,这两个函数实现了相似的功能,两个函数在glibc/sysdeps/unix/sysv/linux/open64.c中有宏定义如下:
strong_alias(__libc_open64 , __open);
...
strong_alias(__open64_nocancel, __open_nocancel);
以及还有在某些情况__open64_nocancel函数可以等价为__libc_open64函数的定义。在同一文件中,两个函数定义如下:
可以看到这两个函数在return时均调用了INLINE_SYSCALL_CALL函数,即到最后将带有文件修改方式和读写属性的flag作为参数,调用SYSCALL进行打开文件操作,并将句柄返回。(再往底层就是直接宏定义汇编代码,就不继续深究INLINE_SYSCALL_CALL函数内部了)
返回后,回到_IO_file_open函数中,接下来将打开文件后的文件流序号赋值给_fileno字段,之后调用了_IO_mask_flags将具有读写方式的属性加入FILE结构体的flags字段中,若读写方式为a(追加),则会将文件末尾作为文件的偏移。最后会调用_IO_link_in函数确保该结构体已链入_IO_list_all链表(因为在_IO_link_in函数中会有对_IO_LINKED的check,所以并不是重复链入),至此_IO_file_open函数执行完毕。
从_IO_file_open返回后回到_IO_new_file_fopen函数,之后有的一个大段的if语句中,大概是给之前初始化的_wide_data中的各元素进行赋值,在if函数的最后将FILE结构体中的_mode字段赋值为1。该段代码大概如下(语句太长就不贴了):
if(result != NULL)
{
cs = strstr(last_recognized +1 , ",ccs=");
if(cs != NULL)
{
...... //大概是给_wide_data中的各元素赋值
result->_mode = 1;
}
}
return result;
-
return后返回到__fopen_internal函数中,此时执行到了__fopen_internal函数的最后一步,如果上面调用_IO_file_fopen函数打开文件失败,则执行函数第81-83行:调用_IO_un_link函数将链入_IO_list_all的结构体摘除,并free掉为其申请的空间,之后return NULL;若打开文件成功,则会执行第79行:调用__fopen_maybe_mmap函数并返回。该函数在glibc/libio/iofopen.c中定义如下;
该函数会判断_flags2字段中是否含有_IO_FLAGS2_MMAP位,即在打开文件时是否有m属性,还会检查_flags字段中是否含有_IO_NO_WRITES位,即在打开文件时是否有r属性。即在打开文件时有rm两个属性,则会执行函数的主体部分,调用_IO_JUMPS_FILE_plus函数将重置FILE结构体的vtable虚表,该函数在glibc/libio/libioP.h中定义如下:
以上,完成了对fopen函数源码的分析,该函数主要进行了3个操作:为文件流申请空间;初始化FILE结构体及虚表,包括将文件流链入_IO_list_all链表中;打开文件流,包括读取文件属性以及利用系统调用打开文件。
通过阅读源码,对文件属性有了船新的认识:
- 除了日常用到的r/w/a/b/+之外还有x/m/c/e这4个属性,而且作为主属性的r/w/a必须在fopen第二个参数的开头,即只能wb而不能bw。
- 在正常编写代码时宏观能够感到打开文件方式不一样的属性有r/w/a/+/x,而m/c/e这三个属性的采用,仅仅会在系统进行打开文件操作过程中进行一些不太影响大局的判断操作,在宏观上感觉不到。这可能也是FILE结构体中_flags和_flags2两个字段的区别。
- 在上一点中所没提到的b属性,虽然一直知道是以二进制方式打开文件,但是在_IO_new_file_fopen函数中的关于副属性的switch...case语句中,b属性并没有什么卵用。没有加入任何flag标志位,只是将last_recognized赋值为b,即最后一个识别的属性是b。就算不考虑后续代码,只看switch...case语句的结果,当b属性后面跟有其他属性,那么b属性的case中没有留下任何东西(其他属性多多少少修改了_flags/_flags2字段)。
fread
fread函数的一般用法为
fread( void *buffer, size_t size, size_t count, FILE *stream );
该函数共有4个参数,buffer代表接收从文件读取数据的变量首地址,size代表每个对象的大小,count代表对象的个数,stream是代表文件流。即该函数实现了从stream中读size * count字节数据并赋给buffer所指向的地址。该函数在glibc/libio/iofread.c中有如下宏定义:
weak_alias(_IO_fread , fread)
即fread函数原形为_IO_fread函数,该函数在同一文件中定义如下:
该函数的最外层代码比较短,逻辑也很清晰,首先在第34行调用了CHECK_FILE函数对将要输入的文件流进行检查,该函数在glibc/libio/libioP.h中有宏定义如下:
即当IO_DEBUG被定义时,会对FILE结构体的_flags字段进行_IO_MAGIC_MASK位的验证,若不存在,则说明传进来的不是FILE结构体,就return 0。
接下来在函数的第37、39行分别调用了_IO_acquire_lock和_IO_release_lock函数,用来加锁以及去锁。
在中间的第38行,调用了_IO_sgetn函数进行读入数据操作。经过辗转后发现该函数为虚表中的__xsgetn,即_IO_file_xsgetn函数,该函数定义于glibc/libio/fileop.c中,是fread函数的关键。
该函数中定义了4个变量,分别是want表示还要读入的数据字节数、have表示输入缓冲区中剩余的空间大小、count表示要读出数据的个数、s表示接收读出数据的变量地址。由于我们刚从fopen初始化过来,因此FILE结构体中的各字段仍是空值,因此会进入在第1302-1311行的if-else语句,该语句首先判断该文件流中的_IO_save_base字段是否已经赋值,即文件流是否有备份的缓冲区,若有则会将该缓冲区free掉,并去掉_IO_IN_BACKUP位,最后调用_IO_doallocbuf函数。该函数在glibc/libio/genops.c中定义如下:
该函数经过一些检查后会调用_IO_DOALLOCATE函数,该函数在glibc/libio/libioP.h中有宏定义为
#define _IO_DOALLOCATE(FP) JUMP0(__doallocate , FP)
,即为虚表中的__doallocate,对应_IO_file_doallocate函数,该函数在glibc/libio/filedoalloc.c中定义如下:可以看到在第94行给文件流字段加上了_IO_LINE_BUF字段,函数主要是在最后调用了malloc函数分配了size大小的空间给指针p,size在第83行被赋值为_IO_BUFSIZ,该字段有宏定义为8192,但在第84行调用了_IO_SYSSTAT函数,该函数为虚表中的__stat,对应着_IO_file_stat函数,该函数最终将调用syscall来获取该文件状态,并初始化结构体st,初始化后的st如下图:
因为在第97行存在判断,因此size最后赋值为st.blksize的0x1000字节,即4K大小。紧接着调用了_IO_setb函数,该函数在glibc/libio/genops.c中定义如下:
该函数主要实现了对_IO_buf_base和_IO_buf_end两个字段进行赋值。到这里可以知道_IO_doallocbuf函数实现了给文件流分配4K空间用作缓存缓冲区的操作。紧接着回到_IO_file_xsgetn函数是一个100多行的while循环:
-
n <= 4K
因为是刚刚完成初始化的文件流,第一次进行fread时所以会进入第1341行的判断语句,则会调用__underflow函数,该函数在glibc/libio/genops.c中定义如下:
该函数经过一系列检查后调用了_IO_UNDERFLOW函数,该函数即虚表中的__underflow字段,对应_IO_new_file_underflow函数,该函数定义在glibc/libio/fileop.c中,进入函数后按照当前状态会直接跳过前面的检查,直接从第520行开始执行,如下图:
首先将read和write的6个字段都初始化为_IO_buf_base,之后调用_IO_SYSREAD函数尝试从fp中读_IO_buf_end - _IO_buf_base即4K大小的数据到从_IO_buf_base开始的空间中,该函数的原形为虚表中的__read,对应_IO_file_read函数。该函数在glibc/libio/fileops.c中定义如下:
该函数再往下就是调用__read()或__read_nocancel()直接进行系统调用进行读入,返回值为实际读取的大小,赋值给count。若正常读取成功,则会将_IO_read_end和offset字段增加count的大小,返回_IO_read_ptr的值。调用完该函数后,返回_IO_file_xsgetn函数,此时该文件中最多4K大小的数据已经被读入到了缓存缓冲区中,且_IO_read_base和_IO_read_end两个指针分别对应这想要读入的数据的起始位置和终止位置,相当于输入缓冲区,此时执行continue,重新开始循环,进入第1316行的判断语句,直接调用memcpy函数,将数据从输入缓冲区中拷贝如目标变量中,_IO_read_ptr字段加上相应大小,此时所有数据全部读入,want赋值为0,一路执行,最后跳出循环。 -
n > 4K
若读取数据大小大于4K,则不会进入第1341行的判断句,而是继续往下执行:
紧接着调用了_IO_setg和_IO_setp两个函数,这两个函数在glibc/libio/libioP.h中有宏定义如下:
即实现了将FILE结构体中的read和write相关共6个指针均初始化为_IO_buf_base的功能。随后在1357行的判断中,是将之前分配的缓冲区大小作为一个block_size,因为这条分支接下来会直接调用_IO_SYSREAD函数,将数据直接从文件流中读入变量中而不经过缓冲区,所以为了(优化性能?),每次只读取block_size大小的数据,即4K。调用完_IO_SYSREAD后want会减去4K再次进行循环,直到最后小于4K的一部分,会和上面n <= 4K经历相同的过程,并退出循环。
至此,分析完了fread函数主要流程,尤其是_IO_file_xsgetn函数的执行流程。fread函数主要进行了1个操作,调用_IO_file_xsgetn函数,当然加锁也是比较重要的。_IO_file_xsgetn函数,主要进行了3个操作:调用_IO_doallocbuf给FILE结构体分配缓冲区;当n <= block_size时,调用_IO_file_underflow将文件流中的数据读入缓冲区再调用memcpy从缓冲区拷贝至目标变量中;当n > block_size时,大部分先对齐到block_size,调用_IO_SYSREAD函数直接从文件流读入到目标变量,若还有剩余的数据再用老方从走缓冲区拷贝到目标变量中。
通过阅读源码,对FILE结构体以及其中各字段所代表的含义有了船新的认识:
- 首先是从_IO_read_ptr到_IO_buf_end这8个字段,原本认为是共申请了3个缓冲区,通过阅读源码后知道了只申请了1个缓冲区,其中_IO_buf_base和_IO_buf_end指向这个缓冲区的两端,其余6个read和write字段没事的时候都与_IO_buf_base的值相同,在进行读操作时,相当于3个read指针起作用控制缓冲区中的内容,可以推出在进行写操作时,3个read指针与_IO_buf_base的值相同,而3个write指针独起作用控制缓冲区中的内容。
- 其次_IO_save_base到_IO_save_end这3个字段,因为在该段代码中只存在几处判断,并没有实际用处,所以判断大概是为了保存某个时刻缓冲区而设置的指针。
- 最后是在调试时发现的FILE结构体有多种形态,比如我在调试时看到的FILE结构体实际上是glibc/libio/bits/libio.h中定义的_IO_FILE_complete结构体。最终究其原因,是因为有个宏判断把定义结构体的大括号给吃掉了。如下图:
fwrite
fwrite函数的一般用法为
fwrite(const void* buffer, size_t size, size_t count, FILE* stream);
与fread函数相似,该函数共有4个参数,buffer代表存储要写入文件数据的首地址,size代表每个对象的大小,count代表对象的个数,stream代表文件流。即该函数实现了将buffer中size * count字节数据写入stream文件流的操作。该函数在glibc/libio/iofwrite.c中有如下宏定义:
weak_alias(_IO_fwrite , fwrite)
即fwrite函数原形为_IO_fwrite函数,该函数在同一文件中定义如下:
也是一样的调用了CHECK_FILE函数进行检查,以及在调用关键函数前后加锁与去锁。在第39行调用了函数_IO_sputn,该函数在经过一系列定义和宏定义后为虚表中的__xsputn,即_IO_new_file_xsputn函数,该函数定义在glibc/libio/fileops.c中,是fwrite函数的关键。
该函数中有3个比较重要的变量,分别是s表示放有待写入数据变量的首地址,to_do表示还需要写入的字节数,must_flush表示是否需要刷新缓冲区。接下来也是按照刚从fopen初始化完的状态开始分析。此时各字段均为空,也没有_IO_LINE_BUF、_IO_CURRENTLY_PUTTING属性,所以不会进入第1233、1250行的判断句,所以count变量没有被赋值仍然为0,也不会进入第1254行的判断语句。而to_do代表的需要写入的字节数没有变,因此会直接进入第1262行的判断语句,如下图:
进入语句后,声明了两个变量block_size和do_write,之后直接调用了_IO_OVERFLOW函数,该函数为虚表中的__overflow,即_IO_new_file_overflow函数,该函数在glibc/libio/fileops.c中定义如下:
该函数首先判断文件是否含有_IO_NO_WRITES属性,即在fopen操作时是否为r只读选项,若是,则不会执行该函数直接返回。接着判断是否不含_IO_CURRENTLY_PUTTING属性或者_IO_write_base字段为空,其中_IO_CURRENTLY_PUTTING属性在该函数的第785行会进行赋值,因此该语句为判断没有正常执行过_IO_new_file_overflow函数或执行过但没有分配缓冲区的情况,会调用_IO_doallocbuf函数分配缓冲区,之后调用_IO_setg将与read相关的3个字段都赋值为_IO_buf_base,之后也会进行_IO_in_backup的检测,这几步操作在上一节_IO_new_file_underflow函数中有过详细描述,因此不再赘述。
紧接着将read和write共6个字段都赋值为_IO_buf_base,并给文件流加上_IO_CURRENTLY_PUTTING属性。由于在_IO_new_file_xsputn调用该函数时的第二个参数,即函数中的ch变量为EOF,因此,会在第790行调用_IO_do_write函数并返回,传入的3个参数分别为FILE结构体指针、_IO_write_base以及_IO_write_ptr - _IO_write_base = 0。该函数在glibc/libio/fileops.c中有宏定义为_IO_new_do_write函数,其定义如下:
可以看到_IO_new_do_write函数中,因为本次传入的第3个参数to_do为0,因此不会进行任何操作直接返回0,而不会去执行new_do_write函数。返回后回到_IO_new_file_xsputn的第1266行,不等于EOF,于是会继续执行。给block_size赋值为申请的空间大小,即4K,do_write代表通过调用new_do_write函数进行写入的数据大小,该数值是与block_size进行对齐的。接下来也是根据to_do与block_size的大小,函数将分成不同的流程。
-
to_do < 4K
若要写入的数据小于4K,则do_write = 0,不会进入第1275行的判断语句,而是在1287行直接调用_IO_default_xsputn函数进行处理,该函数在glibc/libio/genops.c中定义如下:
该函数主要实现了将数据从变量中拷贝到结构体缓冲区中的操作,主要有两种情况,一种是当要拷贝的数据大于20字节时,会直接调用__mempcpy进行拷贝,如果小于等于20,则用for循环逐字节进行拷贝,若缓冲区大小不够,则会调用_IO_OVERFLOW刷新缓冲区。
值得注意的是,_IO_default_xsputn函数仅仅实现了将数据从变量中拷贝到了从_IO_write_base到_IO_write_ptr为止的缓冲区中,并没有写入文件。 -
to_do >= 4K
若要写入的数据大于4K,则do_write被赋值为4K的倍数,将在第1277行调用new_do_write函数进行写入,该函数在glibc/libio/fileops.c中定义如下:
首先会判断是否具有_IO_IS_APPENDING属性,即判断该文件是由a属性打开还是由w属性打开:若为w属性,则将_offset字段赋值为-1;若为a属性,则_offset字段不变,这正是写文件时覆盖和追加方式的体现。之后判断_IO_read_end与_IO_write_base相等,若不等则调用_IO_SYSSEEK函数。对于本次调用的流程,这两个判断语句不会有太大的影响。主要是在函数的第457行执行了_IO_SYSWRITE函数,该函数为虚表中的__write,即为_IO_new_file_write函数,该函数在glibc/libio/fileops.c中定义如下:
该函数也是通过调用__write函数或__write_nocancel函数进行系统调用,将to_do长度的数据从data中写入文件中。调用完_IO_SYSWRITE函数后,若读入成功,即count不为0,在之后会调用_IO_adjust_column函数去更新FILE结构体中的_cur_column字段,该字段代表(文件的行数+1),该函数在glibc/libio/genops.c中定义如下:
该函数通过判断上面通过_IO_SYSWRITE函数写入的数据中含有多少\n字符来确定文件中增加了多少行。调用完_IO_adjust_column后返回到new_do_write函数,之后会调用_IO_setg函数以及各种赋值操作将文件流中read、write相关6个字段都赋值为_IO_buf_base,类似于初始化的操作。综上,new_do_write函数完成了将对齐block_size大小的数据不通过缓冲区,直接从变量写入文件流的操作。从new_do_write函数返回后,之前未对齐的剩余的数据,则会在下方调用_IO_default_xsputn函数拷贝到缓冲区中。
至此,分析完了fwrite函数主要流程,尤其是_IO_new_file_xsputn函数的执行流程。fwrite函数与fread相互对仗,fread是将文件中数据直接读入变量,或先从文件读入FILE结构体缓冲区,再利用read相关指针进行间接读入变量;fwrite是将数据直接从文件中写入变量或写入FILE结构体中的缓冲区。_IO_new_file_xsputn作为实现fwrite的重要函数,主要进行了****个操作:调用_IO_new_file_overflow函数为FILE结构体申请缓冲区;若n < block_size时,直接调用__mempcpy函数拷贝到缓冲区中;若n >= block_size时,会将对齐到block_size的部分用系统调用直接读入文件,剩余部分按与n < block_size相同的方法拷贝到缓冲区中。
通过阅读源码,对fwrite函数有了船新认识:
- 本以为fwrite只是把fread的读操作变成写操作,其他都是相同的。然而fread执行完后不论多少数据都读入了变量中,而fwrite执行完后未对齐block_size大小的数据仍在缓冲区中,推测在执行fclose函数或在程序退出时才会真正的写入文件中。
- _IO_new_file_overflow函数并不仅仅有上文中描述的调用_IO_doallocbuf申请缓冲区的作用,其主要担负着刷新缓冲区的作用:第一种调用情况为上文中所提到的,当_IO_buf_base字段为空,即还未初始化缓冲区时,则会调用_IO_doallocbuf函数进行申请缓冲区;若缓冲区已经初始化,且_IO_write_end == _IO_write_ptr,即缓冲区已满时,则会把这些待写入内容写入文件,之后会将_IO_write_ptr赋值为_IO_buf_base,相当于清空缓冲区的操作。
fclose
与fopen函数相同,在glibc/include/stdio.h中有如下宏定义:
#define fclose(fp) _IO_new_fclose(fp)
即fclose函数的原形为_IO_new_fclose函数,该函数在glibc/libio/iofclose.c中定义如下:
该函数主要是fopen函数的逆过程,首先在判断文件流是否含有_IO_file_flags和_IO_SI_FILEBUF后,函数会执行_IO_un_link函数,该函数在glibc/libio/genops.c定义如下:
该函数是_IO_link_in函数的逆过程,主要实现了将文件流从_IO_list_all链表中卸下,以及一些对结构体中字段的善后操作。接着调用了_IO_file_close_it函数,该函数在glibc/libio/fileops.c定义如下:
该函数在第134行判断是否为写属性的文件流,以及是否进行过写操作,若有,则会调用_IO_do_flush函数,该函数在glibc/libio/libioP.h中有宏定义如下:
可以看到该函数直接是针对文件流的整个缓冲区去调用了_IO_do_write函数,即实现了将仍存在于缓冲区中的数据写入文件的操作。之后调用_IO_SYSCLOSE函数,该函数对应虚表中的__close,即_IO_file_close函数,该函数在glibc/libio/fileops.c中定义如下:
该函数直接调用了__close_nocancel函数去执行系统调用对文件进行关闭。返回到_IO_new_file_close_it之后紧接着调用_IO_setb、_IO_setg、_IO_setp等函数将文件流中所有read和write字段置0,并在第159-161行将_flags、_fileno_offset修改为一个关闭状态的属性。返回到_IO_new_fclose函数后,主要去执行了_IO_FINISH函数,该函数为虚表中的__finish,即对应_IO_new_file_finish函数,该函数在glibc/libio/fileops.c中定义如下:
该函数一次调用了_IO_do_flush、_IO_SYSCLOSE以及_IO_default_finish函数,其中_IO_default_finish函数在glibc/libio/genops.c中定义如下:
可以看到_IO_new_file_finish中调用的几个函数,均在前面正常关闭的流程中有过调用,所以基本上都不会去执行。
至此,完成了对fclose函数流程的分析,总的来说代码比较短暂,也都是对前面已经执行代码的一个逆过程,因此并没有太多需要注意的地方,主要还是印证了前面在fwrite结尾的一个预测,会在关闭文件流时去调用了_IO_do_flush函数将缓冲区内的数据写入文件。
0x03 利用方法
- 利用伪造stdout进行任意地址读
stdout,即标准输出,默认为当前终端,其本质也为一个FILE结构体,利用这种方法最终达到将任意地址数据输出到终端供我们进行读的效果。因此首先定位到fwrite函数中输出的位置,即调用_IO_SYSWRITE的地方。由前小节的分析可知,只有在new_do_write函数中有对_IO_SYSWRITE的调用,而调用new_do_write理论上有两个地方,一个是在_IO_new_file_xsputn函数中的第1266行调用_IO_OVERFLOW函数中会有调用,以及在第1277行直接进行调用。
第一种调用情况,在利用_IO_OVERFLOW函数通过调用_IO_do_write函数,实现间接调用new_do_write函数将_IO_write_base到_IO_write_ptr之间的数据进行写入。
首先我们需要获得执行到_IO_OVERFLOW函数的条件,主要还是第1262行的to_do + must_flush > 0,因为正常函数调用时传进来的to_do均大于0,因此必然会进入该语句,所以前置条件几乎算是没有;
接下来进入_IO_OVERFLOW函数内部进行分析调用new_do_write函数的条件。
可以看到在我们最终调用_IO_do_write函数之前会有2个比较关键的判断:第747行判断是否有写权限,若没有则会报错并返回,因此搜集到的第一个必要条件为:f->_flags & _IO_NO_WRITES == 0
;接下来一个重要判断是在第754行的判断,经过上一节的分析我们知道这个判断是用来判断缓冲区是否初始化的,若未初始化则会进入该判断语句,由于在该判断分支中最后会将FILE结构体中的write相关指针进行重新赋值,从而破坏了我们事先构造好的write指针,所以这个判断也不能进入,因此搜集到的第二个必要条件为f->_flags & _IO_CURRENTLY_PUTTING != 0
。
之后进入_IO_do_write函数,这个函数比较简单,只要满足f->_IO_write_ptr - f->_IO_write_base != 0
便可调用new_do_write函数;最后在new_do_write函数中,只要满足fp->_IO_read_end == fp->_IO_write_base
即可调用_IO_SYSWRITE将_IO_write_base到_IO_write_ptr之间的数据输出。
第二种调用情况,是在第1277行直接调用new_do_write函数,因为此处第二个参数,即输出的数据段起始地址为外部传入参数,而不能通过伪造FILE结构体来进行控制,因此不能实现任意地址读的功能,直接PASS掉。
综上,我们只要伪造满足这4个条件的stdout结构体就能够实现任意地址读,其中第1、2个条件为文件流不能具有_IO_NO_WRITES(0x8)属性,且具有_IO_CURRENTLY_PUTTING(0x800)属性,而且_flags位自带一个_IO_MAGIC(0xfbad0000),因此构造的_flags为0xfbad0800。第3、4个条件就和read、write指针息息相关了,根据条件只要构造_IO_read_end = _IO_write_base = (想要leak的起始地址),_IO_write_ptr = (想要leak的结束地址),其他3个没有提到的指针置0就可以了。
因此伪造的fake_FILE结构体大概长这样(一般用got表进行libc的leak):
在我们最终伪造好这个FILE结构体后再去调用针对stdout进行输出的函数就可以实现任意地址读了,常见的函数一般有printf、fwrite、puts等。
- 利用伪造stdout进行任意地址写
在_IO_new_file_xsputn函数中,不仅调用了_IO_SYSWRITE进行了输出,而且也有调用__mempcpy函数将要输出的数据写入缓冲区的操作,该操作在函数中的第1258行,如下图:
根据调用的参数,可通过伪造FILE结构体达到将s中数据向我们可控指针f->_IO_write_ptr中写的操作,即不可控制内容的任意地址写。可以看到执行该语句的最主要判断为count > 0,而count变量在上方有多次赋值操作,对于已经控制了FILE结构体的我们来说轻而易举,但在执行之前会与to_do变量进行比较,取较小的长度进行写入,其中to_do变量为调用时想要写入的长度,一般为字符串的长度。如,puts("12345678")
在调用该函数时,to_do的值就为8,s变量就是指向12345678这个字符串的开头。
综上,我们只需要构造如下的fake_FILE就能够实现将字符串写到指定内存的操作。
虽然看起来不能实现任意地址写任意数据,但若是输出我们输入的值时,则可实现任意地址写任意数据;就算输出的都是固定字符串,能够将一些判断标志改为其他值,而改变程序正常执行流程有时还是很有用的。
- 利用伪造stdin进行任意地址写
stdin,即标准输出,默认为键盘,其本质也为一个FILE结构体,利用这种方法最终达到由我们从键盘输入的数据写到任意地址的效果。同样,首先我们需要定位到fread函数中获取输入的位置,即调用_IO_SYSREAD函数的地方。由前小节分析可知仅有在_IO_UNDERFLOW函数中会调用_IO_SYSREAD函数进行写入, 程序在_IO_file_xsgetn函数中是通过第1344行调用__underflow函数来间接调用该函数,如下图:
与利用stdout进行任意地址读有所不同的是,并不是每次执行都会顺理成章地去调用_IO_UNDERFLOW函数,而会有很多外部的限制条件。首先我们可以看到在调用__underflow函数之间就有了许多的限制。从调用处开始往上看,首先最息息相关的两个条件判断支,第一个为第1341行的判断要求fp->_IO_buf_base != NULL && want < fp->_IO_buf_end - fp->_IO_buf_base
,其中want变量的值为传入的参数n,即想要读入的字节数;第二个条件为不满足第1316行的判断语句,即want > fp->_IO_read_end - fp->_IO_read_ptr
。其实除了这2个判断语句外,在第1302行的判断也不能为真,因为进入该分支后会调用_IO_doallocbuf函数重新分配缓冲区,会破坏我们事先构造好的FILE结构体,但由于该判断句的条件包含于1341行的判断句,所以不单独算一个条件。满足这两个条件后,程序正常执行到了__underflow函数,如下图:
可以看到我们最终要调用的_IO_UNDERFLOW函数在该函数的最终结尾处,因此要小心的过掉上面的各个分支。通过观察,大部分分支在正常情况下都不会进入,需要注意的是一个在第296行的if语句不能满足,因此第3个条件为fp->_IO_read_ptr >= fp->_IO_read_end
,接下来进入_IO_UNDERFLOW函数内部,即_IO_new_file_underflow函数,如下图:
在_IO_new_file_underflow函数的第531行调用了_IO_SYSREAD函数的调用,其上方仍有许多if分支,看似特别多,其实只有第478行的影响比较大,即第4个条件为fp->_flags & _IO_NO_READS == 0
。之后第484、487行的分支虽然都不能进入,但这些判断条件在调用前已经囊括了,因此不做重复搜集;第500行的条件分支也没有进去的必要,之后就能够顺利地调用_IO_SYSREAD函数,实现向fp->_IO_buf_base地址写入从键盘输入_IO_buf_base - _IO_buf_end长度数据的功能。
综上,我们只要伪造满足这4个条件的stdin结构体就能够实现任意地址写,其中第1个条件与调用时本来要写入的数据长度有关,我们要伪造的写入大小应该比其本要写入的大小更大。比如,在调用read(0 , buf , 0x10)函数时,我们构造的_IO_buf_end - _IO_buf_base就应该大于0x10;第2、3个条件综合起来可构造_IO_read_ptr == fp->_IO_read_end来同时满足,也不知道给个什么值,就默认为0吧;第4个就是_flags属性的问题了,不包含_IO_NO_READS。
因此伪造的fake_FILE结构体大概长这样:
在我们最终伪造好这个FILE结构体后再去调用针对stdin进行输入的函数就可以实现任意地址写了,常见的函数一般有scanf、fread、gets、fgets等。
修改stdin->_IO_buf_end而导致的堆溢出
再次回到执行fread函数过程中在_IO_new_file_underflow中调用_IO_SYSREAD将文件中的数据读入缓冲区的地方,即_IO_new_file_underflow的第531行,此时调用如下:
_IO_SYSREAD(fp, fp->_IO_buf_base, fp->_IO_buf_end - fp->_IO_buf_base)
通过上文的分析可知,_IO_buf_base指向所申请的堆的头部,_IO_buf_end指向所申请的堆的末尾,在执行fread过程中当文件大小 <= 缓冲区大小时(这个大小通常是4K),会执行到这一步,将文件中数据全部读入缓冲区中。但,正如这个调用语句所示,此处的缓冲区大小是由_IO_buf_end - _IO_buf_base所决定。
有这样一种场景,在申请过缓冲区后,_IO_buf_base指向缓冲区的头部,而通过某些手段,我们能够修改_IO_buf_end为别的更大的值,在执行相应函数时则会造成堆溢出。而这种手段,我们可以在有unsortbin attack的条件下,很轻松地将_IO_buf_end修改为main_arena上的很大的值,那么我们就可以构造这样一个文件或输入,一直覆盖到main_arena之前的free_hook等敏感指针。劫持vable
对FILE结构体的虚表的利用,早在libc-2.23版本时就有了FSOP之类的伪造vtable劫持控制流的利用方法等,但随着版本的更新,从2.24版本开始,对调用vtable的合法性检查也开始进行了check,这就导致了前版本中直接更改vtable的利用方法变得不可行。同样,在本文的2.27版本中,在调用vtable之前也是有这一定的check机制。下面我们就分析vtable的check机制,以及利用方法。
首先我们来定位vtable调用的地方,从前小节的分析中,可以找到许多调用的地方,比如_IO_new_file_xsputn函数第1266行调用的_IO_OVERFLOW函数就是vtable的入口。追随_IO_OVERFLOW的脚步,可以在glibc/libio/libioP.h中找到如下宏定义:
#define _IO_OVERFLOW(FP, CH) JUMP1(__overflow, FP, CH)
......
#define JUMP1(FUNC, THIS, X1) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1)
......
#define _IO_JUMPS_FUNC(THIS) (IO_validate_vtable (_IO_JUMPS_FILE_plus (THIS)))
通过多个宏定义,我们可以发现,在调用vtable所代表的函数之前,首先调用了IO_validate_vtable函数,该函数在glibc/libio/libioP.h中定义如下:
该函数首先判断了调用的vtable函数地址是否在__start___libc_IO_vtable与__stop___libc_IO_vtable之间,若在此之间,则说明是libc中的合法vtable地址。若不在这个区间,则会调用第876行的_IO_vtable_check函数进行进一步的检查。该函数在glibc/libio/vtable.c中定义如下:
由于存在预编译头,且SHARE、PTR_DEMANGLE是有定义的,因此会执行上面两个部分的检查。第一部分为判断引用的虚表指针是否为默认命名空间外重构的虚表指针,其中atomic_load_relaxed函数为获得加载指针的当前值,PTR_DEMANGLE函数则是类似canary之类的一个保护虚表不被修改的函数;第二部分则是检查引用的虚表指针是否为动态链接库中加载的函数。但我们在2.23版本中通常将堆或栈上的一块区域用来伪造为FILE结构体,同样vtable也就接在这个fake_FILE结构体的后面,所以上面的3个条件都不会满足。因此,在新版本中用曾经的利用方法最终会执行到_IO_vtable_check函数的第72行,报错并结束进程。
上面介绍完了新版本中加入的3个对vtable的check机制,下面讲讲大神们是如何绕过check并再次实现利用的。
由于_IO_vtable_check函数中的第一个检查,有PTR_DEMANGKE函数的存在,几乎时不能够伪造对应的条件;而第二个检查,若能够伪造,则可以选择其他更方便的利用方法,而不用继续在vtable的利用上死磕了,因此这两个检查在正常情况下难以绕过。于是在调用_IO_vtable_check函数前的检查则成了关键,即调用的虚表指针必须在__start___libc_IO_vtable与__stop___libc_IO_vtable之间。然后大佬们就找到了这样一组内部虚表_IO_str_jumps/_IO_wstr_jumps与_IO_file_jumps/_IO_wfile_jumps相对应,但其中的函数都换成了另外一组,如下图:
并且发现其中的__finish对应的函数大有可为,_IO_str_finish函数在glibc/libio/strops.c中定义如下:
可以看到,当满足条件时,会将结构体中的_s._free_buffer作为函数指针,将_IO_buf_base作为参数进行执行,因此我们可以试着利用原来FSOP的利用方法,来构造一个满足条件的fake_FILE。
FSOP方法在之前的文章中有过介绍,在此只做简单描述。该方法的精髓为当程序从main函数返回或调用exit函数或libc进行abort操作时,会调用_IO_flush_all_lockp函数去遍历_IO_list_all链表中的每一个FILE结构体,而当FILE结构体满足(_mode <= 0) && (_IO_write_ptr > _IO_write_base)时,则会调用_IO_OVERFLOW函数,即vtable指针+ 0x18位置的函数。
因此,对应到此处,想要成功调用_IO_str_finish函数,则需要在FSOP的基础上将的vtable改为_IO_str_jumps - 8
,此为调用函数前的利用条件。在进入函数后,首先要满足判断条件_IO_buf_base != NULL
以及_flags & _IO_USER_BUF == 0
,最后是利用条件_s._free_buffer == system_addr || _IO_buf_base == "/bin/sh\x00"
或_s._free_buffer == one_gadget
。其中_IO_strfile结构体在glibc/libio/strfile.h中定义如下:
综上,我们想要需要构造如下的fake_FILE结构体来利用FSOP方法来绕过新版本中对vtable的check,从而达到利用的目的。