本文中若无特殊标识,则默认指linux中glibc环境下的堆管理。
后面一段时间将系统的学习下堆相关的知识,本文为系列的第一篇学习笔记
什么是堆
堆内存是一种允许程序在运行过程中动态分配内存和使用的区域。和栈的主要不同在于动态分配,堆的内存区域是程序运行时申请和释放的。
堆和栈的对比如下表所示
堆 | 栈 | |
---|---|---|
申请 | 程序在运行过程中动态分配,由程序控制申请 | 程序运行前分配 |
释放 | 不能自动释放,由程序控制释放 | 自动释放 |
特点 | 地址由低 => 高 | 地址由高=>低 |
内存分配 | 非线性,无序 | 线性,有序 |
堆的基本数据结构
堆的基本数据结构主要包含堆块和堆表两部分。
- 堆块(chunk)
- 堆块进一步分为块首和块身。
- 块首(malloc_chunk):包含当前堆块的主要信息例如:此堆块的大小,前一个堆块的大小,是否是空闲态还是占用态等状态表信息。
- 块身:块身就是本堆块存放数据的位置,即最终分配给用户的数据区。
- 在内存中紧跟在mallco_chunk后面。
- 当申请堆块成功后,返回的指针指向块身。
- 堆块拥有不同的状态
- allocated:已分配给用户使用的chunk
- Unallocated/free:已释放/未分配给用户使用的chunk
- 堆表(bin)
- 堆表用来索引堆块。堆表中包含索引堆块的大小,位置,状态等信息。
- 在glibc中使用四种不同的bin管理堆块。
- Fast bin
- Unsorted bin
- Small bin
- Large bin
Malloc_CHUNK
堆管理器最基本的数据结构是malloc,在源码中的定义如下
struct malloc_chunk {
INTERNAL_SIZE_T mchunk_prev_size; /* Size of previous chunk, if it is free. */
INTERNAL_SIZE_T mchunk_size; /* Size in bytes, including overhead. */
struct malloc_chunk* fd; /* double links -- used only if this chunk is 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 this chunk is free. */
struct malloc_chunk* bk_nextsize;
};
typedef struct malloc_chunk* mchunkptr;
-
mchunk_prev_size
: 当物理地址相邻的前一个chunk为未分配状态时,该字段表示前一个chunk的大小。不会出现两个free状态的chunk相邻的情况,当两个free的chunk相邻时,会合并成一个chunk。因此对于一个free状态的chunk,它的前一个chunk总是已分配的,prev_size字段并不起作用,而是用来储存前一个chunk的用户数据。 -
mchink_size
:记录了当前chunk的大小,chunk的大小都是8字节对齐,所以mchunk_size的低3位并不用来表示地址。为了充分利用内存空间,这三位被当作了标志位。--------------------------------------------------- |...........................................|A|M|P| ---------------------------------------------------
- A (NON_MAIN_ARENA) : 记录当前chunk是否不属于主线程。1 = 不属于,0 = 属于。
- M (IS_MMAPED): 记录当前chunk是否是由mmap分配的。1 = 是, 0 = 否。
- P (PREV_INUSE): 记录前一个chunk块是否被分配。1 = 是,0 = 否。这里的前一个chunk指的是物理地址上相邻的前一个chunk。
-
其他字段:对于已分配的chunk,并不包含其他的字段(
fd
,bk
等)。
为什么mallco_chunk的结构是这样呢?我们来了解下最基本的堆管理方法 - 隐式链表
隐式链表
堆内存管理器都是以堆块(chunk)为最小单位进行堆内存管理的。为了高效的分配和使用内存,有很多种堆块的管理方法,下面介绍常见的几种方法
-
隐式链表(Implict List):通过一个内部链表将所有区块的头字段链接起来
-
显式链表(Explicit List):通过一个链表将所有空闲区块的头字段连接起来。
- 隔离闲置的链表(Segregated Free List):可以跟踪不同尺寸的置区块之间的链表,也可以说可以跟踪闲置区块之间是已分配的区块
- 根据尺寸已排序的链表(Blocks Sorted List by sizes):可以使用平衡二叉树,指针位于每个空闲块中,长度用作关key值
后三种方法我们暂时了解,不去深入的学习,重点来看一下隐式链表的方法。
隐式链表之所以叫隐式,是因为它并没有真的拥有一个链表结构,但是可以通过特殊的构造实现类似链表的数据结构。
对于内存空间中的堆块,堆管理器需要知道堆块的大小和使用状态来决定如何分配。
典型的堆块设计如下图所示
- 在x86_64中,内存是按照8字节对齐的,因此chunk_size的低3位可以节省出来用来标识状态。最低位用来标识chunk是否被分配。
将堆块连接起来后,链表隐式地由每个chunk的size字段链接起来。对于chunk p来说,它可以方便的用自己的size定位到下一个chunk,从而可以向链表一样的进行遍历等操作,也拥有链表单向的特征。典型示意图如下所示:
那么在这种情况下malloc是如何进行堆块的分配和释放呢?
释放
释放相对来说比较简单,直接将chunk_size的最低位置0即可完成释放。
分配
在进行分配操作的时候,堆内存管理器遍历整个链表,比较用户申请的空间大小与chunk_size大小,当用户申请的空间大小小于等于chunk_size时,即对chunk进行分割操作,分配给申请的程序。
这样一来会带来一个问题,随着chunk的消耗,会产生越来越多的碎片化、无法继续使用的chunk,最终导致内存消耗殆尽。因此在分配切割的同时,堆管理器还需要对空闲的chunk进行合并。
在隐式链表结构下,进行后向合并是很方便的 。比如将上图中的堆块p2与相邻的空闲堆块合并,p2可以通过自身的size得到空闲区块的起始位置,从而得到空闲区块的大小,最终合并空闲区块。但是前向合并很艰难,对于p3来说,它无法直接获取前一个空闲堆块的起始位置和大小,必须再从头遍历一次才可以。
为了解决前向合并的困难,Knuth提出了一种聪明而通用的技术——边界标记。
Knuth在每个chunk的最后添加了一个脚部(Footer),它就是该chunk 头部(header)的一个副本,我们称之为边界标记:
增加了边界标记后,当p3要合并前一个空闲chunk时,访问上一个地址即可获取前一个chunk的大小,从而可以找到前一个chunk的起始位置并进行合并。
上面的方法虽然解决了合并的问题,但是每一个chunk都要有一个header一个footer来表示同一个size值,这显然是浪费了内存空间的。我们回到最开始要解决的问题,是要解决前向合并,也就是前一个chunk是free时,才需要前一个chunk有footer。因此,只有状态是free的chunk才需要footer,allocated的chunk并不需要footer。此时如何知道前一个chunk的状态呢,我们进一步把原来表示chunk状态的字段改为表示前一个chunk状态,新的allocated chunk将如下所示
而free chunk如下所示
进一步分析发现 free状态还是有两个chunk size,同时footer的标志位也没有什么作用。如果我们将整个结构体的分界上移一个单位的地址,即将当前free chunk的footer分配给下一个footer,变成下一个chunk的起始地址,我们就得到了新的chunk结构。
如上图所示,当前一个结构体为未分配状态时,chunk_size的最低位为0,此时结构体的起始地址表示前一个chunk的大小。而当前一个chunk为allocated状态时,这个地址可以用来给前一个chunk作为存储空间,即前一个chunk的padding段。
此时我们基本已经演化出来了malloc_chunk的形态,在glibc中的chunk结构如下所示。
Allocated Chunk
chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|If previous chunk is free, this filed is size of previous chunk|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of chunk, in bytes |A|M|P|
mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| User data starts here... .
. .
. (malloc_usable_size() bytes) .
. |
nextchunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| user data of chunk. |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of next chunk, in bytes |A|0|1|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Free Chunk
chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| user data of previous chunk |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
'head:' | Size of chunk, in bytes |A|0|P|
mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Forward pointer to next chunk in list |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Back pointer to previous chunk in list |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Unused space (may be 0 bytes long) .
. .
. |
nextchunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
'foot:' | Size of chunk, in bytes |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of next chunk, in bytes |A|0|0|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
对于fd和bk两个指针的作用,我们将在后面学习中再来深入研究。
Reference
- https://zhuanlan.zhihu.com/p/24753861
- https://zhuanlan.zhihu.com/p/185826940
- https://heap-exploitation.dhavalkapil.com