利用_IO_2_1_stdout_泄露libc

利用_IO_2_1_stdout_泄露libc

前置知识

stdin/stdout/stderr

在 Linux 系统中,"标准流"(Standard Streams)是程序与外部环境进行数据交互的核心机制,标准流主要包括以下三种类型:

  1. 标准输入(stdin):这是程序或shell接收数据的通道。通常情况下,当你在命令行中输入内容时,就是通过标准输入将这些内容传递给程序的。例如,当你输入ls命令来列出目录内容时,这个命令就是通过标准输入传递给系统的。
  2. 标准输出(stdout):这是用于输出程序运行的正常结果的通道。继续以ls命令为例,该命令执行后,列出的目录内容就是通过标准输出显示在你终端上的。
  3. 标准错误(stderr):这也是一种输出通道,不过它专门用于输出错误信息或者诊断信息。比如你用 ls 去访问一个不存在的路径,终端会提示 “No such file or directory(没有那个文件或目录)”,这类错误信息就是通过标准错误通道输出的。

_IO_2_1_stdout_

glibc 内部定义的 全局变量,存储 stdout 流的完整状态以及很多函数指针,在标准IO函数中会调用这些函数指针,因此当程序没有输出功能时,可以劫_IO_2_1_stdout_泄露libc

FILE结构

_IO_FILE_plus

struct _IO_FILE_plus
{
  _IO_FILE file;
  const struct _IO_jump_t *vtable;
};

然后我们分别看下他的两个组成部分

_IO_FILE

FILE结构在程序执行fopen函数时会自动进行创建,并分配在堆中。

FILE结构定义在glibc/libio/libio.h中,结构源码如下

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
};

进程中的FILE结构会通过_chain域彼此连接形成一个链表,链表头部用全局变量_IO_list_all表示,通过这个值可以遍历所有的FILE结构,大致的链表结构如下图:

img

在标准I/O库中,每个程序启动时有三个文件流是自动打开的:stdin、stdout、stderr。因为会自动打开,所以在初始状态下,_IO_list_all指向了一个有这些文件流构成的链表,但是需要注意的是这三个文件流位于的是libc.so的数据段

_IO_jump_t

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);
};

vtable即虚表,内部存储着很多函数指针,当用户调用标准IO函数时就会查看虚表,然后找对应的函数指针,完成输入或输出。

_IO_2_1_stdout_

在了解完结构体后我们再深入理解下_IO_2_1_stdout_的类型以及和他相关联的结构体

struct _IO_FILE_plus;

extern struct _IO_FILE_plus _IO_2_1_stdin_;
extern struct _IO_FILE_plus _IO_2_1_stdout_;
extern struct _IO_FILE_plus _IO_2_1_stderr_;

_flags规则

通过上述对就结构体的了解,我们将深入讲解下第一个成员变量_flags,这个变量在利用_IO_2_1_stdout_泄露libc有至关重要的作用。

先简单介绍一下_flag的规则,_flag的高两位字节是由libc固定的,不同的libc可能存在差异,但是基本上都一样:0xfbad0000。高两位字节其实就是作为一个标识,标志这是一个什么文件。而低两位字节的位数规则决定了程序的执行状态,低两位的规则如下:

#define _IO_MAGIC 0xFBAD0000 /* Magic number */
#define _OLD_STDIO_MAGIC 0xFABC0000 /* Emulate old stdio. */
#define _IO_MAGIC_MASK 0xFFFF0000
#define _IO_USER_BUF 1 /* User owns buffer; don't delete it on close. */
#define _IO_UNBUFFERED 2
#define _IO_NO_READS 4 /* Reading not allowed */
#define _IO_NO_WRITES 8 /* Writing not allowd */
#define _IO_EOF_SEEN 0x10
#define _IO_ERR_SEEN 0x20
#define _IO_DELETE_DONT_CLOSE 0x40 /* Don't call close(_fileno) on cleanup. */
#define _IO_LINKED 0x80 /* Set if linked (using _chain) to streambuf::_list_all.*/
#define _IO_IN_BACKUP 0x100
#define _IO_LINE_BUF 0x200
#define _IO_TIED_PUT_GET 0x400 /* Set if put and get pointer logicly tied. */
#define _IO_CURRENTLY_PUTTING 0x800
#define _IO_IS_APPENDING 0x1000
#define _IO_IS_FILEBUF 0x2000
#define _IO_BAD_SEEN 0x4000
#define _IO_USER_LOCK 0x8000

#define _IO_FLAGS2_MMAP 1
#define _IO_FLAGS2_NOTCANCEL 2

一般在执行流程中会将_flag和定义常量进行按位与运算,并根据与运算的结构进行判断如何执行。

puts()函数执行流程

接下来我们以put()函数为例,看下函数的实际执行逻辑是怎样的。

总览:

_IO_puts --> _IO_new_file_xsputn--> _IO_do_write --> new_do_write--> _IO_SYSWRITE

_IO_puts --> _IO_new_file_xsputn

put()函数在glibc源码中表现形式为_IO_puts

int
_IO_puts (const char *str)
{
  int result = EOF;
  _IO_size_t len = strlen (str);
  _IO_acquire_lock (_IO_stdout);

  if ((_IO_vtable_offset (_IO_stdout) != 0
       || _IO_fwide (_IO_stdout, -1) == -1)
      && _IO_sputn (_IO_stdout, str, len) == len
      && _IO_putc_unlocked ('\n', _IO_stdout) != EOF)
    result = MIN (INT_MAX, len + 1);

  _IO_release_lock (_IO_stdout);
  return result;
}
#define _IO_sputn(__fp, __s, __n) _IO_XSPUTN (__fp, __s, __n)

这里可以看到_IO_puts在过程当中调用了一个叫做_IO_sputn函数(_IO_fwrite也会调用这个),_IO_sputn其实是一个宏,定义在glibc/libio/libioP.h,它的作用就是调用_IO_2_1_stdout_中的vtable所指向的_xsputn,也就是_IO_new_file_xsputn函数

_IO_new_file_xsputn --> _IO_OVERFLOW

简单的描述一下_IO_new_file_xsputn函数的执行过程,在关键部分展示代码._

介绍之前,先说明三个指针的意义

_IO_write_base -->缓冲区起始地址
_IO_write_ptr -->下一个写入的地址
_IO_write_end-->缓冲区结尾地址

首先进入函数之后判断输出缓冲区还有多少空间,这里是由_IO_write_end - _IO_write_base得来的,接下来如果缓冲区有空间,则先把数据载入输出缓冲区,然后判断是否有数据还需要写入或者必须刷新缓冲区。

如果条件满足,代码首先尝试刷新缓冲区。通过_IO_OVERFLOW(f, EOF)尝试刷新缓冲区。_IO_OVERFLOW就是vtable中的__overflow。

      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;

_IO_new_file_overflow --> _IO_do_write

int
_IO_new_file_overflow (_IO_FILE *f, int ch)
{
    //绕过条件一
  if (f->_flags & _IO_NO_WRITES) /* SET ERROR */
    {
      f->_flags |= _IO_ERR_SEEN;
      __set_errno (EBADF);
      return EOF;
    }
    //绕过条件二
  /* If currently reading or no buffer allocated. */
  if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0 || f->_IO_write_base == NULL)
    {
      /* Allocate a buffer if needed. */
      if (f->_IO_write_base == NULL)
	{
	  _IO_doallocbuf (f);
	  _IO_setg (f, f->_IO_buf_base, f->_IO_buf_base, f->_IO_buf_base);
	}
      /* Otherwise must be currently reading.
	 If _IO_read_ptr (and hence also _IO_read_end) is at the buffer end,
	 logically slide the buffer forwards one block (by setting the
	 read pointers to all point at the beginning of the block).  This
	 makes room for subsequent output.
	 Otherwise, set the read pointers to _IO_read_end (leaving that
	 alone, so it can continue to correspond to the external position). */
      if (__glibc_unlikely (_IO_in_backup (f)))
	{
	  size_t nbackup = f->_IO_read_end - f->_IO_read_ptr;
	  _IO_free_backup_area (f);
	  f->_IO_read_base -= MIN (nbackup,
				   f->_IO_read_base - f->_IO_buf_base);
	  f->_IO_read_ptr = f->_IO_read_base;
	}

      if (f->_IO_read_ptr == f->_IO_buf_end)
	f->_IO_read_end = f->_IO_read_ptr = f->_IO_buf_base;
      f->_IO_write_ptr = f->_IO_read_ptr;
      f->_IO_write_base = f->_IO_write_ptr;
      f->_IO_write_end = f->_IO_buf_end;
      f->_IO_read_base = f->_IO_read_ptr = f->_IO_read_end;

      f->_flags |= _IO_CURRENTLY_PUTTING;
      if (f->_mode <= 0 && f->_flags & (_IO_LINE_BUF | _IO_UNBUFFERED))
	f->_IO_write_end = f->_IO_write_ptr;
    }
  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;
  *f->_IO_write_ptr++ = ch;
  if ((f->_flags & _IO_UNBUFFERED)
      || ((f->_flags & _IO_LINE_BUF) && ch == '\n'))
    if (_IO_do_write (f, f->_IO_write_base,
		      f->_IO_write_ptr - f->_IO_write_base) == EOF)
      return EOF;
  return (unsigned char) ch;
}

上面即是_IO_new_file_overflow函数的部分代码,我们想要利用的就是最后红色框中的_IO_do_write (f, f->_IO_write_base,f->_IO_write_ptr - f->_IO_write_base),_IO_do_write就是我们需要执行的目标函数,这个函数执行后会调用系统调用write输出缓冲区,传入_IO_do_write函数的参数为:stdout结构体、_IO_write_base和size(_IO_write_end - _IO_write_base计算得来)

如果我们事先在stdout的_IO_write_base的位置部署要输出的起始地址,那么在去利用_IO_do_write函数,即可打印部分内存地址,打印出来的内容就包含我们所需要泄露的libc

但是如果我们要利用_IO_do_write需要绕过两个检查:

  if (f->_flags & _IO_NO_WRITES)

这里判断_flags的标志位是否包含_IO_NO_WRITES,将_flags和_IO_NO_WRITES进行一个按位与的操作,我们可以向前翻一下_flag规则的章节,_flag与_IO_NO_WRITES各自定义的常量为:

#define _IO_MAGIC 0xFBAD0000 /* 魔数 */
#define _IO_NO_WRITES 8 /* 不可写 */

可以看到_flag魔数的常量为0xfbad0000,_IO_NO_WRITES不可写标志位的常量为8,我们返回上图的程序中,如果进行按位与操作之后的结果为真,则返回为错误。一旦返回的是错误,那么后续我们想要利用的_IO_do_write函数就不会再被执行了,所以我们要将此处的与运算为假

#define _IO_MAGIC 0xFBAD0000
#define _IO_NO_WRITES 8
_flags & _IO_NO_WRITES = 0 
_flags = 0xfbad0000

然后我们看第二个条件

if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0 || f->_IO_write_base == NULL)

第二个判断是为了检查输出缓冲区是否为空,如果为空则进行分配空间,并且会初始化指针。一旦进行初始化操作,那么就会覆盖掉我们事先在stdout的_IO_write_base的数据,这样一来我们无法改写_IO_write_base。所以这个判断条件分支尽可能的也不进入。

因此绕过:

#define _IO_MAGIC 0xFBAD0000
#define _IO_CURRENTLY_PUTTING 0x800
f->_flags & _IO_CURRENTLY_PUTTING = 1
_flags = 0xfbad0800

而第二个条件因为我们会部署_IO_write_base,因此很好绕过

_IO_new_do_write --> new_do_write

int
_IO_new_do_write (_IO_FILE *fp, const char *data, _IO_size_t to_do)
{
  return (to_do == 0
	  || (_IO_size_t) new_do_write (fp, data, to_do) == to_do) ? 0 : EOF;
}

可以看到_IO_new_do_write并没有做太多的操作,就调用了new_do_write函数,并且new_do_write函数的参数和传入的参数是一样的,第一个参数是stdout结构体,第二个参数是输出缓冲区起始地址,第三个参数是输出长度

new_do_write --> _IO_SYSWRITE

static size_t
new_do_write (FILE *fp, const char *data, size_t to_do)
{
  size_t count;
  if (fp->_flags & _IO_IS_APPENDING)
    /* On a system without a proper O_APPEND implementation,
       you would need to sys_seek(0, SEEK_END) here, but is
       not needed nor desirable for Unix- or Posix-like systems.
       Instead, just indicate that offset (before and after) is
       unpredictable. */
    fp->_offset = _IO_pos_BAD;
  else if (fp->_IO_read_end != fp->_IO_write_base)
    {
      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);
  if (fp->_cur_column && count)
    fp->_cur_column = _IO_adjust_column (fp->_cur_column - 1, data, count) + 1;
  _IO_setg (fp, fp->_IO_buf_base, fp->_IO_buf_base, fp->_IO_buf_base);
  fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_buf_base;
  fp->_IO_write_end = (fp->_mode <= 0
		       && (fp->_flags & (_IO_LINE_BUF | _IO_UNBUFFERED))
		       ? fp->_IO_buf_base : fp->_IO_buf_end);
  return count;
}

这里有一个if和else if条件绕过,两个条件都绕过难度较高,可以利用性也不强,我们可以通过满足一个条件而绕过另一个条件,这里我们通过构造_flag满足if条件,从而绕过else if,这么做的原因是:

一般在做这种题的时候都会伴随着随机化保护的开启,进行攻击的时候,我们一般采用的都是覆盖末位字节的方式造成偏移,因为即使随机化偏移也会存在0x1000对齐。但是这时候就会遇到一个很尴尬的情况,_IO_read_end和_IO_write_base存放的地址是由末位字节和其他高字节共同组成的,其他高字节由于随机化的缘故无法确定,基本上这个地方很难绕过。而一旦进行到else if 内那随后执行的_IO_SYSSEEK函数会因为fp->_IO_write_base - fp->_IO_read_end难以控制导致执行失败。

而if分支相对来说造成的影响就比较小了,内部仅仅将偏移设置为标准值,不会影响后续的输出流程。并且if判断的条件也很容易满足,我们只需要将fp->_flags & _IO_IS_APPENDING != 0即可:

#define _IO_MAGIC 0xFBAD0000
#define _IO_IS_APPENDING 0x1000
fp->_flags & _IO_IS_APPENDING = 1
_flags = 0xfbad1000

总结

在实际操作中,我们只需要满足_flag为0xFBAD1800,将之后24个字节置零即_IO_read_ptr,_IO_read_end,_IO_read_base这三个8字节的地址置为0。之后的_IO_write_base与_IO_write_ptr分别为我们想要泄露的地址的起止点,之后遇到puts或printf,就会将_IO_write_base指向的内容打印出来。。

参考文章

好好说话之IO_FILE利用(1):利用_IO_2_1_stdout泄露libc_libc泄露方式-CSDN博客

posted @ 2026-02-13 11:59  sprYAaaa  阅读(57)  评论(0)    收藏  举报