堆内存概述、Tcache Poisoning

栈这种数据结构具有后进先出的存取原则,并且内存上的数据都紧凑排列。这些特性使针对栈的内存管理相对较为简单,也天生适合进行程序的执行流控制。程序在运行时会通过栈进行函数的过程调用、局部变量的存储,如果攻击者在栈上进行一些破坏(进行ROP攻击)就能直接劫持程序执行流,造成严重的后果。

个人十分推荐下面这位UP的视频对栈进行初步的学习,又或是加深理解深度。

https://www.bilibili.com/video/BV1mi7u6nE7e/?share_source=copy_web&vd_source=bfaef5666a51431addb223130f3c190c

堆前置

回顾程序加载进内存后的结构图,与栈不同,堆区域的内存是向上增长的。因为堆内存和栈内存都是程序在运行时大小动态变化的区域,所以在程序加载进内存时二者之间会有一个很大的空隙并且二者的增长方向不同,这很大程度上保证了二者的独立性。

在堆和栈之间的中间大一部分其实还有一个内存映射段,该段上用于加载外部对象进行动态链接、进程间通信、文件 I/O、大内存分配等核心功能。

什么是堆?

在平常的编程中,调用 malloc 函数就能返回一个指针,然后就可以通过这个指针往指向的内存区域写入数据。其实该指针指向的那一块空间并不是堆块的全貌。在 Linux 中,用户申请的堆可以视作一个结构体。

/*
  This struct declaration is misleading (but accurate and necessary).
  It declares a "view" into memory allowing access to necessary
  fields at known offsets from a given base. See explanation below.
*/
struct malloc_chunk {

  INTERNAL_SIZE_T      prev_size;  /* Size of previous chunk (if free).  */
  INTERNAL_SIZE_T      size;       /* Size in bytes, including overhead. */

  struct malloc_chunk* fd;         /* double links -- used only if free. */
  struct malloc_chunk* bk;

  /* Only used for large blocks: pointer to next larger size.  */
  struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
  struct malloc_chunk* bk_nextsize;
};

当然严格上说,直接把 malloc 视为上文定义的结构体是不准确的,malloc_chunk 这个数据结构更多是在堆空间被释放后为了便于管理而使用的。一个刚通过 malloc 申请出的内存空间上,只有堆块头的 prev_szie 、size 字段是被预定义且当前堆块无法使用的,所以从堆块头部往后偏移 0x10 字节是 malloc 返回的指针地址。直到该堆块被释放(前提是被交给堆管理器),整个堆块才会被补上 fd 等字段形成一个完整的 malloc_chunk 结构体。

prev_szie 字段是个较有意思的字段,只有当物理内存空间上紧挨该堆块的前一个堆块(低地址方向)也是空闲状态,该字段才会有值。如果低地址方向上的堆块为使用状态,那么这个字段就没有意义了,其反而可以被低地址方向上堆块的用户空间使用。

prev_szie 字段本设计用于堆块的合并过程,但是只要相邻两个堆块的任意一方都是使用状态,这个字段就没有意义,所以该字段被前一个堆块占用是完全合法的。当前一个堆块被释放后,堆管理器会正确修改后一个堆块的 prev_szie 值使其正确。

堆内存的分配与释放

函数调用视角

在C语言中会使用 malloc 和 free 函数分别向操作系统申请、释放堆块。二者的函数签名如下

void *malloc(size_t size);

malloc 函数的形参 size 用于告诉操作系统需要申请的内存大小,返回类型 void * 表示返回一个未知类型的指针,该指针指向的就是申请到的内存块(具体说是该内存块的用户空间),堆块的数据结构在 Linux 中定义为 malloc_chunk 。

malloc 函数直接从操作系统获取堆块,不会初始化内存,所以如果该内存是从堆管理器中取出的,可能还会携带数据。
除了介绍的 malloc 函数,C语言还提供了一些其他申请堆块的函数,这些函数大多在获取堆块后还对该内存区域做出了一些别的操作,比如 calloc ,该函数在获取堆块后还会把堆块内存置零,保证其没有历史数据残留。

void free(void *ptr);

free 函数的形参 ptr 是一个原先从 malloc 或其他方式获取的指针,用于告诉操作系统某一堆块已经不被使用。

系统调用视角

brk 和 mmap 是程序调用 malloc 向操作系统申请内存时会调用到的系统调用。brk 在 glibc 中被提供为 sbrk,但二者的作用都是通过修改当前进程的 brk 指针,进而控制堆内存的边界。而 mmap 用于从更大的内存映射段中寻找一块空闲区域来创建映射。

所以可以发现,malloc 更本质的理解其实是一个动态分配内存的接口,而不是专注于堆内存的分配,当然大多数情况下用户申请的内存大小不大,一般也都是从堆区直接申请内存即可。通常 128KB 是 malloc 选择使用 brk 还是 mmap 的分界线。

brk 指针底层由操作系统进行维护,每一个进程都会有一个 brk 指针指向它们虚拟内存中堆内存的边界,对于系统调用来说 brk 函数需要直接指定下一次 brk 需要指向的地址。虽然这个地址是随意传入的,但操作系统内核实际将 brk 移动到的内存地址一定是内存页对齐(4KB)的。

int brk(void *addr);

该系统调用成功返回 0,失败返回 -1 。

glibc 则对该系统调用进行了封装,使其可以通过传入增量的方式对 brk 指针进行修改,也就是 sbrk 。

void *sbrk(intptr_t increment);

函数调用成功会返回旧的 brk 指针,失败会返回 (void*) -1 ,该值被视为错误提示符。

brk 和 mmap 二者都会返回新开辟内存的起始指针,所以对于 malloc 函数而言,不论使用 brk 和 mmap 对操作内核申请的内存空间效果都是一样的,malloc 在后期对堆块进行加工为 chunk 后就能返回给用户。

从释放内存的角度看,free 会有两条路径可选。如果前面的堆块是 malloc 通过 mmap 系统调用获取的内存,由于该内存块一般较大,操作系统通常会立即通过 munmap 系统调用将该块内存返还,以收回内存资源。

反之,如果 malloc 是通过改变 brk 指针获得的空间,那么这一块空间很有可能会交给堆管理器,并记录进一个链表中加以管理。当后续用户申请相同大小的堆块时,堆管理器就能够迅速将位于链表中的堆块返还给用户,避免了系统调用带来的沉重开销。

Ptmalloc2 下的堆块缓存机制

对于操作系统而言,进行一次系统调用的开销是十分可观的。所以对于用户释放掉的一些较小的堆块,大多数情况下并不会直接返还给操作系统而是交由堆管理器(Linux 下为 Ptmalloc2)进行维护,当用户再次申请大小相同的堆块时,堆管理器会从缓存中遍历,若能找到大小合适的空闲堆块就能都直接返回。Ptmalloc 充当用户调用 malloc 或 free 和操作系统执行 brk 和 mmap 等系统调用之间的中间层,决定释放堆块时堆块的去向。

当堆块被堆管理器决定放入缓存,堆管理器会使用一个链表进行维护,这个链表串联了相同大小的空闲堆块。根据堆块大小的不同,堆管理器使用不同的机制去进行管理,比如 Tcache 、Fastbin 、Unsortedbin 等等。

Tcache 缓存机制

Tcache 指 Thread Local Caching 也就是本地线程缓存,所以 Tcache 中的堆块通常独属于某一个线程,这样避免了多线程情况下对主分配区的锁竞争进而提升了性能。管理 Tcache 的结构体定义为 tcache_perthread_struct ,该结构体在线程所属的堆区被定义,理论上每一个线程都可以拥有一份独属的 tcache_perthread_struct 。

这里说“线程所属的堆区”,但是程序加载进内存后堆内存不就只有一处吗?这涉及到程序整个堆内存的分配逻辑。
前面提到的堆区 heap 指的是进程级别的虚拟地址空间,这一大块区域只有一块,被称作主 heap。主 heap 会划分出多块 arena 分配区供不同线程使用,arena 是线程级的,其中主线程会优先使用其中的 main arena ,其他线程则使用其他 arena 。
arena 不是简单的一对一关系,arena 的数量存在上限。当线程数量未达到 arena 的数量上限时,每个线程都会有一个独属的 arena ,这样减少了锁竞争。当线程数量超过 arena 的数量上限后,多个线程只能共享一个 arena ,此时则需要通过锁竞争来抢夺内存资源。所以 main arena 也有可能被其他的普通线程使用。
但是 arena 不一定是由主 heap 分配出来的,如果内存空间是通过 mmap 从内存映射段中获取的内存,这一块内存也可以用来分配多个 arena,此时相对主 heap 而言就出现了多个 heap。

然后 arena 内部才可以分出 chunk 用于直接返回堆块。但有时直接通过 mmap 申请的一大块内存也可以直接成为 chunk 返回,该 chunk 不属于任何 arena 的堆区,在释放后会直接返还给操作系统。

在 tcache_perthread_struct 中存在两个字段 counts 和 entries 。

typedef struct tcache_perthread_struct
{
  char counts[TCACHE_MAX_BINS];
  tcache_entry *entries[TCACHE_MAX_BINS];
} tcache_perthread_struct;

由于对于单个线程而言 Tcache 会将所有大小相同的堆块放进一个链表, counts 字段用于记录每个链表内包含的空闲堆块数。Tcache 回收的堆块范围在 0x20 ~ 0x410 之间(glibc 2.26 及以上),按照以 0x10 字节作为步长,则可以划分出 64 个 bin,所以 TCACHE_MAX_BINS 的值一般为 64 。entries 字段指向每个 bin 中 chunk 链表的头指针,如果 entries[i] == NULL 说明该大小档位的 bin 中还没有空闲堆块。

当 malloc 从 Tcache 中获取缓存堆块时,几乎就可以直接使用 entries 字段获取该链表上的头部指针获取该堆块,但 Tcache 的每个链表都有数量上限为 7 个,当链表内的堆块已经有 7 个后,新释放的堆块只能被交给其他的缓存的机制,比如 Fastbin 。由于最新释放的堆块会被放进链表的头部,所以还可以看出 Tcahce 的存取规则是先进后出的 LIFO 原则。

从单个堆块视角看,依然保持了 malloc_chunk 的堆块头结构。但是 Tcache 为了追求性能,使用了单向链表进行管理,在用户区域的起始地址使用 8 个字节作为 next 指针,改指针用于指向位于同一链表中下一个堆块用户区的地址。

glibc 2.32 起, entries 中的 next 指针受 Safe-Linking 保护,不再是明文地址,next 指针的真实地址会先右移 12 位,然后与存储该 next 指针的内存地址(即 &e->next)进行异或运算,得到最终存储的混淆值。该过程由以下宏完成。

#define PROTECT_PTR(pos, ptr) \
  ((__typeof(ptr)) ((((size_t)pos) >> 12) ^ ((size_t)ptr)))

Safe-Linking 下,next 指针被加密为 real ^ (pos >> 12),其中 pos 是存储该 next 字段的内存地址。由于 ASLR,每次程序启动时堆基址不同,pos >> 12 也不同,所以攻击者无法在静态阶段预先构造合法的加密指针,必须在运行时获知相关地址信息。

具体到 Tcahce 中的堆块,使用的是 tcache_entry 结构体,早期该结构体只有一个字段用于串联整个链表。

typedef struct tcache_entry
{
  struct tcache_entry *next;
} tcache_entry;

后续,该结构体添加了一个 key 字段,主要用于检测 Double Free 攻击。该字段在堆块第一次被放入 Tcache 时会被设置为一个特定的值,后续的每个堆块再尝试进入 Tcache 会校验该堆块的 key 位置是否存在且内容与设定的值相同,倘若相同堆管理器可以很大程度怀疑该堆块受到了 Double Free 攻击。

typedef struct tcache_entry
{
  struct tcache_entry *next;
  struct tcache_perthread_struct *key;
} tcache_entry;

根据 glibc 版本不同,在 2.29 版本以后 key 字段就是当前线程存放 tcache_perthread_struct 的地址,再往 2.34 版本以后该字段的值是一个进程级的随机数,数据类型也变成了uintptr_t 无符号整型。

typedef struct tcache_entry
{
  struct tcache_entry *next;
  uintptr_t key;
} tcache_entry;

Tcache Poisoning

由于堆并不直接参与程序的执行流程,无法像栈一样进行 ROP、JOP 攻击,因此基于堆的攻击主要以读写任意内存为目标,这就离不开堆管理器的缓存机制。

在堆管理器中,空闲堆块由一个链表进行表示。这说明,用户从缓存中获取的下一个堆块完全取决于上一个堆块指向的地方,如果攻击者能够篡改这些指针,程序就很有可能把一些敏感内存返回(例如栈的内存地址、虚函数表),再获取这些内存后,又很容易将其覆盖为攻击者期望的值。这就是通过堆进行二进制利用堆核心思路。

在 Tcache 中,核心的思路就是篡改 next 指针。比如攻击者通过 UAF 等漏洞将 tcache_entry 的 next 字段指向 __free_hook 的地址,并在上面写入 system 函数的地址,那么再每次调用 free 函数前都会进行 system 的调用,攻击者只需要提前布置参数即可。

在上面的链表中,位于链表第二个内存块的指针被篡改为指向 __free_hook 区域的指针,所以攻击者此时再尝试从 Tcache 中获取三次堆块,那么第三次返回的指针将会指向 __free_hook 。

__free_hook 是 glibc 中的一个全局函数指针,属于内存分配器的调试钩子。

它的功能是当程序调用 free 释放内存时,glibc 会先检查这个指针是否非空;若非空,就调用它指向的函数,然后再执行正常的释放逻辑。开发者可用它来追踪内存释放行为,而在漏洞利用中,攻击者常通过堆漏洞覆盖它,劫持 free 的执行流。

需注意从 glibc 2.34 起该钩子已被移除,不再被调用。