前段时间项目组换了一个新来的产品经理;这位大佬还未熟悉产品, 别人问到啥问题都要找测试回答, 连客户演示、销售之类的问题都要来找测试, 晕😵, 到底我是产品还是你是产品?
宏定义
名称 | 32bit取值 | 64bit取值 | 定义 | 备注 |
---|---|---|---|---|
SIZE_SZ | 4 | 8 | size_t 的长度 |
sizeof(size_t) |
MALLOC_ALIGNMENT | 16 | 16 | min(sizeof(long double), 2*SIZE_SZ) | |
MALLOC_ALIGN_MASK | 0xf | 0xf | MALLOC_ALIGNMENT - 1 |
|
MINSIZE | 16 | 32 | chunk的最小长度 | 4*SIZE_SZ |
MAX_FAST_SIZE | 80 | 160 | 通过 mallopt() 函数能够设置的最大fastBIN的长度 |
80 * SIZE_SZ / 4 |
DEFAULT_MXFAST | 64 | 128 | fast bin的默认最大长度 | 64 * SIZE_SZ / 4 |
NSMALLBINS | 64 | 64 | small bins的数量? | |
SMALLBIN_WIDTH | 16 | 16 | 暂时不明 | MALLOC_ALIGNMENT |
SMALLBIN_CORRECTION | 1 | 0 | 暂时不明 | (MALLOC_ALIGNMENT > 2 * SIZE_SZ) |
MIN_LARGE_SIZE | 1008 | 1024 | 最小的 large bin 长度 | ((NSMALLBINS - SMALLBIN_CORRECTION) * SMALLBIN_WIDTH) |
数据结构
每个堆都有一个用来管理堆内存的 malloc_state
结构, 主线程的第一个堆对应的 malloc_state
结构又称为 main_arena
.
chunks 和 bins
按照结构体的定义, glibc 管理动态内存的最小单位是chunk, 长度按照 2xSIZE_SZ 对齐, 所有的chunk的长度都是 2xSIZE_SZ 的整数倍.
- 元数据部分
- .mchunk_prev_size 当P位置0时, 表示前一个chunk的长度; 否则存储用户数据
- .mchunk_size 当前chunk的长度. 由于chunk的长度对齐特性, 低位的三个比特存储了额外的信息
- A: 1表示不属于main_arena, 0表示属于main_area
- M: 1表示由mmap函数分配得来
- P: 前一个chunk是否正在使用
- 用户数据部分
- .fd, .bk, .fd_nextsize, .bk_nextsize 在chunk空闲时用作存储链表指针, 与其他chunk组成单链表/双循环链表/跳表
一个chunk存储了两部分数据, 分别是供glibc管理chunk的元数据, 以及返回给调用者的用户数据.chunk的组成结构如下图所示, 一些控制结构和用户数据发生了混合, 我猜作者在设计chunk的时候一定是希望能充分利用内存空间. 更多细节可参见CTF wiki:
chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| .mchunk_prev_size , if unallocated (P clear) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| .mchunk_size , in bytes |A|M|P|
mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| User data starts here... .
. .fd, .bk, .fd_nextsize, .bk_nextsize .
. (malloc_usable_size() bytes) .
next . |
chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| (size of chunk, but used for application data) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of next chunk, in bytes |A|0|1|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
glibc 使用链表来管理chunk, 这些链表称为 bins. bins分为四类, 其中 unsorted, small 以及 large 存放在结构体 malloc_state
的数组malloc_state::bins
, bins中的所有chunk均被标记为空闲状态(即下一个chunk的P为置0); 而 fast bins单独存放在数组malloc_state::fastbinsY
, 所有chunk一直处于使用状态(即下一个chunk的P为置1). 以64bi为例子:
- fast bins:
- 通过
fd
组成单向链表, 从头部存取(后进先出); - 链表内chunk长度一致. 最小为32字节(
MINSIZE
), 对索引为0; 最大为global_max_fast
, 这个值可以通过函数__libc_mallopt(M_MXFAST, value)
改变, 默认值为128字节, 最大值为(160 + SIZE_SZ) & ~MALLOC_ALIGN_MASK)
, 即176字节; - 因此最多一共有10=(176-32)//16+1类不同长度的链表: 32, 48, 64, …, 176. -chunk一直处于占用状态.
- 通过
- unsorted bin:
- 通过
fd
和bk
组成的双向链表, 只有一个, 从头部放入、从尾部读取(先进先出); - 链表内 chunk 长度可以不一致;
- 通过
- small bins:
- 双向链表, 从头部放入、从尾部取出(先进先出);
- 同一个链表内 chunk 长度一致, 最小为32字节, 最大为1008字节;
- 一共有62=(1008-32)//16+1类不同长度的链表: 32, 48, 64, …, 1008, 数量和
NSMALLBINS
不同;
- large bins:
- 双向链表, 从头部放入、从尾部取出(先进先出);
- 在
malloc_state::bins
中, 除去 unsorted 和62个 small 以外的都是 large ; - 同一个链表内 chunk 长度控制在一定的公差范围内, 不要求严格一致;
- 同一个链表内 chunk 额外使用两个指针组成另一个跳表(skip list), 在这个跳表内按照长度递增排列;
- 链表之间按照公差分成六组, 32bit 和 64bit 的划分方式一致(摘自源码):
- 32 bins of size 64
- 16 bins of size 512
- 8 bins of size 4096
- 4 bins of size 32768
- 2 bins of size 262144
- 1 bin of size what’s left
binmap
malloc_state::binmap
是用来标记 small/large bins中的链表是否为空的位图数据结构, 本质上是一个包含4个32bit整数的数组, 其用途是快速检查对应比特位的 bin 是否为空, 而不需要直接去遍历bins.
tcache
glibc-2.27ubuntu1.2的堆有一个叫做tcache的功能, 网上的资料称该功能的目的是为了给每个线程加快分配内存的速度; 在没有tcache的情况下, 多线程需要通过原子操作从fast bin取得chunk. 不过我看代码通过一个名为tcache的全局变量操作tcache, 所以对于加快多线程分配内存这一个说法时将信将疑的. malloc第一次分配内存时会执行tcache的初始化操作, tcache涉及两个数据结构:
typedef struct tcache_entry
{
struct tcache_entry *next; // 下一个tcache的chunk
} tcache_entry;
typedef struct tcache_perthread_struct
{
char counts[TCACHE_MAX_BINS]; // 每个链表的长度
tcache_entry *entries[TCACHE_MAX_BINS]; // 64个链表数组
} tcache_perthread_struct;
tcache_perthread_struct::entries
和 malloc_state::fastbinsY
类似, 也是后进先出的单链表数组, 每个链表所包含的chunk的长度固定; 不同点在于, 正常情况下tcache的单链表长度不能超过7, 而且指针指向了用户数据部分, 而不是chunk的开始部分. 从glibc-2.27ubuntu1.4开始, tcache_entry
还多了一个结构, 用来检测double free风险:
typedef struct tcache_entry
{
struct tcache_entry *next;
/* This field exists to detect double frees. */
struct tcache_perthread_struct *key;
} tcache_entry;
函数实现
malloc_consolidate
这个函数通过清空 fast bins 实现整理内存碎片, 其函数签名为:
static void malloc_consolidate(mstate av)
通过遍历并清空 fast 单链表:
- 尝试对 chunk 和其前一个、后一个的非 TOP 、空闲 chunk 进行合并;
- 把 chunk 放入 unsorted;
- 如果有前后 chunk 因此被合并, 要将 chunk 从对应的 small/large bin中拆出, 即发生
UNLINK
.
_int_malloc
从堆中分配内存. 函数签名为:
static void * _int_malloc(mstate av, size_t bytes)
函数逻辑:
- 如果传入的av指针为NULL, 调用
sysmalloc
向操作系统申请内存, 然后返回; - 将 sz(用户申请的长度) 转换为 nb( chunk 的长度);
- 如果 nb <
global_max_fast
, 尝试从长度为 nb 的 fast bin 中精确查找. 如果找到就从单链表中拆除并返回; - 如果 nb <
MIN_LARGE_SIZE
(1024B), 尝试从长度为 nb 的 small bin 中精确查找. 如果找到就发生UNLINK并返回; - 如果这是一个 large 申请. 先执行一次
malloc_consolidate
; - 接着因为精确查找失败, 或者这是 large 申请, 执行:
- 从后往前(先进先出)遍历 unsorted
- 如果是 small 申请, unsorted 只有一个 chunk, 而且这个chunk刚好是
av->last_remainder
, 那么从av->last_remainder
切一块出来, 剩余部分赋值给last_remainder并同时放回unsorted, 然后返回; - 如果chunk长度刚好等于 nb 就返回;
- 否则, 把chunk按照长度放到对应的 small/large bins. 根据源代码的注释显示, 这是唯一一处把 chunk 放入到 small/large bins 的代码;
- 在一次
_int_malloc
(不限于一次遍历 unsorted)过程中, 最多循环 10000 次然后退出.
- 如果是 small 申请, unsorted 只有一个 chunk, 而且这个chunk刚好是
- 如果这是一个 large 申请, 尝试从 large bins 找到满足 nb 的最小 chunk, 找到就返回:
- 发生 UNLINK
- 如果切出 nb 长度后剩下部分的长度大于
MINSIZE
, 则将剩下部分放入 unsorted;
- 到这里为止, nb 对应的 small/large bin 没有 chunk 了.
- 尝试从nb开始, 按照长度逐个扫描位图 binmap, 期望找到不为空的 bin;
- 从BIN中取出发生
UNLINK
; - 如果切出 nb 长度后剩下部分的长度大于
MINSIZE
, 则将剩下部分放入 unsorted.- 这是 6.1.1 中 last_remainder 出现在 unsorted 的原因;
- 如果 nb 是一个 small 申请, 还会将剩下部分设为
av->last_remainder
;
- 如果逐个扫描位图也不能找到 chunk, 但 TOP 可以满足, 则从 TOP 的低地址方向切一块并返回;
- 如果 TOP 仍不能满足但 fast 中仍存在 chunk, 则再次发生
malloc_consolidate
, 然后回到6继续循环;- 猜测可能是要照顾多线程程序?
- 否则, 使用
sysmalloc
向操作系统申请 nb 长度的内存, 不管成功与否直接返回;
- 从后往前(先进先出)遍历 unsorted
- 回到 6.
多说一句, 从_int_malloc 分配得的 chunk 块的内容一般不会清空, 上次使用时写入的数据能够保留. 如果事先通过 _libc_mallopt(M_PERTURB, c) 将字符 perturb_byte 设置为c, chunk 块的所有字节会被memset设置为 c^0Xff(和0xff异或的结果).
_int_free
释放从堆中申请的内存. 函数签名为:
static void _int_free(mstate av, mchunkptr p, int have_lock)
free()
的逻辑相对简单得多:
- 如果 size <
global_max_fast
则放入对应长度的 fast bin 的头部并返回;- 这里glibc会检查相邻的下一个chunk的长度(
.mchunk_size
)是否合理, 即在区间(2 * SIZE_SZ, av->system_mem)
;
- 这里glibc会检查相邻的下一个chunk的长度(
- 如果不是
mmap
申请得来的, 尝试合并前后的空闲 chunk:- 如果有前后 chunk 因此被合并, 要将 chunk 从对应的 small/large bin中拆出, 即发生
UNLINK
; - 如果后一个 chunk 是 TOP, 不会对 TOP 进行 UNLINK; 而是直接修改
av->top
- 否则把 chunk 放入 unsorted;
- 如果合并后的size 大于阈值
fastBIN_CONSOLIDATION_THRESHOLD
(65536) , glibc 认为堆中可能存在较多碎片, 因此会调用malloc_consolidate
;
- 如果有前后 chunk 因此被合并, 要将 chunk 从对应的 small/large bin中拆出, 即发生
- 否则这个 chunk 是
mmap
申请得来的, 就调用munmap_chunk
返还给操作系统.
tcache
static void * tcache_get (size_t tc_idx)
{
tcache_entry *e = tcache->entries[tc_idx];
assert (tc_idx < TCACHE_MAX_BINS);
assert (tcache->entries[tc_idx] > 0);
tcache->entries[tc_idx] = e->next;
--(tcache->counts[tc_idx]);
return (void *) e;
}
static void tcache_put (mchunkptr chunk, size_t tc_idx)
{
tcache_entry *e = (tcache_entry *) chunk2mem (chunk);
assert (tc_idx < TCACHE_MAX_BINS);
e->next = tcache->entries[tc_idx];
tcache->entries[tc_idx] = e;
++(tcache->counts[tc_idx]);
}
tcache涉及两个操作, tcache_get
以及tcache_put
. 引入tcache后, 这是malloc和free发生的变化😒
-
malloc(__libc_malloc): 首先尝试在tcache中找对应的链表是否为空, 如果不为空就可以调用
tcache_get
从单链表中取一个出来, 后面就不会调用函数_int_malloc
. - _int_malloc: 在 fast 和 small bin 的精确查找过程中, 如果精确查找成功, 就会调用
tcache_put
把对应bin里的 chunk 装入 tcache 的链表, 直到填满7个为止;- 另外, 在遍历unsorted的过程中, 如果chunk的长度和请求的长度一致, glibc会先把chunk通过
tcache_put
放到tcache里, 而不是立即返回这个. 个人猜测这样做的原因是要把unsorted列表里面同样大小的chunk给安排到tcache.
- 另外, 在遍历unsorted的过程中, 如果chunk的长度和请求的长度一致, glibc会先把chunk通过
- _int_free: 在检查是否能塞入fast bin之前, 优先通过 tcache_put 把chunk放到 tcache.
1.4版本的double free检测
static void tcache_put (mchunkptr chunk, size_t tc_idx)
{
tcache_entry *e = (tcache_entry *) chunk2mem (chunk);
assert (tc_idx < TCACHE_MAX_BINS);
/* Mark this chunk as "in the tcache" so the test in _int_free will
detect a double free. */
e->key = tcache;
e->next = tcache->entries[tc_idx];
tcache->entries[tc_idx] = e;
++(tcache->counts[tc_idx]);
}
最新的ubuntu 18.04的glibc版本是 glibc-2.27ubuntu1.4,该版本堆对tcache_entry
结构体增加了一个名为key的指针,tcache_put
函数把chunk的key指针设置为tcache的地址;在释放一个chunk时,会检查当前chunk的key值是否等于tcache的地址,如果等于则表示发生了double free并终止程序。
利用
泄露堆结构的地址
如果 chunk 被插入 unsorted 链表的尾部, 它的 fd 和 bk 会被设置为一个“假的” chunk 的地址, 这个地址和 main_arena
有关, 因此能够进一步泄露 libc 的地址. 以下场景能够使得 chunk 被插入 unsorted 尾部:
- 释放一个大于
global_max_fast
长度的 chunk; - last_remainder 分裂, 即上述
_int_malloc
的6.1.1; - unsorted 的最后一个 chunk 发生分离, 即上述
_int_malloc
的6.3.3;
这个地址是main_arena.top
成员变量的地址, 这个关系和 malloc_state
的存储结构有关, 如下图所示; 在64bit ubuntu 18.04中, 这个地址等于 (char*)&main_arena+88. 这个“假”chunk的fd和bk正好指向了main_arena.bins[0]
及main_arena.bins[1]
, 也就是 unsorted 的第一个chunk, 共同组成了一个双链表.
struct malloc_state
{
__libc_lock_define (, mutex); // &main_arena+0, mutex_t, 4字节
/* Flags (formerly in max_fast). */
int flags; // &main_arena+4, 32bit整数, 4字节
int have_fastchunks; // gdb 调试发现没有这个变量, 估计被编译器优化掉了
mfastbinptr fastbinsY[NFASTBINS]; // &main_arena+8, 指针数组, 长度为10, 80字节
mchunkptr top; // &main_arena+88, 指针, 8字节
mchunkptr last_remainder; // &main_arena+96, 指针, 8字节
/* Normal bins packed as described above */
mchunkptr bins[NBINS * 2 - 2]; // &main_arena+104
/*
...
*/
}
所以, 根据泄露出来的main_arena.top
地址就能根据libc的库文件计算main_arena的地址; 尽管libc的库文件里面没有main_arena这个符号, 但是另一个符号 __malloc_hook 却在库中, 而且它和main_arena的地址距离是固定, 因此就能够确定libc的版本了. 举个例子, 在64bit ubuntu16.04中, __malloc_hook位于main_arena之前0x10的地方, 而紧挨着另一个符号__realloc_hook :
gdb$ x/gx 0x7fa731ec7b08
0x7fa731ec7b08 <__realloc_hook>: 0x00007fa731b88a70
gdb$ x/gx 0x7fa731ec7b10
0x7fa731ec7b10 <__malloc_hook>: 0x0000000000000000
gdb$ x/gx 0x7fa731ec7b20
0x7fa731ec7b20 <main_arena>: 0x0000000100000000