IO FILE 之任意读写

2019-08-04 约 577 字 预计阅读 3 分钟

声明:本文 【IO FILE 之任意读写】 由作者 raycp 于 2019-08-04 09:10:00 首发 先知社区 曾经 浏览数 89 次

感谢 raycp 的辛苦付出!

上篇文章描述了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回顾下通过输入缓冲区进行输入的流程:

  1. 判断fp->_IO_buf_base输入缓冲区是否为空,如果为空则调用的_IO_doallocbuf去初始化输入缓冲区。
  2. 在分配完输入缓冲区或输入缓冲区不为空的情况下,判断输入缓冲区是否存在数据。
  3. 如果输入缓冲区有数据则直接拷贝至用户缓冲区,如果没有或不够则调用__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_basewrite_start_IO_buf_endwrite_end。同时也需将结构体中的fp->_fileno设置为0,最终调用read (fp->_fileno, buf, size))读取数据。

将上述条件综合表述为:

  1. 设置_IO_read_end等于_IO_read_ptr
  2. 设置_flag &~ _IO_NO_READS_flag &~ 0x4
  3. 设置_fileno为0。
  4. 设置_IO_buf_basewrite_start_IO_buf_endwrite_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_baseread_start,构造_IO_write_ptrread_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。

将上述条件综合描述为:

  1. 设置_flag &~ _IO_NO_WRITES_flag &~ 0x8
  2. 设置_flag & _IO_CURRENTLY_PUTTING_flag | 0x800
  3. 设置_fileno为1。
  4. 设置_IO_write_base指向想要泄露的地方;_IO_write_ptr指向泄露结束的地址。
  5. 设置_IO_read_end等于_IO_write_base或设置_flag & _IO_IS_APPENDING_flag | 0x1000
  6. 设置_IO_write_end等于_IO_write_ptr(非必须)。

    满足上述五个条件,可实现任意读。

实践

使用stdout进行任意读写比较经典的一题应该是hctf2018的babyprintf_ver2了,下面来进行利用描述。

题目直接给出了程序基址。

然后存在明显的溢出,可以覆盖stdout,但是无法覆盖stdout的vtable,因为它会修正。

具体该如何利用呢,首先使用stdout任意读来泄露libc地址。构造的FILE结构体如下(使用pwn_debugIO_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字段的构造,可能某个地方不注意就导致程序走偏了,因此感觉可能还是把默认的stdoutstdin直接拷贝出来用会比较好一些,同时pwn_debugIO_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模块。

最后一句,阅读源码对于学习是一件很有帮助的事情。

参考链接

  1. HCTF 2018 部分 PWN writeup--babyprinf_ver2
  2. 浅析IO_FILE结构及利用
  3. 教练!那根本不是IO!——从printf源码看libc的IO

关键词:[‘安全技术’, ‘二进制安全’]


author

旭达网络

旭达网络技术博客,曾记录各种技术问题,一贴搞定.
本文采用知识共享署名 4.0 国际许可协议进行许可。

We notice you're using an adblocker. If you like our webite please keep us running by whitelisting this site in your ad blocker. We’re serving quality, related ads only. Thank you!

I've whitelisted your website.

Not now