上篇文章描述了vtable check以及绕过vtalbe check的方法之一,利用vtable段中的_IO_str_jumps
来进行FSOP。本篇则主要描述使用缓冲区指针来进行任意内存读写。
从前面fread
以及fwrite
的分析中,我们知道了FILE结构体中的缓冲区指针是用来进行输入输出的,很容易的就想到了如果能过伪造这些缓冲区指针,在一定的条件下应该可以完成任意地址的读写。
本文包括两部分:
stdin
标准输入缓冲区进行任意地址写。stdout
标准输出缓冲区进行任意地址读写。接下来描述这两部分的原理以及给出相应的题目实践,原理介绍部分是基于已经拥有可以伪造IO FILE结构体的缓冲区指针漏洞的基础上进行的。在后续过程假设我们目标写的地址是write_start
,写结束地址为write_end
;读的目标地址为read_start
,读的结束地址为read_end
。
前几篇传送门:
stdin
标准输入缓冲区进行任意地址写这一部分主要阐述的是使用stdin
标准输入缓冲区指针进行任意地址写的功能。
先通过fread
回顾下通过输入缓冲区进行输入的流程:
fp->_IO_buf_base
输入缓冲区是否为空,如果为空则调用的_IO_doallocbuf
去初始化输入缓冲区。__underflow
函数执行系统调用读取数据到输入缓冲区,再拷贝到用户缓冲区。假设我们能过控制输入缓冲区指针,使得输入缓冲区指向想要写的地址,那么在第三步调用系统调用读取数据到输入缓冲区的时候,也就会调用系统调用读取数据到我们想要写的地址,从而实现任意地址写的目的。
根据fread
的源码,我们再看下要想实现往write_start
写长度为write_end - write_start
的数据具体经历了些什么。
_IO_size_t _IO_file_xsgetn (_IO_FILE *fp, void *data, _IO_size_t n) { ... if (fp->_IO_buf_base == NULL) { ... //输入缓冲区为空则初始化输入缓冲区 } while (want > 0) { have = fp->_IO_read_end - fp->_IO_read_ptr; if (have > 0) { ... //memcpy } if (fp->_IO_buf_base && want < (size_t) (fp->_IO_buf_end - fp->_IO_buf_base)) { if (__underflow (fp) == EOF) ## 调用__underflow读入数据 ... } ... return n - want; }
上面贴出了一些关键代码,首先是_IO_file_xsgetn
函数,函数先判断输入缓冲区_IO_buf_base
是否为空,如果为空的话则调用_IO_doallocbuf
初始化缓冲区,因此需构造_IO_buf_base
不为空。
接着函数中当输入缓冲区有剩余时即_IO_read_end -_IO_read_ptr >0
,会将缓冲区中的数据拷贝至目标中,因此想要利用输入缓冲区实现读写,最好使_IO_read_end -_IO_read_ptr =0
即_IO_read_end ==_IO_read_ptr
。
同时还要求读入的数据size
要小于缓冲区数据的大小,否则为提高效率会调用read直接读。
_IO_file_xsgetn
函数中当缓冲区不能满足需求时会调用__underflow
去读取数据,查看__underflow
。
int _IO_new_file_underflow (_IO_FILE *fp) { _IO_ssize_t count; ... ## 如果存在_IO_NO_READS标志,则直接返回 if (fp->_flags & _IO_NO_READS) { fp->_flags |= _IO_ERR_SEEN; __set_errno (EBADF); return EOF; } ## 如果输入缓冲区里存在数据,则直接返回 if (fp->_IO_read_ptr < fp->_IO_read_end) return *(unsigned char *) fp->_IO_read_ptr; ... ##调用_IO_SYSREAD函数最终执行系统调用读取数据 count = _IO_SYSREAD (fp, fp->_IO_buf_base, fp->_IO_buf_end - fp->_IO_buf_base); ... } libc_hidden_ver (_IO_new_file_underflow, _IO_file_underflow)
在_IO_new_file_underflow
函数中先判断fp->_IO_read_ptr < fp->_IO_read_end
是否成立,成立则直接返回,因此再次要求伪造的结构体_IO_read_end ==_IO_read_ptr
,绕过该条件检查。
接着函数会检查_flags
是否包含_IO_NO_READS
标志,包含则直接返回。标志的定义是#define _IO_NO_READS 4
,因此_flags
不能包含4
。
最终系统调用_IO_SYSREAD (fp, fp->_IO_buf_base,fp->_IO_buf_end - fp->_IO_buf_base)
读取数据,因此要想利用stdin
输入缓冲区需设置FILE结构体中_IO_buf_base
为write_start
,_IO_buf_end
为write_end
。同时也需将结构体中的fp->_fileno
设置为0,最终调用read (fp->_fileno, buf, size))
读取数据。
将上述条件综合表述为:
_IO_read_end
等于_IO_read_ptr
。_flag &~ _IO_NO_READS
即_flag &~ 0x4
。_fileno
为0。_IO_buf_base
为write_start
,_IO_buf_end
为write_end
;且使得_IO_buf_end-_IO_buf_base
大于fread要读的数据。实践的题目是whctf2017的stackoverflow,这一年也是这一种利用方式的兴起之年,这一题是很经典的一题。
题目首先是输入name,并把name输出出来,由于name未进行初始化设置且读取数据后未加入\x00
,可以由此泄露出libc地址。
接着进入主功能函数,漏洞在先使用temp变量保存了输入的size,但是后续最后写\x00
的时候使用的是temp,而不是size,因此存在一个溢出写\x00
的漏洞。
在之前的文章中,我们知道了当申请堆块大小很大时(0x200000),申请出来的堆块会紧挨着libc,因此我们可以利用这个溢出写\x00
的漏洞往libc的内存中写入一个\x00
字节。
往哪里写一个\x00
字节,后续改变整个内存结构而拿到shell?答案时stdin
结构体中的\x00
,我们先看下输入之前的stdin结构体中的数据:
可以看到在glibc 2.24中,stdin
结构体中存储_IO_buf_end
指针内存地址的末尾刚好为\x00
,若利用漏洞我们将_IO_buf_base
末尾写\x00
,则会使得_IO_buf_base
指向stdin
结构体中存储_IO_buf_end
指针内存地址,即可利用输入缓冲区覆盖_IO_buf_end
。
我们可将_IO_buf_end
覆盖为__malloc_hook+0x8
,则输入时最后控制写的数据为stdin
中的_IO_buf_end
指针位置到__malloc_hook+0x8
,以实现控制__malloc_hook
。
原理就是如此,需要多提两点。
一是IO_getc
函数的作用是刷新_IO_read_ptr
,每次会从输入缓冲区读一个字节数据即将_IO_read_ptr
加一,当_IO_read_ptr
等于_IO_read_end
的时候便会调用read
读数据到_IO_buf_base
地址中。
二是往malloc_hook写什么,由于one gadget
用不了,因此在栈中找到了一个gadget,地址为0x400a23
,可以读取数据形成栈溢出,从而进行ROP,拿到shell。
.text:0000000000400A23 lea rax, [rbp+name] .text:0000000000400A27 mov esi, 50h ; count .text:0000000000400A2C mov rdi, rax ; input .text:0000000000400A2F call input_data
stdout
标准输入缓冲区进行任意地址读写上半部分使用了stdin
进行任意地址写,这部分主要阐述stdout
来进行任意地址读写。stdin
只能输入数据到缓冲区,因此只能进行写。而stdout
会将数据拷贝至输出缓冲区,并将输出缓冲区中的数据输出出来,所以如果可控stdout
结构体,通过构造可实现利用其进行任意地址读以及任意地址写。
任意写的主要原理为:构造好输出缓冲区将其改为想要任意写的地址,当输出数据可控时,会将数据拷贝至输出缓冲区,即实现了将可控数据拷贝至我们想要写的地址。
想要实现上述功能,查看fwrite
源码中如何才能实现该功能:
_IO_size_t _IO_new_file_xsputn (_IO_FILE *f, const void *data, _IO_size_t n) { ... ## 判断输出缓冲区还有多少空间 else if (f->_IO_write_end > f->_IO_write_ptr) count = f->_IO_write_end - f->_IO_write_ptr; /* Space available. */ ## 如果输出缓冲区有空间,则先把数据拷贝至输出缓冲区 if (count > 0) { ... memcpy (f->_IO_write_ptr, s, count);
任意写功能的实现在于IO缓冲区没有满时,会先将要输出的数据复制到缓冲区中,可通过这一点来实现任意地址写的功能。可以看到任意写好像很简单,只需将_IO_write_ptr
指向write_start
,_IO_write_end
指向write_end
即可。
利用stdout
进行任意地址读的原理为:控制输出缓冲区指针指向我们输入的地址,构造好条件,使得输出缓冲区为已经满的状态,再次调用输出函数时,程序会刷新输出缓冲区即会输出我们想要的数据,实现任意读。
仍然是查看fwrite
源码中如何才能实现该功能:
_IO_size_t _IO_new_file_xsputn (_IO_FILE *f, const void *data, _IO_size_t n) { _IO_size_t count = 0; ... ## 判断输出缓冲区还有多少空间 else if (f->_IO_write_end > f->_IO_write_ptr) count = f->_IO_write_end - f->_IO_write_ptr; /* Space available. */ ## 如果输出缓冲区有空间,则先把数据拷贝至输出缓冲区 if (count > 0) { ... //memcpy } if (to_do + must_flush > 0) { if (_IO_OVERFLOW (f, EOF) == EOF)
当f->_IO_write_end > f->_IO_write_ptr
时,会调用memcpy拷贝数据,因此最好构造条件f->_IO_write_end
等于f->_IO_write_ptr
。
接着进入_IO_OVERFLOW
函数,去刷新输出缓冲区,跟进去:
int _IO_new_file_overflow (_IO_FILE *f, int ch) { ## 判断标志位是否包含_IO_NO_WRITES if (f->_flags & _IO_NO_WRITES) /* SET ERROR */ { f->_flags |= _IO_ERR_SEEN; __set_errno (EBADF); return EOF; } ## 判断输出缓冲区是否为空 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); return (unsigned char) ch; } libc_hidden_ver (_IO_new_file_overflow, _IO_file_overflow)
可以看到_IO_new_file_overflow
,首先判断_flags
是否包含_IO_NO_WRITES
,如果包含则直接返回,因此需构造_flags
不包含_IO_NO_WRITES
,其定义为#define _IO_NO_WRITES 8
;
接着判断缓冲区是否为空以及是否包含_IO_CURRENTLY_PUTTING
标志位,如果包含的话则做一些多余的操作,可能不可控,因此最好定义_flags
不包含_IO_CURRENTLY_PUTTING
,其定义为#define _IO_CURRENTLY_PUTTING 0x800
。
接着调用_IO_do_write
去输出输出缓冲区,其传入的参数是f->_IO_write_base
,大小为f->_IO_write_ptr - f->_IO_write_base
。因此若想实现任意地址读,应构造_IO_write_base
为read_start
,构造_IO_write_ptr
为read_end
。
跟进去_IO_do_write
,看该函数的关键代码:
static _IO_size_t new_do_write (_IO_FILE *fp, const char *data, _IO_size_t to_do) { ... _IO_size_t count; if (fp->_flags & _IO_IS_APPENDING) fp->_offset = _IO_pos_BAD; else if (fp->_IO_read_end != fp->_IO_write_base) { _IO_off64_t new_pos = _IO_SYSSEEK (fp, fp->_IO_write_base - fp->_IO_read_end, 1); if (new_pos == _IO_pos_BAD) return 0; fp->_offset = new_pos; } ## 调用函数输出输出缓冲区 count = _IO_SYSWRITE (fp, data, to_do); ... return count; }
看到在调用_IO_SYSWRITE
之前还判断了fp->_IO_read_end != fp->_IO_write_base
,因此需要构造结构体使得_IO_read_end
等于_IO_write_base
。
也可以构造_flags
包含_IO_IS_APPENDING
,_IO_IS_APPENDING
的定义为#define _IO_IS_APPENDING 0x1000
,这样就不会走后面的这个判断而直接执行到_IO_SYSWRITE
了,一般我都是设置_IO_read_end
等于_IO_write_base
。
最后_IO_SYSWRITE
调用write (f->_fileno, data, to_do)
输出数据,因此还需构造_fileno
为标准输出描述符1。
将上述条件综合描述为:
_flag &~ _IO_NO_WRITES
即_flag &~ 0x8
。_flag & _IO_CURRENTLY_PUTTING
即_flag | 0x800
_fileno
为1。_IO_write_base
指向想要泄露的地方;_IO_write_ptr
指向泄露结束的地址。_IO_read_end
等于_IO_write_base
或设置_flag & _IO_IS_APPENDING
即_flag | 0x1000
。设置_IO_write_end
等于_IO_write_ptr
(非必须)。
满足上述五个条件,可实现任意读。
使用stdout
进行任意读写比较经典的一题应该是hctf2018的babyprintf_ver2
了,下面来进行利用描述。
题目直接给出了程序基址。
然后存在明显的溢出,可以覆盖stdout
,但是无法覆盖stdout
的vtable,因为它会修正。
具体该如何利用呢,首先使用stdout
任意读来泄露libc地址。构造的FILE结构体如下(使用pwn_debug的IO_FILE_plus
模块):
io_stdout_struct=IO_FILE_plus() flag=0 flag&=~8 flag|=0x800 flag|=0x8000 io_stdout_struct._flags=flag io_stdout_struct._IO_write_base=pro_base+elf.got['read'] io_stdout_struct._IO_read_end=io_stdout_struct._IO_write_base io_stdout_struct._IO_write_ptr=pro_base+elf.got['read']+8 io_stdout_struct._fileno=1
以此来泄露read的地址。
接着使用stdout
的任意地址写来写__malloc_hook
,构造的FILE结构体如下:
io_stdout_struct=IO_FILE_plus() flag=0 flag&=~8 flag|=0x8000 io_stdout_write=IO_FILE_plus() io_stdout_write._flags=flag io_stdout_write._IO_write_ptr=malloc_hook io_stdout_write._IO_write_end=malloc_hook+8
最终将one gaget 写入malloc_hook
。如何触发malloc呢,可以使用输出较大的字符打印来触发malloc函数或是%n
来触发,其中%n
可触发malloc的原因是在于__readonly_area
会通过fopen
打开maps
文件来读取内容来判断地址段是否可写,而fopen
会调用malloc
函数申请空间,因此触发。
可能会有人对于觉得flag|=0x8000
这行构造代码觉得比较奇怪,需要解释下,在printf
函数中会调用_IO_acquire_lock_clear_flags2 (stdout)
来获取lock
从而继续程序,如果没有_IO_USER_LOCK
标志的话,程序会一直在循环,而_IO_USER_LOCK
定义为#define _IO_USER_LOCK 0x8000
,因此需要设置flag|=0x8000
才能够使exp顺利进行。_IO_acquire_lock_clear_flags2 (stdout)
的汇编代码如下:
0x7f0bcf15d850 <__printf_chk+96> mov rbp, qword ptr [rip + 0x2a16f9] 0x7f0bcf15d857 <__printf_chk+103> mov rbx, qword ptr [rbp] 0x7f0bcf15d85b <__printf_chk+107> mov eax, dword ptr [rbx] 0x7f0bcf15d85d <__printf_chk+109> and eax, 0x8000 0x7f0bcf15d862 <__printf_chk+114> jne __printf_chk+202 <0x7f0bcf15d8ba>
使用IO FILE来进行任意内存读写真的是个很强大的功能,构造起来也比较容易。但是对于FILE结构体的伪造,个人感觉可能最容易出问题的地方还是_flags
字段的构造,可能某个地方不注意就导致程序走偏了,因此感觉可能还是把默认的stdout
和stdin
直接拷贝出来用会比较好一些,同时pwn_debug
的IO_FILE_plus
模块提供了apiarbitrary_write_check
以及arbitrary_read_check
来进行相应检测,看相应字段是否设置正确。
至此IO FILE系列描述完毕,前四篇对IO函数fopen、fread、fwrite以及fclose的源码分析;后面三篇介绍了针对IO FILE的相关利用,包括劫持vtable、vtable引入的check机制以及相应的后续利用方式。在整个过程中为方便构造IO 结构体还在pwn_debug
中加入了IO_FILE_plus
模块。
最后一句,阅读源码对于学习是一件很有帮助的事情。