pwn入门(堆的基础知识)

堆概述

什么是堆

在程序运行过程中,堆可以提供动态分配的内存,允许程序申请大小未知的内存。堆其实就是程序虚拟地址空间的一块连续的线性区域,它由低地址向高地址方向增长。我们一般称管理堆的那部分程序为堆管理器。

对于不同的应用来说,由于内存的需求各不相同等特性,因此目前堆的实现有很多种,具体如下

dlmalloc  – General purpose allocator 
ptmalloc2 – glibc 
jemalloc  – FreeBSD and Firefox 
tcmalloc  – Google 
libumem   – Solaris

目前 Linux 标准发行版中使用的堆分配器是 glibc 中的堆分配器:ptmalloc2。ptmalloc2 主要是通过 malloc/free 函数来分配和释放内存块。(glibc是最重要的堆管理器)

需要注意的是,在内存分配与使用的过程中,Linux 有这样的一个基本内存管理思想,只有当真正访问一个地址的时候,系统才会建立虚拟页面与物理页面的映射关系。 所以虽然操作系统已经给程序分配了很大的一块内存,但是这块内存其实只是虚拟内存。只有当用户使用到相应的内存时,系统才会真正分配物理页面给用户使用。

堆的基本操作
malloc函数

malloc 函数用来动态开辟空间,并返回对应大小字节的内存块的指针。此外,该函数还对一些异常情况进行了处理。

  • 当 n=0 时,返回当前系统允许的堆的最小内存块。
  • 当 n 为负数时,由于在大多数系统上,size_t 是无符号数(这一点非常重要),所以程序就会申请很大的内存空间,但通常来说都会失败,因为系统没有那么多的内存可以分配。
free函数

free 函数会释放由 p 所指向的内存块。这个内存块有可能是通过 malloc 函数得到的,也有可能是通过相关的函数 realloc 得到的。此外,该函数也同样对异常情况进行了处理

  • 当 p 为空指针时,函数不执行任何操作。
  • 当 p 已经被释放之后,再次释放会出现乱七八糟的效果,这其实就是 double free
  • 除了被禁用 (mallopt) 的情况下,当释放很大的内存空间时,程序会将这些内存空间还给系统,以便于减小程序所使用的内存空间。

堆相关的数据结构

内存分配背后的系统调用

在前面提到的函数中,无论是 malloc 函数还是 free 函数,我们动态申请和释放内存时,都经常会使用,但是它们并不是真正与系统交互的函数。这些函数背后的系统调用主要是 brk函数以及 mmap,munmap函数。

brk函数

对于堆的操作,操作系统提供了 brk 函数,glibc 库提供了 sbrk 函数,我们可以通过增加 brk 的大小来向操作系统申请内存。适用于申请比较小的空间。

初始时,堆的起始地址 start_brk以及堆的当前末尾 brk指向同一地址。动态开辟空间时data段向上扩展,就相当于将heap段的空间划分到data段。

mmap函数

malloc 会使用 mmap来创建独立的匿名映射段。匿名映射的目的主要是可以申请以 0 填充的内存,并且这块内存仅被调用进程所使用。适用于申请比较大的空间。

堆管理器如何工作
arena

操作系统 --> 堆管理器 --> 用户
物理内存 --> arena --> 可用内存

内存分配区,可以理解为堆管理器所持有的内存池(类似输入输出的缓冲区)。堆管理器与内存的交易发生在arena中,arena是一种数据结构用来控制从操作系统申请的数据。

特点:

一个线程只有一个arena,这些arena是互不相同的。主线程的arena称为main_arena,子线程的arena称为thread_arena。并不是是每个线程都有一个arena,其个数是有限制的。

chunk

用户申请内存的单位,也是堆管理器管理内存的基本单位,malloc( )返回的指针指向一个chunk的数据区域。

概述

在程序的执行过程中,我们称由 malloc 申请的内存为 chunk 。这块内存在 ptmalloc 内部用 malloc_chunk 结构体来表示。当程序申请的 chunk 被 free 后,会被加入到相应的空闲管理列表中。无论一个 chunk 的大小如何,处于分配状态还是释放状态,它们都使用一个统一的结构。虽然它们使用了同一个数据结构,但是根据是否被释放,它们的表现形式会有所不同。

chunk结构

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

截屏2024-08-14 11.26.32

每个字段的具体的解释如下

  • prev_size, 如果该 chunk 的**物理相邻的前一地址 chunk(两个指针的地址差值为前一 chunk 大小)**是空闲的话,那该字段记录的是前一个 chunk 的大小 (包括 chunk 头)。否则,该字段可以用来存储物理相邻的前一个 chunk 的数据。这里的前一 chunk 指的是较低地址的 chunk

  • size,表示该 chunk 的大小,大小必须是 2 * SIZE_SZ 的整数倍。如果申请的内存大小不是 2 * SIZE_SZ 的整数倍,会被转换满足大小的最小的 2 * SIZE_SZ 的倍数。32 位系统中,SIZE_SZ 是 4;64 位系统中,SIZE_SZ 是 8。 该字段的低三个比特位对 chunk 的大小没有影响,它们从高到低分别表示

    • NON_MAIN_ARENA,记录当前 chunk 是否不属于主线程,1 表示不属于,0 表示属于。
  • IS_MAPPED,记录当前 chunk 是否是由 mmap 分配的。

    • PREV_INUSE,记录前一个 chunk 块是否被分配。一般来说,堆中第一个被分配的内存块的 size 字段的 P 位都会被设置为 1,以便于防止访问前面的非法内存。当一个 chunk 的 size 的 P 位为 0 时,我们能通过 prev_size 字段来获取上一个 chunk 的大小以及地址。这也方便进行空闲 chunk 之间的合并。
  • fd,bk。 chunk 处于分配状态时,从 fd 字段开始是用户的数据。chunk 空闲时,会被添加到对应的空闲管理链表中,其字段的含义如下

    • fd 指向下一个(非物理相邻)空闲的 chunk
    • bk 指向上一个(非物理相邻)空闲的 chunk
    • 通过 fd 和 bk 可以将空闲的 chunk 块加入到空闲的 chunk 块链表进行统一管理
  • fd_nextsize, bk_nextsize,也是只有 chunk 空闲的时候才使用,不过其用于较大的 chunk(large chunk)。

    • fd_nextsize 指向前一个与当前 chunk 大小不同的第一个空闲块,不包含 bin 的头指针。
    • bk_nextsize 指向后一个与当前 chunk 大小不同的第一个空闲块,不包含 bin 的头指针。
    • 一般空闲的 large chunk 在 fd 的遍历顺序中,按照由大到小的顺序排列。这样做可以避免在寻找合适 chunk 时挨个遍历。

**我们称前两个字段称为 chunk header,后面的部分称为 user data。每次 malloc 申请得到的内存指针,其实指向 user data 的起始处。**当一个 chunk 处于使用状态时,它的下一个 chunk 的 prev_size 域无效,所以下一个 chunk 的该部分也可以被当前 chunk 使用。这就是 chunk 中的空间复用。

截屏2024-08-14 19.58.55

bin
概述

用户释放掉的 chunk 不会马上归还给系统,ptmalloc 会统一管理 heap 和 mmap 映射区域中的空闲的 chunk。当用户再一次请求分配内存时,ptmalloc 分配器会试图在空闲的 chunk 中挑选一块合适的给用户。这样可以避免频繁的系统调用,降低内存分配的开销。

在具体的实现中,ptmalloc 采用分箱式方法对空闲的 chunk 进行管理。首先,它会根据空闲的 chunk 的大小以及使用状态将 chunk 初步分为 4 类:fast bins,small bins,large bins,unsorted bin。每类中仍然有更细的划分,相似大小的 chunk 会用双向链表链接起来。也就是说,在每类 bin 的内部仍然会有多个互不相关的链表来保存不同大小的 chunk。

截屏2024-08-14 19.59.26

数组中的第一个为unsorted bin,数组中从2 开始编号的前64 个bin 称为small bins,同一个small bin 中的chunk 具有相同的大小。两个相邻的small bin 中的chunk 大小相差8bytes。small bins 中的chunk 按照最近使用顺序进行排列,最后释放的chunk 被链接到链表的头部,而申请 chunk 是从链表尾部开始,这样,每一个 chunk 都有相同的机会被 ptmalloc 选中。Small bins 后面的 bin 被称作 large bins。large bins 中的每一个 bin 分别包含了一个给定范围内的 chunk,其中的 chunk 按大小序排列。相同大小的 chunk 同样按照最近使用顺序排列。ptmalloc 使用“smallest-first,best-fit”原则在空闲large bins 中查找合适的chunk。

当空闲的 chunk 被链接到 bin 中的时候,ptmalloc 会把表示该 chunk 是否处于使用中的标志 P 设为 0(注意,这个标志实际上处在下一个 chunk 中) ,同时 ptmalloc 还会检查它前后的 chunk 是否也是空闲的,如果是的话,ptmalloc 会首先把它们合并为一个大的 chunk,然后将合并后的chunk 放到 unstored bin 中。要注意的是,并不是所有的chunk 被释放后就立即被放到bin 中。ptmalloc 为了提高分配的速度,会把一些小的的 chunk 先放到一个叫做fast bins 的容器内。

对于 small bins,large bins,unsorted bin 来说,ptmalloc 将它们维护在同一个数组中。这些 bin 对应的数据结构在 malloc_state 中,如下

#define NBINS 128 
/* Normal bins packed as described above */ 
mchunkptr bins[ NBINS * 2 - 2 ];

bins主要用于索引不同 bin 的 fd 和 bk。

为了简化在双链接列表中的使用,每个 bin 的 header 都设置为 malloc_chunk 类型。这样可以避免 header 类型及其特殊处理。但是,为了节省空间和提高局部性,只分配 bin 的 fd/bk 指针,然后使用 repositioning tricks 将这些指针视为一个malloc_chunk*的字段。

以 32 位系统为例,bins 前 4 项的含义如下截屏2024-07-11 20.51.56

可以看到,bin2 的 prev_size、size 和 bin1 的 fd、bk 是重合的。由于我们只会使用 fd 和 bk 来索引链表,所以该重合部分的数据其实记录的是 bin1 的 fd、bk。 也就是说,虽然后一个 bin 和前一个 bin 共用部分数据,但是其实记录的仍然是前一个 bin 的链表数据。通过这样的复用,可以节省空间。

此外,上述这些 bin 的排布都会遵循一个原则:任意两个物理相邻的空闲 chunk 不能在一起

需要注意的是,并不是所有的 chunk 被释放后就立即被放到 bin 中。ptmalloc 为了提高分配的速度,会把一些小的 chunk 放到 fast bins 的容器内。而且,fastbin 容器中的 chunk 的使用标记总是被置位的,所以不满足上面的原则。

Fast Bin

一般的情况是,程序在运行时会经常需要申请和释放一些较小的内存空间。当分配器合并了相邻的几个小的chunk 之后,也许马上就会有另一个小块内存的请求,这样分配器又需要从大的空闲内存中切分出一块,这样无疑是比较低效的,故而,ptmalloc 中在分配过程中引入了fast bins, 不大于 max_fast (默认值为64B) 的chunk 被释放后, 首先会被放到 fast bins中,fast bins 中的chunk 并不改变它的使用标志P。这样也就无法将它们合并,当需要给用户分配的chunk 小于或等于 max_fast 时,ptmalloc 首先会在 fast bins 中查找相应的空闲块,然后才会去查找bins 中的空闲chunk。 在某个特定的时候, ptmalloc 会遍历fast bins 中的chunk,将相邻的空闲chunk 进行合并,并将合并后的chunk 加入 unsorted bin 中,然后再将 usorted bin 里的 chunk 加入bins 中。

Unsorted Bin

unsorted bin 的队列使用 bins 数组的第一个,如果被用户释放的 chunk 大于max_fast,

或者fast bins 中的空闲chunk 合并后,这些chunk 首先会被放到 unsorted bin 队列中,在进

行malloc 操作的时候, 如果在fast bins 中没有找到合适的chunk, 则ptmalloc 会先在unsortedbin 中查找合适的空闲chunk, 然后才查找bins。 如果unsorted bin 不能满足分配要求。 malloc便会将unsorted bin 中的 chunk 加入 bins 中。然后再从 bins 中继续进行查找和分配过程。从这个过程可以看出来,unsorted bin 可以看做是bins 的一个缓冲区,增加它只是为了加快分配的速度。

ptmalloc内存分配步骤
  1. 获取分配区的锁,为了防止多个线程同时访问同一个分配区,在进行分配之前需要取得分配区域的锁。线程先查看线程私有实例中是否已经存在一个分配区,如果存在尝试对该分配区加锁,如果加锁成功,使用该分配区分配内存,否则,该线程搜索分配区循环链表试图获得一个空闲(没有加锁)的分配区。如果所有的分配区都已经加锁,那么 ptmalloc 会开辟一个新的分配区,把该分配区加入到全局分配区循环链表和线程的私有实例中并加锁,然后使用该分配区进行分配操作。开辟出来的新分配区一定为非主分配区,因为主分配区是从父进程那里继承来的。开辟非主分配区时会调用mmap()创建一个sub-heap,并设置好 top chunk。
  1. 将用户的请求大小转换为实际需要分配的chunk 空间大小。

  2. 判断所需分配chunk 的大小是否满足chunk_size <= max_fast (max_fast 默认为 64B),如果是的话,则转下一步,否则跳到第5 步。

  3. 首先尝试在fast bins 中取一个所需大小的chunk 分配给用户。如果可以找到,则分配结束。否则转到下一步。

  4. 判断所需大小是否处在 small bins 中,即判断 chunk_size < 512B 是否成立。如果chunk 大小处在 small bins 中,则转下一步,否则转到第6 步。

  5. 根据所需分配的 chunk 的大小,找到具体所在的某个 small bin,从该bin 的尾部摘取一个恰好满足大小的 chunk。若成功,则分配结束,否则,转到下一步。

  6. 到了这一步,说明需要分配的是一块大的内存,或者 small bins 中找不到合适的chunk。 于是, ptmalloc 首先会遍历fast bins 中的 chunk, 将相邻的chunk 进行合并,并链接到unsorted bin 中,然后遍历unsorted bin 中的 chunk,如果 unsorted bin 只有一个chunk,并且这个 chunk 在上次分配时被使用过,并且所需分配的 chunk 大小属于 small bins,并且 chunk 的大小大于等于需要分配的大小,这种情况下就直接将该 chunk 进行切割,分配结束,否则将根据 chunk 的空间大小将其放入 smallbins 或是large bins 中,遍历完成后,转入下一步。

  7. 到了这一步,说明需要分配的是一块大的内存,或者 small bins 和unsorted bin 中都找不到合适的 chunk,并且fast bins 和unsorted bin 中所有的chunk 都清除干净了。从large bins 中按照“smallest-first,best-fit”原则,找一个合适的 chunk,从中划分一块所需大小的 chunk,并将剩下的部分链接回到 bins 中。若操作成功,则分配结束,否则转到下一步。

  8. 如果搜索fast bins 和bins 都没有找到合适的 chunk,那么就需要操作top chunk 来进行分配了。判断 top chunk 大小是否满足所需 chunk 的大小,如果是,则从 top chunk 中分出一块来。否则转到下一步。

  9. 到了这一步, 说明 top chunk 也不能满足分配要求, 所以, 于是就有了两个选择: 如果是主分配区,调用 sbrk(),增加 top chunk 大小;如果是非主分配区,调用 mmap来分配一个新的 sub-heap,增加top chunk 大小;或者使用mmap()来直接分配。在这里,需要依靠 chunk 的大小来决定到底使用哪种方法。判断所需分配的 chunk大小是否大于等于 mmap 分配阈值, 如果是的话, 则转下一步, 调用mmap 分配,否则跳到第12 步,增加 top chunk 的大小。

  10. 使用mmap 系统调用为程序的内存空间映射一块 chunk_size align 4kB 大小的空间。然后将内存指针返回给用户。

  11. 判断是否为第一次调用 malloc,若是主分配区,则需要进行一次初始化工作,分配21一块大小为(chunk_size + 128KB) align 4KB 大小的空间作为初始的 heap。若已经初始化过了,主分配区则调用sbrk()增加heap 空间,分主分配区则在top chunk 中切割出一个chunk,使之满足分配需求,并将内存指针返回给用户。

总结一下:根据用户请求分配的内存的大小,ptmalloc 有可能会在两个地方为用户分配内存空间。在第一次分配内存时,一般情况下只存在一个主分配区,但也有可能从父进程那里继承来了多个非主分配区,在这里主要讨论主分配区的情况,brk 值等于start_brk,所以实际上 heap 大小为 0,top chunk 大小也是 0。这时,如果不增加 heap大小, 就不能满足任何分配要求。 所以, 若用户的请求的内存大小小于mmap 分配阈值,则 ptmalloc 会初始heap。然后在heap 中分配空间给用户,以后的分配就基于这个 heap进行。若第一次用户的请求就大于 mmap 分配阈值,则 ptmalloc 直接使用 mmap()分配一块内存给用户,而 heap 也就没有被初始化,直到用户第一次请求小于 mmap 分配阈值的内存分配。 第一次以后的分配就比较复杂了, 简单说来, ptmalloc 首先会查找fast bins,如果不能找到匹配的 chunk,则查找small bins。若还是不行,合并 fast bins,把 chunk加入 unsorted bin,在 unsorted bin 中查找,若还是不行,把unsorted bin 中的 chunk 全加入large bins 中,并查找large bins。 在fast bins 和small bins 中的查找都需要精确匹配,而在 large bins 中查找时,则遵循“smallest-first,best-fit”的原则,不需要精确匹配。若以上方法都失败了,则ptmalloc 会考虑使用top chunk。若top chunk 也不能满足分配要求。而且所需 chunk 大小大于 mmap 分配阈值,则使用 mmap 进行分配。否则增加heap,增大 top chunk。以满足分配要求。

  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值