FILE介绍

FILE是 C 标准 IO 库中用于表示文件流的类型。调用fopen()等函数时,库会创建一个与该流对应的FILE对象,并返回其指针,程序通常使用FILE*来访问和操作这个流。

结构体定义

glibc-2.26版本中FILE定义如下

struct _IO_FILE {
  int _flags;        /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags

  /* The following pointers correspond to the C++ streambuf protocol. */
  /* Note:  Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
  char* _IO_read_ptr;    /* Current read pointer */
  char* _IO_read_end;    /* End of get area. */
  char* _IO_read_base;    /* Start of putback+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_marker *_markers;

  struct _IO_FILE *_chain;

  int _fileno;
#if 0
  int _blksize;
#else
  int _flags2;
#endif
  _IO_off_t _old_offset; /* This used to be _offset but it's too small.  */

#define __HAVE_COLUMN /* temporary */
  /* 1+column number of pbase(); 0 is unknown. */
  unsigned short _cur_column;
  signed char _vtable_offset;
  char _shortbuf[1];

  /*  char* _save_gptr;  char* _save_egptr; */

  _IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

struct _IO_FILE_complete
{
  struct _IO_FILE _file;
#endif
#if defined _G_IO_IO_FILE_VERSION && _G_IO_IO_FILE_VERSION == 0x20001
  _IO_off64_t _offset;
# if defined _LIBC || defined _GLIBCPP_USE_WCHAR_T
  /* Wide character stream stuff.  */
  struct _IO_codecvt *_codecvt;
  struct _IO_wide_data *_wide_data;
  struct _IO_FILE *_freeres_list;
  void *_freeres_buf;
# else
  void *__pad1;
  void *__pad2;
  void *__pad3;
  void *__pad4;
# endif
  size_t __pad5;
  int _mode;
  /* Make sure we don't get into trouble again.  */
  char _unused2[15 * sizeof (int) - 4 * sizeof (void *) - sizeof (size_t)];
#endif
};

在glibc内部,标准 C 库的FILE实际上是一个不透明的指针,它在底层对应的是struct _IO_FILE_plus

  • struct _IO_FILE:基础结构体,包含缓冲区指针、状态位等。
  • struct _IO_FILE_plus:是对_IO_FILE的包装,它在_IO_FILE的末尾添加了一个指向虚函数表(vtable)的指针。这是劫持控制流的核心。
struct _IO_FILE_plus {
    _IO_FILE file;
    const struct _IO_jump_t *vtable; // 关键利用点
};

在CTF中,我们不必关心所有字段,只需关注那些能实现内存读写或控制流劫持的关键点。

核心字段

标志位与魔数:_flags
  • 偏移:0x0
  • 组成:高16位是魔数0xfbad0000(用于校验),低16位是状态位。

很多系统函数会校验(fp -> _flags & _IO_MAGIC_MASK == _IO_MAGIC)。 在任意地址读利用中,常将其修改为0xfbad1800(绕过检查并设置_IO_IS_APPENDING等标志位),从而强制触发缓冲区刷新。

三组缓冲区指针

glibc使用“基址 (base)”、”当前位置 (ptr)“和”结束位置 (end)“来管理内存。

指针类型字段名作用
输入_IO_read_ptr
_IO_read_end
_IO_read_base
管理从文件读取到内存的数据。当ptr < end时,getc直接从内存取值。
输出_IO_write_base
_IO_write_ptr
_IO_write_end
管理待写入文件的数据。当ptr > base时,调用fflush会将这段数据写入内核。
储备区_IO_buf_base
_IO_buf_end
这是整个I/O缓冲区的总边界。baseend的长度决定了缓冲区大小。

若要实现任意地址读,通常将_IO_write_base改为目标地址,_IO_write_ptr改为目标地址 + 长度。这样当程序调用stdout的相关函数时,会认为目标地址处的数据是“待刷新的缓冲区内容”而将其打印出来。

链表指针:_chain

偏移:0x68 (64位) 作用:所有的FILE结构体(stdin、stdout、stderr以及fopen打开的文件)都通过这个指针串联在一起,表头是_IO_list_all

House of Orange 就是通过溢出覆盖_IO_list_all,将其指向受控的堆内存。当程序因报错退出调用_IO_flush_all_lockp时,会遍历该链表,执行受控内存中的vtable

文件描述符:_fileno

偏移:0x70 作用:对应的系统文件句柄。

将其改为1,原本写入文件的内容会显示在屏幕上;将其改为0,原本写入文件的操作可能变成从屏幕读入(取决于具体逻辑)。

核心机制:vtable (虚函数表)

这是_IO_FILE_plus结构的精髓。它包含了一系列函数指针,处理open、read、write、close、seek等操作。

偏移:0xd8 (64位) 跳转逻辑:当调用fwrite(buf, size, nmemb, fp)时,内核实际上执行的是:fp->vtable->__xsputn(fp, buf, size * nmemb)

_IO_jump_t中的关键逻辑:

  • __overflow:缓冲区溢出时调用
  • __finish:关闭文件流时调用
  • __xsputn:像puts/fwrite这类输出函数最终会调用它
  • __str_finish:在_IO_str_jumps虚表中,这个位置常被用于劫持RIP到system

一个被劫持的_IO_FILE_plus通常长这样

[ 0x00 ] _flags          : 0xfbad1800 (或者 /bin/sh)
[ 0x08 ] _IO_read_ptr    : 0x0
...
[ 0x20 ] _IO_write_base  : 目标读取地址 (泄露地址时)
[ 0x28 ] _IO_write_ptr   : 目标读取地址 + 长度
...
[ 0x68 ] _chain          : 指向下一个 FILE 结构
[ 0x70 ] _fileno         : 1 (stdout)
...
[ 0xd8 ] vtable          : 指向伪造的 jump_t 表或 libc 中的已知虚表

结构体的生命流程

结构体的诞生:fopen

当调用FILE *fp = fopen("file.txt","r");时,底层发生了以下链式反应:

  • 分配空间:通过malloc在堆上分配一个struct _IO_FILE_plus的空间。
  • 初始化:设置_flags(根据模式如”r”,“w”设置)、_fileno(调用内核系统调用open获取)
  • 挂在虚表:将vtable指向_IO_file_jumps
  • 注册虚表:调用_IO_link_it将这个新结构体挂到全局的_IO_list_all链表上

数据的搬运工:fread/fwrite

这两个函数是缓冲区管理指针(_ptr_base_end)的主要操纵者

fread

  • 检查_IO_read_ptr是否小于_IO_read_end
  • 如果是,直接从缓冲区拷贝数据给用户,更新ptr
  • 如果缓冲区空了,调用vtable->__underflow触发系统调用读取数据填满缓冲区

fwrite

  • 将数据拷贝到_IO_write_ptr指向的缓冲区
  • 如果缓冲区满了,调用vtable->__overflow刷新到内核

结构体的销毁:fclose

fclose(fp);不仅仅是关闭文件描述符,它还包含复杂的清理工作

  • 刷新缓冲:调用vtable->__finish确保缓冲区里没写完的数据推送到内核
  • 卸载链表:从_IO_list_all中移除自己
  • 释放内存:调用free(fp)释放堆内存

在fclose过程中,会多次调用vtable中的函数指针,FSOP最常用的触发点就是这里

特殊阶段:系统崩溃/退出:exitabort

调用exit时,系统会调用_IO_flush_all_lockp顺着_IO_list_all进行遍历,把还没有写完的数据(缓冲区里的数据)全部写进硬盘中。

如果在退出前修改了vtable,就可以劫持程序流。