落雨宸的时光机
2249 字
11 分钟
CTF PWN IO_FILE 攻击总结
2026-02-24
...

1. FILE 结构#

Pwn-File structure note

下面给出 stdin/stdout/stderr 的定义,它们都是一个 FILE 结构体:

// https://elixir.bootlin.com/glibc/glibc-2.31/source/libio/stdio.c#L33
FILE *stdin = (FILE *) &_IO_2_1_stdin_;
FILE *stdout = (FILE *) &_IO_2_1_stdout_;
FILE *stderr = (FILE *) &_IO_2_1_stderr_;

// https://elixir.bootlin.com/glibc/glibc-2.31/source/libio/libio.h#L149
extern struct _IO_FILE_plus _IO_2_1_stdin_;
extern struct _IO_FILE_plus _IO_2_1_stdout_;
extern struct _IO_FILE_plus _IO_2_1_stderr_;

::: tip 等等,怎么有两个不同的定义?stdin/stdout/stderr 究竟是 FILE 结构体,还是 _IO_FILE_plus 结构体?

实际上,在 glibc 的内部实现中,这三个流确实是 struct _IO_FILE_plus。但是由于 FILE 位于 _IO_FILE_plus 结构体的起始位置,_IO_FILE_plus 结构体的地址和它内部 FILE 成员的地址在数值上是完全一样的。因此,glibc 可以安全地将 &IO_2_1_stdin 强制转换为 FILE *。

虽然 stdin/stdout/stderr 拿到的是一个 FILE * 指针,看起来好像只能访问 FILE 内部的成员(如缓冲区指针、标志位等),但实际上:在内存里,FILE 成员的后面紧跟着一个 vtable 指针。当你调用 fprintf(stdout, …) 或 fwrite(…) 时,glibc 内部会将这个 FILE * 重新转换回内部使用的结构体类型,并跳转到 vtable 指向的函数实现(如 _IO_file_write)。

这种设计类似于 C++ 的类继承:FILE (struct _IO_FILE):相当于“基类”,包含了基础的数据成员。_IO_FILE_plus:相当于“派生类”,它在基类的基础上增加了虚函数表 (vtable)。

封装性:对用户屏蔽了底层的虚函数表实现。用户只需要知道 FILE * 怎么用,不需要关心底层的 I/O 是如何跳转的。多态性:不同的文件类型(普通文件、字符串流 sscanf、终端)可以共享相同的 FILE 结构前端,但通过替换 vtable 来实现不同的读写行为。 :::

::: warning 这种 vtable 机制在过去曾是 Linux 提权攻击的一个重灾区(后面会讲到的 FSOP, File Stream Oriented Programming),攻击者通过伪造 vtable 来控制程序执行流。因此,现代 glibc 对 vtable 做了很多安全校验。 :::

下面给出 _IO_FILE_plus 和 FILE 的定义:

// https://elixir.bootlin.com/glibc/glibc-2.31/source/libio/libioP.h#L324
struct _IO_FILE_plus
{
  FILE file;
  const struct _IO_jump_t *vtable;
};

// https://elixir.bootlin.com/glibc/glibc-2.31/source/libio/bits/types/FILE.h#L7
typedef struct _IO_FILE FILE;

// https://elixir.bootlin.com/glibc/glibc-2.31/source/libio/bits/types/struct_FILE.h#L49
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 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; //文件描述符
  int _flags2;
  __off_t _old_offset; /* This used to be _offset but it's too small.  */

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

  _IO_lock_t *_lock; //同步用
#ifdef _IO_USE_OLD_IO_FILE
};

// 可以稍微了解一下 _flags 的定义
// https://elixir.bootlin.com/glibc/glibc-2.31/source/libio/libio.h#L67
/* 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

下面给出 vtable 的定义。当要寻找对应函数的时候,就会来这里查表:

// https://elixir.bootlin.com/glibc/glibc-2.31/source/libio/libioP.h#L293
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);
};

2. FILE 结构体相关的操作函数#

2.1. fopen#

extern FILE *fopen (const char *__restrict __filename,
		    const char *__restrict __modes) __wur;

FILE *
_IO_new_fopen (const char *filename, const char *mode)
{
  return __fopen_internal (filename, mode, 1);
}

FILE *
__fopen_internal (const char *filename, const char *mode, int is32)
{
  struct locked_FILE
  {
    struct _IO_FILE_plus fp;
#ifdef _IO_MTSAFE_IO
    _IO_lock_t lock;
#endif
    struct _IO_wide_data wd;
  } *new_f = (struct locked_FILE *) malloc (sizeof (struct locked_FILE));

  if (new_f == NULL)
    return NULL;
#ifdef _IO_MTSAFE_IO
  new_f->fp.file._lock = &new_f->lock;
#endif
  _IO_no_init (&new_f->fp.file, 0, 0, &new_f->wd, &_IO_wfile_jumps);
  _IO_JUMPS (&new_f->fp) = &_IO_file_jumps;
  _IO_new_file_init_internal (&new_f->fp);
  if (_IO_file_fopen ((FILE *) new_f, filename, mode, is32) != NULL)
    return __fopen_maybe_mmap (&new_f->fp.file);

  _IO_un_link (&new_f->fp);
  free (new_f);
  return NULL;
}

大致流程如下:

  • malloc 分配 FILE + vtable + lock 空間
  • 初始化 FILE,將 vtable 設置為 glibc 中已經寫好的各個函數
  • 將 FILE 鏈入 _IO_list_all
  • 系統調用 open 打開文件,並設置文件描述符號

2.2. fread#

extern size_t fread (void *__restrict __ptr, size_t __size,
		     size_t __n, FILE *__restrict __stream) __wur;

size_t
_IO_fread (void *buf, size_t size, size_t count, FILE *fp)
{
  size_t bytes_requested = size * count;
  size_t bytes_read;
  CHECK_FILE (fp, 0);
  if (bytes_requested == 0)
    return 0;
  _IO_acquire_lock (fp);
  bytes_read = _IO_sgetn (fp, (char *) buf, bytes_requested);
  _IO_release_lock (fp);
  return bytes_requested == bytes_read ? count : bytes_read / size;
}

總結

  1. 調用 vtable 中的 _IO_XSGETN (_IO_file_xsgetn)。
  2. 如果沒有 buffer,則調用 vtable 中的 _IO_DOALLOCATE (_IO_file_doallocate) 進行分配。
  3. 調用 vtable 中的 _IO_SYSSTAT (_IO_file_stat) 查看文件資訊,設置 buffer 大小。
  4. 使用 malloc 分配一塊區域作為 buffer。
  5. 開始讀取 buffer

2.3. fwrite#

size_t
_IO_fwrite (const void *buf, size_t size, size_t count, FILE *fp)
{
  size_t request = size * count;
  size_t written = 0;
  CHECK_FILE (fp, 0);
  if (request == 0)
    return 0;
  _IO_acquire_lock (fp);
  if (_IO_vtable_offset (fp) != 0 || _IO_fwide (fp, -1) == -1)
    written = _IO_sputn (fp, (const char *) buf, request);
  _IO_release_lock (fp);
  /* We have written all of the input in case the return value indicates
     this or EOF is returned.  The latter is a special case where we
     simply did not manage to flush the buffer.  But the data is in the
     buffer and therefore written as far as fwrite is concerned.  */
  if (written == request || written == EOF)
    return count;
  else
    return written / size;
}
libc_hidden_def (_IO_fwrite)

  1. n <= 0: 當data n 小於等於 0 時,程式直接返回 0,不會進行任何操作。

  2. data n 小於等於 buffer剩餘空間 (count)

  • buffer剩餘空間 (count): 由 f->_IO_write_end - f->_IO_write_ptr 決定buffer剩餘空間大小。
  • 當data小於等於剩餘的buffer空間時,程式將資料直接寫入buffer,沒有超出buffer的部分,因此不需要進行syscall write或flush。
  • 程式執行流程:
  • 計算剩餘的buffer空間 count。
  • 將資料完全寫入buffer (__mempcpy 複製資料到 f->_IO_write_ptr)。
  • 更新寫指針 f->_IO_write_ptr。
  • 返回 n,表示成功寫入的資料大小。
  1. data n 大於 buffer剩餘空間 (count)
  • 當data超過buffer剩餘空間時,程式會首先嘗試填滿buffer,然後將剩餘的部分進行syscall write。
  • 步驟:
    • 計算剩餘的buffer空間 count,將 count 字節的資料寫入buffer。
    • 如果資料剩餘 (to_do > 0),會進行buffer刷新(即調用 _IO_OVERFLOW),並將資料寫入底層文件。
    • 嘗試寫入剩餘資料,並根據文件的塊對齊要求優化寫入(維持整塊大小的寫入以提高效能)。
    • 剩餘資料由 _IO_default_xsputn 處理,進行最後的寫入操作。
    • 返回總共寫入的資料大小。
  1. data n 小於等於 buffer剩餘空間,但啟用行緩衝模式 (_IO_LINE_BUF)
  • 行緩衝模式: 如果啟用行緩衝模式且當前正在寫入 (_IO_CURRENTLY_PUTTING),程式會檢查資料中是否包含換行符 (‘\n’)。
  • 步驟:
    • 計算buffer剩餘空間 count。
    • 如果data小於等於剩餘空間,則檢查資料中是否包含換行符。
    • 如果遇到換行符,設置 must_flush = 1,表示需要立即刷新buffer。
    • 將資料寫入buffer直到換行符,然後執行刷新操作。
    • 剩餘資料寫入後,返回成功寫入的資料大小。
  1. data n 大於 buffer剩餘空間,啟用行緩衝模式
  • 步驟:
    • 先將部分資料寫入buffer直到滿。
    • 當buffer空間不足時,觸發刷新,並嘗試將資料直接寫入文件。
    • 檢查剩餘資料中是否有換行符,遇到換行符時觸發buffer刷新。
    • 將剩餘資料按塊大小(block_size)對齊寫入。
    • 返回總共寫入的資料大小。
  1. data n 超過 block_size
  • block_size = f->_IO_buf_end - f->_IO_buf_base,這個大小會決定每次可以寫入的最大數據量。
  • 當data超過buffer塊大小時,程式會分成多個塊進行寫入,以保持區塊對齊,並優化 I/O 操作。
  • 步驟:
    • 先將部分資料寫入buffer。
    • buffer滿後,進行buffer刷新。
    • 將剩餘資料按照塊大小進行對齊寫入(即寫入一整數塊的資料,保證效率)。
    • 最後寫入剩下的資料,並更新寫指針。
    • 返回寫入的總資料大小。

2. 常见用法#

setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
/*
 * 0 (即 NULL):不使用自定义的缓冲区,让系统处理。
 * 2 (即 _IONBF):No Buffering(无缓冲)。
 * 0:缓冲区的大小。既然设置了无缓冲,大小自然就是 0。
*/

有这两行,是设定 stdin/stdout 无缓冲区(具体实现方法:缓冲区为 1 字节)。

PWN 常见。用来确保在不稳定的网络环境下,stdin/stdout 能够第一时间发送。

在 IO_FILE 攻击中更是好:这意味着程序一启动,就能令 _IO_read_ptr == _IO_read_end

直接修改 _IO_buf_base,再消耗掉发送的字节就行。不需要像默认情况一样,要发送 4096 个字节填满缓冲区才能使得修改的 _IO_buf_base 生效。

CTF PWN IO_FILE 攻击总结
https://blog.lzc256.com/posts/ctf-pwn-io-file/
作者
落雨宸
发布于
2026-02-24
许可协议
CC BY-NC-SA 4.0


Loading Comment Component...