最近看了一些内存分配器的资料,找了一大堆,感觉读起来都不是很顺畅,今天终于找到了比较好的资料,特来分享。下文是从一篇论文中截取的部分关于ptmalloc2的描述:
2 Glibc Heap 管理机制
Glibc 的堆分配器是基于
Doug Lea 早期的dlmalloc
分配器发展而来的
ptmalloc2
分配器, 具有速度快、碎片度低、线程安全的特点。本章以当前流行的
glibc 2.19
版本为例
,
说明其堆的分配机制。
2.1 相关结构
2.1.1 堆块结构
堆块块(Chunk),
是
ptmalloc2 进行分配的基本单位
,
其头部保存着自身的大小等信息
, 在这之后即是用户数据。一共有
3
种类型
, 它们都使用同一种数据结构
(malloc_chunk)
定义。
在 32
位系统中
,
堆块的大小最小为
16
字节
, 对 齐
8
字节
;
而在
64
位系统中
,
堆块的大小最小为 32 字节
,
对齐
16
字节。在本文之后的讨论中
, 未做特 别说明则默认为
64
位环境。
具体来说,
用于管理的堆块主要分为
3 种类型。
1、Allocated Chunk:
已分配块
,
如图
1
所示
, 只 使用
prev_size
与
size
这
2
个域
, 用来记录上一块大 小以及自身的大小
,
剩余部分则为用户数据。
2、Free Chunk: 被释放块
,
如图
2
所示
, 另外使用
fd
、
bk 2
个域。
3、Top Chunk: 顶块, 位于所有块之后, 保存着未分配的所有内存, 与已分配块使用相同的域。 除此之外, 由于块的大小是对齐的, 使得低位字节不会使用到, 故 glibc 使用 size 域的最低 3 位来存储一些其它信息。相关的掩码信息定义如下:
从以上代码定义可以推断, size 域的最低位表示 此块的上一块
(
表示连续内存中的上一块
)是否在使 用状态
,
如果此位为
0
则表示上一块为被释放的块, 这个时候此块的
PREV_SIZE域保存的是上一块的地址以便在
free 此块时能够找到上一块的地址并进行合并操作。第
2
位表示此块是否由
mmap
分配
, 如果此位为
0
则此块是由
top chunk
分裂得来
, 否则是由mmap
单独分配而来。第
3 位表示此块是否不属于main_arena,
在之后会提到
main_arena 是主线程用于保存堆状态的结构, 如果此位为 0 则表示此块是在主线程中分配的。
2.1.2 Bins
结构
当一个分配块被执行free
操作后
, glibc将其按照 一定规则放入
bin
结构中
, 以便下次分配时能够再次利用。
bin
实际上即是链表结构
,
利用
fd
与
bk 指针 进行组织
,
根据块的大小不同分为不同的
bin 结构。
Fast Bins: chunk
的指针数组
, 每个元素是一 条单向链表的头部
, 且同一条链表中块的大小相同。 主要保存大小
32
至
128
字节的块
,
特点是当
free 时 不取消下一块的
PREV_INUSE
位
, 也不检查是否能够进行合并操作, 主要目的是能够最快速地利用较小的内存块。由于是单向链表
,
故
Fast bins 的取用机制是
LIFO (Last In First Out) , 即后释放的块将先被 利用。
Small Bins: chunk
的指针数组
, 每个元素是一条双向循环链表的头部
, 且同一条链表中块的大小 相同。主要保存大小
32
至
1024 字节的块。由于是双向链表
, Small Bins
的取用机制是 FIFO (First In First Out) ,
即先释放的块会先被利用
,
之后的 Large Bins
与
Unsorted Bins
也是同样的机制。
Large Bins: chunk
的指针数组
, 每个元素是一条双向循环链表的头部
, 但同一条链表中块的大小不一定相同
,
按照从大到小的顺序排列
,
每个
bin 保存一定大小范围的块。主要保存大小
1024
字节以上的块。
Unsorted Bins:
与
Small Bins
和
Large Bins 类似是双向循环链表
,
只有一个
bin, 其中保存的块大小不定
,
用于收集刚刚被
free 或从大的块中分裂剩下的块。
2.1.3 Arena
结构
当前主线程的堆分配状态是由 glibc 中的全局变量
main_arena
保存的
,
这是一个
malloc_state 类型的结构体。而
malloc_state
结构体的部分定义如下
:
我们关心的部分有以下几个域
:
fastbinsY:
保存
Fast Bins
的数组
top:
保存
Top Chunk
的地址
last_remainder:
保存上一次分裂的块
bins:
其中下标为
1
的元素是
unsorted bin, 之后的
bins
从小到大对应
small bins
与
large bins, 下标为
0
的元素不用。
2.2 分配函数
Malloc 函数为
glibc
的主要分配接口
, 给出需要分配的大小参数
, 返回值为分配得到的用户数据指针。主要的功能由
_int_malloc 函数实现。
在第一次执行 malloc
函数时
,
系统会使用
brk 系统调用向操作系统扩展程序的数据区
,
此时
glibc 将初始化
top chunk
与
main_arena,
取得
132KB的空间。 如果之后所有的
malloc
操作都可以满足
, 即最后总是能在此
132KB
中找到合适的内存块返回
, 则不再使用系统调用与内核交互。这段时间
, 程序的堆内存由
glibc
管理。否则
,
将使用
brk
或
mmap 系统调用来向内核申请更多空间。
当程序将需要的空间大小传入 malloc
时, glibc首先将其加上
8
字节的额外开销
(
用于保存
size
域, 因为
prev_size 域实际占用的是上一块的空间故不算 额外开销
)
然后对齐
16
字节
,
如果不足
32 字节则分配
32
字节。接下来我们的叙述中
, 请求大小皆指已 经处理之后对齐
16
字节的大小。
2.2.1
检查
Fast bins
如果请求大小满足 Fast bins,
则在对应的
bin 中寻找是否有相同大小的块
, 如果有则直接将其取出返回给程序
,
同时更新
fast bins
中对应 bin 存储的链表头指针。
2.2.2
检查
Small bins
如果块大小符合 Small bins, 则在对应大小的Small bin
中寻找是否有合适的块
, 如果有则直接返回
,
同时更新
Small bins
中对应 bin 的链表中该块的上一块和下一块的指针。对于
Fast bins
和
Small bins
来说
,
每个
bin 中的块大小都是相同的
,
所以只要对应的
bin
中有块
, 就能够直接返回恰好符合的块。
2.2.3
处理
Unsorted bin
如果之前没能返回恰好符合的块, 则开始处理Unsorted bin
中的块
(
这里有一个例外
,
如果 Unsorted bin
中只有一个块且这个块是
last_remainder, 而且大小足够
,
则优先使用此块
, 分裂后将前一块返回给用户
,
剩下的一块作为新的
last_remainder 再次放入Unsorted bin.
)
具体来说,
处理循环如下
:
a) 逐个迭代
Unsorted bin
中的块
, 如果发现块的大小正好是需要的大小
,
则迭代过程中止
, 直接返回此块
;
否则将此块放入到对应的
Small bin 或者large bin
中
,
这也是整个
glibc 堆管理中唯一会将块放入
Small bins
与
large bins
中的代码。
b) 迭代过程直到
Unsorted bin 中没有块或超过最大迭代次数
(10000)为止。
c) 随后开始在
Small bins
与
large bins 中寻找最合适的块
(
指大于请求大小的最小块
),
如果能够找到, 则分裂后将前一块返回给用户
, 剩下的块放入Unsorted bin
中。
d) 如果没能找到
,
则回到开头
,
继续迭代过程, 直到
Unsorted bin
空为止。
2.2.4
使用顶块
如果之前的操作都没能找到合适的块, 将分裂Top chunk
返回给用户
,
若
Top chunk 的大小仍然不足
,
则再次执行
malloc_consolidate
函数清除 Fast bins,
若
Fast bins
已空
,
只能使用
sysmalloc 函数借助系统调用拓展空间。
2.3 释放函数
Free 函数是
glibc
的释放接口
, 将之前分配得到的用户内存指针作为参数
, glibc 会释放这一块空间。 主要的功能由
_int_free
函数实现。
首先,
进行一系列的检查
,
包括内存地址对齐, PREV_INUSE
位是否正确等等
, 能够发现一些破坏与
double free 的问题。
如果块大小满足 Fast bins, 则不取消下一块的PREV_INUSE
位
,
也不设置下一块的
prev_size
域, 直接将该块放入对应的
Fast bin
中
, 且不进行相邻块的合并操作。
检查被 free
内存块的前一块
(这里的前一块指连续内存中的上一块
,
通过
prev_size
域来寻找
), 如果未使用
,
则合并这
2
块
,
将前一块从其
bin 中移除之后再检查其后一块
,
如果发现是
Top chunk, 则最后将合并到
Top chunk
中
,
不放入任何
bin;
如果不是 Top chunk
且未使用
,
则再合并这
2
块
,
将后一块从其 bin中移除
,
并且将合并过的大块放入
Unsorted bin
中。
2.4 重分配函数
realloc 是一项复合操作
, 既要给出之前分配的用户内存指针
,
又要传入需要的大小数值
,
旨在重
新分配这块空间以符合用户新的需求。在执行具体操作之前
, 同样会先将用户传入的大小参数进行处理以对齐
16
字节。主要功能由
_int_realloc
函数实现。
若发现之后需要的大小比之前的大小更小,
则直接对此块进行压缩操作, 分裂出的部分如果达到
块的最小大小
(32
字节
),
则调用
_int_free 函数释放此块。
若发现已分配的块后一块是 Top chunk(这里的后一块指的是连续内存中的下一块
,
通过
size 域来寻找
),
则直接向
Top chunk
中扩展一部分空间
, 返回的指针与之前传入的指针相同。
若发现下一块已经被 Free, 且下一块的大小能够满足新的需求大小
,
则向下一块中扩展
,
使用
unlink
宏将下一块从对应的
bin
中移除
, 扩展完成后再对剩下的块调用
_int_free。返回的指针与之前传入 的指针相同。
若无法向下一块扩展,
则直接调用_int_malloc 分配新的堆块
, 然后把之前堆块中的用户数据复制到新的堆块中
,
最后对之前的块调用
_int_free
函数。
2.5 堆管理的特点
可以看到, glibc 在堆管理方面使用了很多技巧, 最终目的都是为了能够加快分配速度、降低碎片率、 更好更合理的利用堆内存区域。而这样一来
, 安全性便成为其最大的问题
, 由于内存块的元数据与用户数据交错布置
,
导致块的元数据很容易遭到破坏
, 如果程序中有缓冲区溢出漏洞更是可以进一步的利用。
该论文中没有关于mmap()的一些讲解,比较遗憾。
如果对于ptmalloc2还有所疑惑,可以看一下这个资料:
Heap Exploitation Part 1: Understanding the Glibc Heap Implementation | Azeria Labs
郑重声明,本篇博客转载自:《Glibc
堆利用的若干方法》,该论文于2018年发表于《信息安全学报》。