在日常的二进制漏洞利用过程中,最终在获取到任意地址读写之都会面临一个问题: 要从哪里读,写到哪里去。对于信息泄露有很多方法,比如寻找一些数据结构在内存中残留的地址,可以是内部结构,也可以是用户定义的结构;相比而言,内存写原语的使用场景就相对有限了。因为在当今的系统安全纵深防护之下,代码和数据已经呈现了严格分离的趋势,可读可写可执行的历史遗留问题会逐渐退出历史舞台。更多的利用方法是关注代码重用(ROP)、可修改的函数指针、面向对象引入的多态调用等等。
具体的利用技巧和目标对象有直接联系,本文介绍的是基于 Glibc 的运行场景,也是当前很多 CTF 赛题所面临的场景。犹记得上古年代堆漏洞利用是那么单纯,先泄露 libc、然后 free_hook
写 system
,然后 getshell。但随着 glibc 版本的更新,也逐渐引入了一些新的缓释措施,因此大家的目光就转向了其他的后利用链。其中 FILE 结构体虽然不是唯一的,但却是使用最多的,因此本文就来学习一下。
下文涉及源代码部分如无特殊说明均来自 glibc-2.35
我们平时往输入输出读写数据的时候,也许是直接使用 glibc 对系统调用的浅封装 open
、read
、write
,但更多时候也使用 fopen
、fread
、printf
等,而后者大多是 POSIX 标准的 IO 函数。这些函数主要围绕 FILE
结构体而操作,由 fopen
返回,也就是常说的 _IO_FILE
:
typedef struct _IO_FILE FILE;
该结构体的定义如下所示:
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;
// ...
};
作为一个管理 IO 流缓冲的数据结构,需要保存当前用户写入的内存,包括已经读出和写入的大小和剩余数量等,这些位置使用指针表示;当然也包括系统调用所需的文件句柄 _fileno
;另外 _flags
用于指定当前文件流的属性,比如是否可读可写等。
虽然我们平时也会使用 fopen
打开并读写文件,但更多的情况下并没有显式操作 FILE
,而是使用了标准库中定义的 stdin
、stdout
和 stderr
宏去进行读写标准输入输出。这些值其实也是 FILE
指针:
extern struct _IO_FILE_plus _IO_2_1_stdin_;
FILE *stdin = (FILE *) &_IO_2_1_stdin_;
FILE *stdout = (FILE *) &_IO_2_1_stdout_;
//...
以 stdin
为例,虽然其类型是 FILE *
,但实际类型却是 _IO_2_1_stdin_
的类型,即 _IO_FILE_plus
:
struct _IO_FILE_plus
{
FILE file;
const struct _IO_jump_t *vtable;
};
该结构第一个元素包含的也是 FILE
,因此可以进行强制类型转换。但末尾还多了个指针,一般这种模式常用于在 C 语言中实现面向对象(多态)调用,这个指针就作为虚函数表。该虚表的定义如下:
#define JUMP_FIELD(TYPE, NAME) TYPE NAME
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);
};
包含了针对文件 read、write、close、sync 等操作的函数指针,列举其中两个函数原型:
typedef ssize_t (*_IO_read_t) (FILE *, void *, ssize_t);
typedef ssize_t (*_IO_write_t) (FILE *, const void *, ssize_t);
上面介绍了 _IO_FILE
中的相关基础结构,接着看一下关于标准输入输出的定义,如下所示:
# define DEF_STDFILE(NAME, FD, CHAIN, FLAGS) \
static _IO_lock_t _IO_stdfile_##FD##_lock = _IO_lock_initializer; \
static struct _IO_wide_data _IO_wide_data_##FD \
= { ._wide_vtable = &_IO_wfile_jumps }; \
struct _IO_FILE_plus NAME \
= {FILEBUF_LITERAL(CHAIN, FLAGS, FD, &_IO_wide_data_##FD), \
&_IO_file_jumps};
DEF_STDFILE(_IO_2_1_stdin_, 0, 0, _IO_NO_WRITES);
DEF_STDFILE(_IO_2_1_stdout_, 1, &_IO_2_1_stdin_, _IO_NO_READS);
DEF_STDFILE(_IO_2_1_stderr_, 2, &_IO_2_1_stdout_, _IO_NO_READS+_IO_UNBUFFERED);
_IO_wide_data
是针对宽字符的虚函数表,这里先不关心,重点关注的是对 _IO_FILE_plus
结构体末尾虚函数表的定义,可以看到三者都被定义为了全局的 _IO_file_jumps
:
#define libio_vtable __attribute__ ((section ("__libc_IO_vtables")))
#define JUMP_INIT(NAME, VALUE) VALUE
#define JUMP_INIT_DUMMY JUMP_INIT(dummy, 0), JUMP_INIT (dummy2, 0)
const struct _IO_jump_t _IO_file_jumps libio_vtable =
{
JUMP_INIT_DUMMY,
JUMP_INIT(finish, _IO_file_finish),
JUMP_INIT(overflow, _IO_file_overflow),
JUMP_INIT(underflow, _IO_file_underflow),
JUMP_INIT(uflow, _IO_default_uflow),
JUMP_INIT(pbackfail, _IO_default_pbackfail),
JUMP_INIT(xsputn, _IO_file_xsputn),
JUMP_INIT(xsgetn, _IO_file_xsgetn),
JUMP_INIT(seekoff, _IO_new_file_seekoff),
JUMP_INIT(seekpos, _IO_default_seekpos),
JUMP_INIT(setbuf, _IO_new_file_setbuf),
JUMP_INIT(sync, _IO_new_file_sync),
JUMP_INIT(doallocate, _IO_file_doallocate),
JUMP_INIT(read, _IO_file_read),
JUMP_INIT(write, _IO_new_file_write),
JUMP_INIT(seek, _IO_file_seek),
JUMP_INIT(close, _IO_file_close),
JUMP_INIT(stat, _IO_file_stat),
JUMP_INIT(showmanyc, _IO_default_showmanyc),
JUMP_INIT(imbue, _IO_default_imbue)
};
libc_hidden_data_def (_IO_file_jumps)
并且,该全局虚函数表在 ELF 中使用单独的段 __libc_IO_vtables
进行标识:
root@pwnlab:/pwn$ rabin2 -S /usr/lib/x86_64-linux-gnu/libc.so.6 | grep __libc
16 0x001baac0 0x1a01 0x001baac0 0x1a01 -r-x __libc_freeres_fn
27 0x00214910 0xe8 0x00215910 0xe8 -rw- __libc_subfreeres
28 0x002149f8 0x8 0x002159f8 0x8 -rw- __libc_atexit
29 0x00214a00 0xd68 0x00215a00 0xd68 -rw- __libc_IO_vtables
开头介绍 FILE 数据结构的时候,有一个字段还没介绍,即 struct _IO_FILE *_chain
。从名字上也可以猜测这个字段用于实现链表结构。在上节介绍 stdin/out/err 定义的时候看到:
stdin->chain = 0
stdout->chain = &stdin
stderr->chain = &stdout
最后还定义了一个全局的头部 _IO_list_all
:
struct _IO_FILE_plus *_IO_list_all = &_IO_2_1_stderr_;
从链表顺序可见,stdin 在链表尾部,stderr 在头部。实际在使用 fopen
打开新的文件流时,会经过一系列调用:
_IO_new_fopen
__fopen_internal
_IO_new_file_init_internal
_IO_link_in
最终将新创建的 FILE 结构体插入上述链表的头部:
void _IO_link_in (struct _IO_FILE_plus *fp) {
if ((fp->file._flags & _IO_LINKED) == 0) {
fp->file._flags |= _IO_LINKED;
fp->file._chain = (FILE *) _IO_list_all;
_IO_list_all = fp;
}
}
所以说全局的 _IO_list_all
维护了当前 IO 库所打开的所有文件流。这是为了方便所有文件流进行迭代,比如在程序退出前需要清空缓冲区并执行关闭文件等操作,这在后文漏洞利用的过程中也会提到。
上面其实简要介绍了 fopen 的流程,文件读写的过程其实也类似,先看读文件的 fread,其调用链路为:
_IO_fread
_IO_sgetn
_IO_XSGETN
其中 _IO_XSGETN
是一个宏,其定义如下:
#define _IO_XSGETN(FP, DATA, N) JUMP2 (__xsgetn, FP, DATA, N)
#define JUMP0(FUNC, THIS) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS)
#define JUMP1(FUNC, THIS, X1) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1)
#define JUMP2(FUNC, THIS, X1, X2) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1, X2)
#define JUMP3(FUNC, THIS, X1,X2,X3) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1,X2, X3)
JUMPn
主要是跳转到 vtable
对应的字段获取动态函数地址,不同点主要在于参数个数。因此实际的文件读取流程都在 _IO_file_xsgetn
函数中,getn 表示从 fp
中读取 n 字节。具体实现代码太长就不复制了,可以简要概括如下:
_IO_buf_base
表示;_IO_doallocbuf
进行分配,会从相同的虚表中查找对应的 __doallocate
方法并执行;_IO_file_doallocate
内部会使用 malloc
分配缓存,默认大小是 8192;_IO_buf_base
指向缓存头部,_IO_buf_end
指向尾部(=p+size);_IO_read_ptr
是缓存中有效数据的头部,_IO_read_end
是有效数据的尾部;_IO_file_underflow
调用,判断文件是否已经关闭,如果关闭了就退出读取返回 EOF 给用户,否则继续等待数据;vtable->read
即系统调用进行读取;文件写入的流程也大同小异,使用虚表中的 xsputn 即 _IO_file_xsputn
方法进行写入。在新版本 glibc 中使用了新的实现 _IO_new_file_xsputn
。和 fread 的差异主要在于内存分配的过程,实际是在触发 overflow 调用的时候进行内存分配的,下面是一个实际的栈回溯示例:
[#0] __GI___libc_malloc
[#1] __GI__IO_file_doallocate
[#2] __GI__IO_doallocbuf
[#3] _IO_new_file_overflow
[#4] _IO_new_file_xsputn
[#5] __GI__IO_fwrite
[#6] main
触发 overflow 表示内部缓存空间不足,此时会有几种不同的情况:
_IO_write_base == 0
),此时直接申请;flags & _IO_CURRENTLY_PUTTING == 0
),此时需要调整对应的读写指针以腾出空间;还有一个值得一提的是 fclose
,其大致操作如下:
_IO_un_link
);vtable->finish
:
vtable->flush
);vtable->close
);至此,完成了对 stdio 生命周期的简单分析。
从前面的介绍可以看出,FILE 结构体之所以是一个常用的攻击目标,是因为其中虚函数表中存在大量的函数指针,并且在程序正常执行的生命周期会触发其中许多方法的查找和执行。加上 stdin、stdout、stderr 这些全局指针的存在,使得一旦可以劫持虚函数查找中的任意一级流程,就可以实现控制流劫持,从而进一步实现任意代码执行。
具体而言又有不同的细分,笔者只根据自己的经验去总结了一些常见攻击方法,若有疏漏可以在文末评论指出。
下文中会将
_IO_FILE_plus
和FILE
统称为 FILE 结构。
通过前面的代码可知,内存中的 stdin 等全局变量存放着 _IO_2_1_stdin_
等 FILE 结构体的地址,而且这个变量本身并不是 const
的,因此可以在运行中修改。攻击者一旦获取到内存任意读写的原语,就可以修改该变量,令其指向攻击者可控的内存中,并在内存里伪造一个 FILE 结构,从而让其中的 vtable 指向我们伪造的地址。最后在执行对应的 IO 操作时触发虚函数调用即可劫持 PC。
有一些细节需要注意,由于我们伪造了整个 FILE 结构,因此其中的每个字段都要保证不会影响正常运行。例如其中的最后一个字段 _IO_lock_t *_lock
,需要是一个合法的指针,计数为 0 (表示未被占用)且可以修改,例如:
gef➤ p *stdin._lock
$1 = {
lock = 0x0,
cnt = 0x0,
owner = 0x0
}
上节说到所有打开的 FILE 结构都会被存放到全局的 _IO_list_all
链表里,并通过 _chain
字段作为链表的 next 指针。在 libc 中有个函数 _IO_flush_all_lockp
,主要作用是遍历上述链表,并对其中的每个文件执行刷新缓存的操作:
int _IO_flush_all_lockp (int do_lock) {
int result = 0;
FILE *fp;
for (fp = (FILE *) _IO_list_all; fp != NULL; fp = fp->_chain)
{
run_fp = fp;
if (do_lock)
_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;
if (do_lock)
_IO_funlockfile (fp);
run_fp = NULL;
}
return result;
}
也就是说会调用 vtable->overflow
方法。这个特性的好处是我们可以通过伪造链表的方式进行多次任意调用,从而实现类似 ROP 的调用链构造。当然根据 &&
的短路性质,所构造的 FILE 结构需要满足一些前置条件,一般是选择满足前者,即:
fp->_mode <= 0
,且fp->_IO_write_ptr > fp->_IO_write_base
因为后者相对复杂且可能会有一些副作用。
_IO_flush_all_lockp
在很多场景下都会被触发,比如:
exit
函数时因此可以在很多交互受限的场景下强行触发漏洞。下面是一个 main
函数退出时调用的栈回溯示例:
[#0] → _IO_flush_all_lockp(do_lock=0x0)
[#1] → _IO_cleanup()
[#2] → __run_exit_handlers(status=0x0, listp=, run_list_atexit=0x1, run_dtors=0x1)
[#3] → __GI_exit(status=0)
[#4] → __libc_start_call_main
[#5] → __libc_start_main_impl
[#6] → _start()
不管是虚表劫持还是 FSOP,在 glibc-2.24 之后都会遇到一个问题,即引入了针对虚表地址的额外判断:
# define _IO_JUMPS_FUNC(THIS) (IO_validate_vtable (_IO_JUMPS_FILE_plus (THIS)))
static inline const struct _IO_jump_t *
IO_validate_vtable (const struct _IO_jump_t *vtable)
{
/* Fast path: The vtable pointer is within the __libc_IO_vtables
section. */
uintptr_t section_length = __stop___libc_IO_vtables - __start___libc_IO_vtables;
uintptr_t ptr = (uintptr_t) vtable;
uintptr_t offset = ptr - (uintptr_t) __start___libc_IO_vtables;
if (__glibc_unlikely (offset >= section_length))
/* 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;
}
这是每次在查找虚表函数,即调用 JUMPn
时都会进行的检查,如果虚表地址不在对应段的范围,会额外进行一次 _IO_vtable_check
检查操作,如果不通过的话会直接 __libc_fatal
退出。
额外检查的目的是判断对应的 FILE 结构体是否在动态库的范围内,因为可能用户会跨动态库去调用外部方法。检查的实现主要如下:
void attribute_hidden
_IO_vtable_check (void)
{
void (*flag) (void) = atomic_load_relaxed (&IO_accept_foreign_vtables);
PTR_DEMANGLE (flag);
if (flag == &_IO_vtable_check)
return;
{
Dl_info di;
struct link_map *l;
if (!rtld_active ()
|| (_dl_addr (_IO_vtable_check, &di, &l, NULL) != 0
&& l->l_ns != LM_ID_BASE))
return;
}
__libc_fatal ("Fatal error: glibc detected an invalid stdio handle\n");
}
其中 PTR_DEMANGLE 是 glibc 中引入的指针加密特性,目的就是为了提高攻击者修改指针劫持控制流的难度。
这个检查有效防止了修改虚函数表进行攻击的手法,因此业内又开始发掘其绕过方式或者其他的漏洞利用路径。
其中一种方法是不修改虚表,而是通过修改 FILE 结构体中的其他字段实现任意代码读写。其主要思路是通过这些字段扰乱 I/O 函数则正常执行流程。
对于 fwrite 而言,我们可以先将 fileno
改为 1
(标准输出),这样就可以在触发系统调用时将数据返回给我们,关键是如何设置系统调用的参数。在前面我们说过了,触发系统调用是通过虚表的 vtable->write
去实现的,所以我们主要看调用前的代码:
int
_IO_new_file_overflow (FILE *f, int ch) {
/* If currently reading or no buffer allocated. */
if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0 || f->_IO_write_base == NULL) {
// ...
}
if (ch == EOF)
return _IO_do_write (f, f->_IO_write_base,
f->_IO_write_ptr - f->_IO_write_base);
if (f->_IO_write_ptr == f->_IO_buf_end ) /* Buffer is really full */
if (_IO_do_flush (f) == EOF)
return EOF;
// ...
}
_io_do_write/flush
都会将 write_base 至 write_ptr 之间的数据写入到 fp 中,因此只要将其设置为目标地址区间,就可以实现对应地址的信息泄露。不过需要注意的是前面的两个判断需要绕过,以减少调整内部缓存区域引起的副作用。
简单来说,通过修改以下字段即可实现任意内存地址泄露:
_fileno
设为 1;_flag &= ~_IO_NO_WRITES
,清除掉读字段,只需要往目标文件写内容;_flag |= _IO_CURRENTLY_PUTTING
;添加该字段,绕过 overflow 的条件判断;_IO_write_base
为想要泄露的起始地址,_IO_write_ptr
为结束地址;_IO_read_end
= _IO_write_base
;最后一项是为了在 do_write
的时候防止 seek 扰乱内存:
static size_t
new_do_write (FILE *fp, const char *data, size_t to_do) {
size_t count;
if (fp->_flags & _IO_IS_APPENDING)
fp->_offset = _IO_pos_BAD;
else if (fp->_IO_read_end != fp->_IO_write_base) {
// seek
}
count = _IO_SYSWRITE (fp, data, to_do);
// ...
}
同样的思路也可以用在 fread 中实现任意地址写:
size_t _IO_file_xsgetn (FILE *fp, void *data, size_t n) {
// while (want > 0)
have = fp->_IO_read_end - fp->_IO_read_ptr;
if (want <= have) {
// 直接拷贝缓存到目标地址
} else {
// ...
if (fp->_IO_buf_base
&& want < (size_t) (fp->_IO_buf_end - fp->_IO_buf_base))
{
if (__underflow (fp) == EOF)
break;
continue;
}
}
}
int _IO_new_file_underflow (FILE *fp) {
// ...
if (fp->_flags & _IO_EOF_SEEN) return EOF;
if (fp->_flags & _IO_NO_READS) return EOF;
if (fp->_IO_read_ptr < fp->_IO_read_end)
return *(unsigned char *) fp->_IO_read_ptr;
// ...
count = _IO_SYSREAD (fp, fp->_IO_buf_base,
fp->_IO_buf_end - fp->_IO_buf_base);
}
如果我们想要从标准输入中写入,那么也只需要满足以下条件:
_fileno
为 0(标准输入);_flag &= ~_IO_NO_READS
,绕过 underflow 中的判断;_IO_default_setbuf
设置的默认值;其本质上是手动对缓存进行”创建“,这样在写入伪造缓存的时候也就写到了手动指定的缓存地址,实现任意地址写入。
除了修改数据指针,还有另外一种绕过 vtable check 的方法。既然 vtable 的作用是多态调用,那么肯定不会只有一种实现,事实上也如此。
例如,在 glibc 的代码中,除了 _IO_file_jumps
这个虚表,还有 _IO_str_jumps
,属于另外一种 IO 类型 _IO_streambuf
,包含在 _IO_strfile_
结构中:
struct _IO_str_fields
{
/* These members are preserved for ABI compatibility. The glibc
implementation always calls malloc/free for user buffers if
_IO_USER_BUF or _IO_FLAGS2_USER_WBUF are not set. */
_IO_alloc_type _allocate_buffer_unused;
_IO_free_type _free_buffer_unused;
};
struct _IO_streambuf
{
FILE _f;
const struct _IO_jump_t *vtable;
};
typedef struct _IO_strfile_
{
struct _IO_streambuf _sbf;
struct _IO_str_fields _s;
} _IO_strfile;
由于 _IO_str_jumps
这个虚表本来就在 glibc 中,因此是可以满足检查的:
const struct _IO_jump_t _IO_str_jumps libio_vtable =
{
JUMP_INIT_DUMMY,
JUMP_INIT(finish, _IO_str_finish),
JUMP_INIT(overflow, _IO_str_overflow),
JUMP_INIT(underflow, _IO_str_underflow),
JUMP_INIT(uflow, _IO_default_uflow),
JUMP_INIT(pbackfail, _IO_str_pbackfail),
JUMP_INIT(xsputn, _IO_default_xsputn),
JUMP_INIT(xsgetn, _IO_default_xsgetn),
JUMP_INIT(seekoff, _IO_str_seekoff),
JUMP_INIT(seekpos, _IO_default_seekpos),
JUMP_INIT(setbuf, _IO_default_setbuf),
JUMP_INIT(sync, _IO_default_sync),
JUMP_INIT(doallocate, _IO_default_doallocate),
JUMP_INIT(read, _IO_default_read),
JUMP_INIT(write, _IO_default_write),
JUMP_INIT(seek, _IO_default_seek),
JUMP_INIT(close, _IO_default_close),
JUMP_INIT(stat, _IO_default_stat),
JUMP_INIT(showmanyc, _IO_default_showmanyc),
JUMP_INIT(imbue, _IO_default_imbue)
};
如果我们可以修改 stdin/stdout 虚表的值为该虚表地址,那么就可以利用不同的实现方法去构造后续的任意地址读写原语,这对于使用 file_jumps 虚表条件难以满足时可以作为一种迂回的实现方案。这里细节就不展开了,可以参考网上其他的相关文章:
除了 str_jumps,其实在 glibc 中存在大量可选的虚表,在 GDB 中列举如下:
gef➤ info variables -t 'struct _IO_jump_t'
All defined variables with type matching regular expression "struct _IO_jump_t" :
File ./libio/fileops.c:
1481: const struct _IO_jump_t _IO_file_jumps_maybe_mmap;
1457: const struct _IO_jump_t _IO_file_jumps_mmap;
1432: const struct _IO_jump_t __GI__IO_file_jumps;
File ./libio/iofopncook.c:
111: static const struct _IO_jump_t _IO_cookie_jumps;
File ./libio/iopopen.c:
48: static const struct _IO_jump_t _IO_proc_jumps;
File ./libio/iovsprintf.c:
38: static const struct _IO_jump_t _IO_str_chk_jumps;
File ./libio/memstream.c:
36: static const struct _IO_jump_t _IO_mem_jumps;
File ./libio/obprintf.c:
93: const struct _IO_jump_t _IO_obstack_jumps;
File ./libio/strops.c:
353: const struct _IO_jump_t _IO_str_jumps;
File ./libio/vsnprintf.c:
67: const struct _IO_jump_t _IO_strn_jumps;
File ./libio/vswprintf.c:
66: const struct _IO_jump_t _IO_wstrn_jumps;
File ./libio/wfileops.c:
1071: const struct _IO_jump_t _IO_wfile_jumps_maybe_mmap;
1047: const struct _IO_jump_t _IO_wfile_jumps_mmap;
1021: const struct _IO_jump_t __GI__IO_wfile_jumps;
File ./libio/wmemstream.c:
37: static const struct _IO_jump_t _IO_wmem_jumps;
File ./libio/wstrops.c:
362: const struct _IO_jump_t _IO_wstr_jumps;
File ./stdio-common/vfprintf-internal.c:
2203: static const struct _IO_jump_t _IO_helper_jumps;
比如在 HITCON 2017 : Ghost in The Heap Writeup 中基于 _IO_wstr_jumps
虚表中的 _IO_wstr_finish
方法实现利用。它们原理都是大同小异的,只有一些细节上的差异。
本节来介绍几种比较常见的的后利用技巧,虽然大部分都是在堆漏洞的场景下提出的,但其中的一些思路同样也可以适用于其他的漏洞。
这是一种针对分配受限情况下堆漏洞的利用方法,源于 XCTF 2021 final,因为是 rekapig 战队出的题所以交了这个名字。主要限制是程序使用 calloc 分配内存,不走 tcache 从而无法使用 tcache stashing unlink attack 将布局好的 fake chunk 申请出来。
在这种情况下要想继续成功攻击,可以利用 FILE 结构体中的 I/O 缓存特性,在第一次读/写的时候如果缓存没有初始化,会使用 malloc 进行申请。在对虚表的选择上,该利用方法使用了 _IO_str_jumps
作为伪造 FILE 结构的虚表地址,因为 IO_str_overflow
函数内会连续调用 malloc 、memcpy、free,并且这三个函数的参数都可以由 FILE 结构内的数据来控制。
因此只要先在堆上布局好合适的数据,就可以在触发 overflow 调用 malloc、memcpy、free 三连时触发 __free_hook
劫持并触发任意代码劫持。原始题目中因为使用的是 largebin,因此最初是通过 largebin 攻击将堆地址写入到 _IO_list_all
中,最后通过 _IO_flush_all_lockp
去触发 IO_str_overflow
的调用。
具体堆风水细节这里就不展开了,感兴趣的同学可以参考文末的链接。
虽然现在大多数堆漏洞还是通过修改 __malloc_hook
或者 __free_hook
去实现代码执行,但在新版本的 glibc 中已经去除掉了(这两个符号依然存在,但是相应的 hook 不会被执行)。而且很多程序中还加上了 seccomp 的沙盒,要求只能执行 read/write/open,这又进一步限制了 ROP 执行 shellcode 的能力。
另外一个限制是 main 函数不会返回,且退出调用的不是 exit
而是 _exit
,后者并不会触发 _IO_flush_all_lockp
调用。因此这种利用方法的主要思路是通过 __malloc_assert
触发 fflush(stderr)
,从而调用 _IO_file_jumps.sync
方法。
虽然原文章中所说的将 sync 地址修改为指定地址同样有 vtable 校验的限制,但这个通过断言去触发 I/O 刷新的操作还是值得借鉴的。我们可以通过堆风水修改 top chunk 大小去主动触发这个断言从而劫持控制流。
该漏洞利用方法同样是为了解决在 glibc-2.34 之后取消 malloc/free hook 导致无法劫持控制流的问题。前面说过 FILE 的 vtable 需要在合法范围,也给出了一系列已有的虚函数表定义,但并没说虚函数表就一定要是其中某一个,实际上只要在对应 section 区间内就可以。
因此,该作者提出了微偏移的概念,在原有的虚函数表前后进行微小偏移,这样就可以在触发虚函数调用的时候执行该范围内的任意函数。为此选择了 _IO_cookie_jumps
这个虚函数表,并以及其中的 _IO_cookie_read
、_IO_cookie_write
等函数为目标,因为其中存在函数指针的使用:
static ssize_t
_IO_cookie_read (FILE *fp, void *buf, ssize_t size)
{
struct _IO_cookie_file *cfile = (struct _IO_cookie_file *) fp;
cookie_read_function_t *read_cb = cfile->__io_functions.read;
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (read_cb);
#endif
if (read_cb == NULL)
return -1;
return read_cb (cfile->__cookie, buf, size);
}
由于 FILE 结构是我们伪造的,因此大部分内容可控,关键是如何绕过 PTR_DEMANGLE
的指针加密保护,这在前面的章节中也说过。但世上没有绝对安全的系统,只不过是投入成本的问题罢了。通过查看对应代码的回报可以发现,PTR_DEMANGEL
主要是将某个 TLS 段的值 ROR 移位后与原指针异或实现的加密。
gef➤ disassemble _IO_cookie_read
0x00007ffff7e0d7b0 <+0>: endbr64
0x00007ffff7e0d7b4 <+4>: mov rax,QWORD PTR [rdi+0xe8]
0x00007ffff7e0d7bb <+11>: ror rax,0x11
0x00007ffff7e0d7bf <+15>: xor rax,QWORD PTR fs:0x30
0x00007ffff7e0d7c8 <+24>: test rax,rax
0x00007ffff7e0d7cb <+27>: je 0x7ffff7e0d7d6 <_IO_cookie_read+38>
0x00007ffff7e0d7cd <+29>: mov rdi,QWORD PTR [rdi+0xe0]
0x00007ffff7e0d7d4 <+36>: jmp rax
0x00007ffff7e0d7d6 <+38>: mov rax,0xffffffffffffffff
0x00007ffff7e0d7dd <+45>: ret
因此只要我们可以将这个值泄露出来或者修改为已知值,同样也能绕过指针加密。这样一来就可以伪造 read_cb
函数指针的值,从而实现任意代码执行的目的。
最后一种技巧和 FILE 结构没什么关系,但我觉得很有趣,所以也介绍一下,让大家感受一下世界的参差。这种技巧依赖于 Largebin attack 可以往任一地址写堆地址的能力,主的目标是伪造 ld.so
中的 rtld_global
结构体。我们知道 ELF 中有也有 ctor 和 dtor,主要是可以让动态链接的程序在加载初期和结束前运行指定代码,这种攻击方法就是瞄准了程序结束后执行的 .finit_array
数组,相关代码片段如下:
if (l->l_info[DT_FINI_ARRAY] != NULL)
{
ElfW(Addr) *array =
(ElfW(Addr) *) (l->l_addr
+ l->l_info[DT_FINI_ARRAY]->d_un.d_ptr);
unsigned int i = (l->l_info[DT_FINI_ARRAYSZ]->d_un.d_val
/ sizeof (ElfW(Addr)));
while (i-- > 0)
((fini_t) array[i]) ();
}
如果能够伪造对应的 FINIT_ARRAY
,同样也能实现任意代码执行。当然实现上要解决 ld.so
与 libc.so
之间的 ASLR 偏移问题,据说可以通过爆破第二低字节解决,不过笔者只是出于叶公好龙的口嗨状态,还没有实际尝试过,等测试后再回来补充吧。
由于 glibc 中的代码较多,也就给攻击者提供了许多藏污纳垢之地,仅仅是 I/O 这块就包含了各种不同的漏洞利用姿势。但回头来看,IO_FILE 的战场最初是围绕函数指针(虚表)展开的,且随着不断加固,为了绕过又不得不引入复杂的内存布局。放眼未来的话其实可以打开思路,不仅局限于某个特定结构,甚至不必局限于 libc,文末的 Bnana 就是一个很好的示例。相信随着网络安全行业的发展,会涌现出更多的新人才和新思路,当然 CTF 比赛也会更卷一些了 😃
版权声明: 自由转载-非商用-非衍生-保持署名 (CC 4.0 BY-SA)
原文地址: https://evilpan.com/2022/07/30/glibc-exp-tricks/
微信订阅: 『有价值炮灰』
– TO BE CONTINUED.