REinject's Blog

我们的功夫再棒,也敌不过洋枪


覆盖 _IO_2_1_stdout 泄漏 libc 地址

PWN 类型的题基本上都需要用到 libc 的地址,一般情况可以通过获取程序 GOT 表填充的 libc API 地址通过相对偏移计算出 libc 基址。但是也有时候没办法直接读 GOT,这时候如果可以实现任意位置写,那通过覆盖 _IO_2_1_stdout 的方式就可以泄漏 libc 地址。

操作上比较简单,直接把 _IO_2_1_stdout 结构开头的 flag 置为 0x00000000fbad1800,并将 _IO_write_base 低字节位改小,然后等着程序调用 putsprintf 函数即可将 libc 地址泄漏到标准输出里。

还记得学习 C 代码第一课 —— 打印 Hello, world! 吗:

#include <stdio.h>

int main() {
  printf("Hello, world!\n");
  return 0;
}

只需要导入 stdio.h 这个头,就可以完成程序的输入输出功能,stdio.h 头就是由 glibc 提供的 (源码)。

所以就从这个 printf 开始了解下为什么可以泄漏 libc 地址吧。

因为 printf 除了输出字符串,还提供了字符串格式化的功能,内部代码比较多,所以看得时候跳过字符串格式化的部分,只看输出相关(或者从相对简单的 puts 开始)。

glibc-2.27 中找到 printf 的实现代码 printf.c#L27

int
__printf (const char *format, ...)
{
  va_list arg;
  int done;

  va_start (arg, format);
  done = vfprintf (stdout, format, arg);
  va_end (arg);

  return done;
}

内部调用 vfprintf,在 vprintf.c#L28 实现:

int
__vprintf (const char *format, __gnuc_va_list arg)
{
  return vfprintf (stdout, format, arg);
}

从这里可以看到调用了 vfprintf 完成字符串格式化操作,并输出到 stdout,从名字看就知道是标准输出,基于对 linux 的了解,一个进程的标准输入 (stdin)、标准输出 (stdout)、标准错误输出 (stderr) 分别和文件描述符 (fd) 0、1、2 绑定,在 libc 中找到相关定义在 stdio.c#L33

_IO_FILE *stdin = (FILE *) &_IO_2_1_stdin_;
_IO_FILE *stdout = (FILE *) &_IO_2_1_stdout_;
_IO_FILE *stderr = (FILE *) &_IO_2_1_stderr_;

在这里看到了熟悉的 _IO_2_1_stdout_,从这里可知,stdout_IO_2_1_stdout_ 的指针。关于 _IO_2_1_stdout_ 的实现在 stdfiles.c#L53

# define DEF_STDFILE(NAME, FD, CHAIN, FLAGS) \
  static struct _IO_wide_data _IO_wide_data_##FD \
    = { ._wide_vtable = &_IO_wfile_jumps }; \
  struct _IO_FILE_plus NAME \
    = {FILEBUF_LITERAL(CHAIN, FLAGS, FD, &_IO_wide_data_##FD), \
       &_IO_file_jumps};

DEF_STDFILE(_IO_2_1_stdin_, 0, 0, _IO_NO_WRITES);
DEF_STDFILE(_IO_2_1_stdout_, 1, &_IO_2_1_stdin_, _IO_NO_READS);
DEF_STDFILE(_IO_2_1_stderr_, 2, &_IO_2_1_stdout_, _IO_NO_READS+_IO_UNBUFFERED);

后面再来继续分析 stdout,先继续看 vfprintf,实现位置 vfprintf.c#L1243,真正的 printf 功能实现代码是这个函数里,所以这个函数代码很长就不贴了:

int
vfprintf (FILE *s, const CHAR_T *format, va_list ap)     // 记住这里 s = stdout = &_IO_2_1_stdout_

通过分析可知该函数实际调用了 process_argprocess_string_argoutcharoutstring 这四个宏定义完成字符串输出,process_argprocess_string_arg 实际也是调用 outcharoutstring,所以只需要关注 outcharoutstring

#define    outchar(Ch)                                         \
  do                                                           \
    {                                                          \
      const INT_T outc = (Ch);                                 \
      if (PUTC (outc, s) == EOF || done == INT_MAX)            \
  {                                                            \
    done = -1;                                                 \
    goto all_done;                                             \
  }                                                            \
      ++done;                                                  \
    }                                                          \
  while (0)

#define outstring(String, Len)                                 \
  do                                                           \
    {                                                          \
      assert ((size_t) done <= (size_t) INT_MAX);              \
      if ((size_t) PUT (s, (String), (Len)) != (size_t) (Len)) \
  {                                                            \
    done = -1;                                                 \
    goto all_done;                                             \
  }                                                            \
      if (__glibc_unlikely (INT_MAX - done < (Len)))           \
      {                                                        \
  done = -1;                                                   \
   __set_errno (EOVERFLOW);                                    \
  goto all_done;                                               \
      }                                                        \
      done += (Len);                                           \
    }                                                          \
  while (0)

这两个宏定义通过 PUTCPUT 两个宏定义完成字符、字符串的输出,最终调用的是 _IO_putc_unlocked_IO_sputn

# define PUTC(C, F)    _IO_putc_unlocked (C, F)            // F = s = stdout = &_IO_2_1_stdout_
# define PUT(F, S, N)  _IO_sputn ((F), (S), (N))

_IO_putc_unlocked_IO_sputn 分别定义在 libio.h#L411libioP.h#L377 中,一个对应 putc,一个对应 puts,弄懂其中一个另外一个也就明白了,_IO_putc_unlocked 比较简单,就以这个为切入口:

// putc
// libio.h
#define _IO_putc_unlocked(_ch, _fp) \
   (_IO_BE ((_fp)->_IO_write_ptr >= (_fp)->_IO_write_end, 0) \
    ? __overflow (_fp, (unsigned char) (_ch)) \
    : (unsigned char) (*(_fp)->_IO_write_ptr++ = (_ch)))

这段代码的意思是当 (_fp)->_IO_write_ptr 到达 (_fp)->_IO_write_end 位置就调用 __overflow 刷新缓冲区到文件流,否则将字符 _ch 写入到 _IO_write_ptr 位置并使之后移。_IO_write_ptr_IO_write_end 都是什么,这时候就需要继续分析 stdout 结构,前面知道它是 _IO_2_1_stdout_ 的指针,_IO_2_1_stdout_ 是一个被声明为 _IO_FILE_plus 结构体类型的全局变量:

// https://github.com/bminor/glibc/blob/glibc-2.27/libio/bits/libio.h#L320
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_;

// https://github.com/bminor/glibc/blob/glibc-2.27/libio/libioP.h#L322
struct _IO_FILE_plus
{
  _IO_FILE file;
  const struct _IO_jump_t *vtable;
};

// https://github.com/bminor/glibc/blob/glibc-2.27/libio/bits/libio.h#L245
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
};

// https://github.com/bminor/glibc/blob/glibc-2.27/libio/libioP.h#L287
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);
#if 0
    get_column;
    set_column;
#endif
};

_IO_FILE_plus 是在 _IO_FILE 的基础上扩充了一个类 c++ 的虚函数表字段 vtable,所以 _IO_2_1_stdout_ 经常被统一强转成 _IO_FILE 进行参数传递,这时 ((_IO_FILE *)stdout)->_IO_write_ptr 等价于 stdout->file._IO_write_ptr

然后需要知道这个结构里这些字段的含义:

  • _flags:之前说的要把 _IO_2_1_stdout_ 开头覆盖为 0x00000000fbad1800,其实被覆盖的就是这个字段,它包含了一组位标志,表示文件流的不同状态
  • 一些缓冲区相关的指针
    • _IO_read_ptr:指向当前读取位置
    • _IO_read_end:指向读取结束位置
    • _IO_read_base:指向读取开始位置
    • _IO_write_base:指向写入开始位置
    • _IO_write_ptr:指向当前写入位置
    • _IO_write_end:指向写入结束位置
    • _IO_buf_base:指向缓冲区开始位置
    • _IO_buf_end:指向缓冲区结束位置

_IO_buf_base ~ _IO_buf_end 表示整个缓冲区范围,_IO_write_base ~ _IO_write_end 表示 put 缓冲区范围,_IO_read_base ~ _IO_read_end 表示 get 缓冲区范围,对于 stdout 来说应该只可能会有 put 缓冲区吧。

前面看到了当 _IO_write_ptr 到达 _IO_write_end 位置就会调用 __overflow 刷新缓冲区,用户输出内容实际是先被写到 _IO_write_ptr 指向的位置,最初指向 _IO_write_base,随着输出内容的增加,该指针不断向后移动,当到达 _IO_write_end 位置则表示 put 缓冲区被填满,这时才会调用 __overflow 将 put 缓冲区中的内容全部输出到文件流。

所以把 _IO_write_base 改小之后缓冲区变大,就可以使输出内容变多,至于为什么改小就能输出那么多 libc 的地址,这个后面再细究。但是实际上在调用 __overflow 时,_IO_write_base 的值受 _flags 标志位的影响会变动,所以需要通过控制标志位的值使 _IO_write_base 在调用 __overflow 过程中不被重置。

根据之前 _IO_2_1_stdout_ 的定义,可知 vtablefileops.c#L1455 中的 _IO_file_jumps 提供虚函数实现:

const struct _IO_jump_t _IO_file_jumps libio_vtable =
{
  JUMP_INIT_DUMMY,
  JUMP_INIT(finish, _IO_file_finish),
  JUMP_INIT(overflow, _IO_file_overflow),
  JUMP_INIT(underflow, _IO_file_underflow),
  JUMP_INIT(uflow, _IO_default_uflow),
  JUMP_INIT(pbackfail, _IO_default_pbackfail),
  JUMP_INIT(xsputn, _IO_file_xsputn),
  JUMP_INIT(xsgetn, _IO_file_xsgetn),
  JUMP_INIT(seekoff, _IO_new_file_seekoff),
  JUMP_INIT(seekpos, _IO_default_seekpos),
  JUMP_INIT(setbuf, _IO_new_file_setbuf),
  JUMP_INIT(sync, _IO_new_file_sync),
  JUMP_INIT(doallocate, _IO_file_doallocate),
  JUMP_INIT(read, _IO_file_read),
  JUMP_INIT(write, _IO_new_file_write),
  JUMP_INIT(seek, _IO_file_seek),
  JUMP_INIT(close, _IO_file_close),
  JUMP_INIT(stat, _IO_file_stat),
  JUMP_INIT(showmanyc, _IO_default_showmanyc),
  JUMP_INIT(imbue, _IO_default_imbue)
};
libc_hidden_data_def (_IO_file_jumps)

__overflow 对应的实现是 _IO_file_overflow,它是 _IO_new_file_overflow 函数的别名:

// https://github.com/bminor/glibc/blob/glibc-2.27/libio/fileops.c#L745
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 ((f->_flags & _IO_CURRENTLY_PUTTING) == 0 || f->_IO_write_base == NULL)
    {
        // ...
        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;
        // ...
    }
    // ...
    _IO_do_write (f, f->_IO_write_base,
            f->_IO_write_ptr - f->_IO_write_base);
    // ...
}

libc_hidden_ver (_IO_new_file_overflow, _IO_file_overflow)

上面只列出了会导致 _IO_write_base 被重置的部分,即:

  • _flags 不包含 _IO_NO_WRITES 函数会直接报错返回
  • _flags 不包含 _IO_CURRENTLY_PUTTING 函数会修改 _IO_write_base 指向 _IO_buf_base,也就是缓冲区开头

最后调用 _IO_do_write 完成缓冲区输出,它是 _IO_new_do_write 函数的别名,内部调用 new_do_write

// https://github.com/bminor/glibc/blob/glibc-2.27/libio/fileops.c#L430
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;
}
libc_hidden_ver (_IO_new_do_write, _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)
        /* 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)
    {
        _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);
    // ...
    _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;
    // ...
    return count;
}

函数中调用 _IO_SYSSEEK 会改变缓冲区写入文件位置,所以为了排除这个影响,需要使 _flags & _IO_IS_APPENDINGfp->_IO_read_end == fp->_IO_write_base 任一条件成立才能绕过,调用 _IO_SYSWRITE 将 put 缓冲区内容输出到标准输出文件流,输出之后 put、get 相关缓冲区指针都会被重置,完成缓冲区刷新操作。

经上可知,为了实现将篡改后 _IO_write_base 指向数据能被正确的写入到标准输出,需要使满足下面条件:

  • _flags & _IO_NO_WRITES == _IO_NO_WRITES
  • _flags & _IO_CURRENTLY_PUTTING == _IO_CURRENTLY_PUTTING
  • _flags & _IO_IS_APPENDING == _IO_IS_APPENDING_IO_read_end == _IO_write_base

所以使 _flags = 0x00000000fbad1800 刚好可以满足上面条件,然后控制 _IO_write_base 指针输出原始缓冲区之前的内容即可。

然后再来看为什么只需要将 _IO_write_base 改向前一些就可以输出 libc 地址,一般是把低位字节置为 0x00。

这里利用之前的 HelloWorld 代码做个测试,因为满足前面泄漏 libc 的条件,所以不出意外的话输出中包含许多 libc 地址:

#include <stdio.h>

int main() {
  setvbuf(stdout, 0, 2, 0);  // no buffer
  stdout->_flags = 0x00000000fbad1800;
  stdout->_IO_write_base = (char *)(((unsigned long long)stdout->_IO_write_base) & 0xffffffffffffffffff00);
  printf("Hello, world!\n");
  return 0;
}

g++ main.cpp 命令编译生成 a.out,用这个脚本加载获取返回内容:

from pwn import *

p = process("./a.out")
# gdb.attach(p)
print(p.recvall())

# pause()

输出如下:

[+] Starting local process './a.out': pid 14204
[+] Receiving all data: Done (81B)
[*] Process './a.out' stopped with exit code 0 (pid 14204)
b'DV\x0e\xed\xe0\x7f\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe0H\x0e\xed\xe0\x7f\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00Hello, world!\n

能发现正如预期一样,libc 地址泄漏了出来。

然后去掉 setvbuf(stdout, 0, 2, 0); 再试试呢,你会发现打印出来的只是 Hello, world!,libc 地址并没有被泄漏。

所以只有在将 put 缓冲区设置为 NULL 的时候,才可以泄漏出 libc 地址,这是因为这时候 put 缓冲区相关指针指向的是 _IO_2_1_stdout_ 中的 char _shortbuf[1] 字段,并且 _IO_2_1_stdout_ 整个结构都是静态存储在 libc.data 区域,所以此时泄漏的是 .data 区域的数据,里面包含很多 libc 地址。

默认情况下,整个缓冲区大小是 0x400,调用 malloc 分配在堆上,所以这时候 _IO_write_base 向前改小并不能泄漏 libc 地址。这块就不具体分析了,有兴趣可以看一下源码中 setbuf 相关的实现。

最后总结下:除了程序有任意位置写漏洞之外,还需要将 stdout 缓冲区设置为 NULL,才可以用这种方法泄漏 libc 地址。

相关题目: