1. 堆利用基础:glibc堆管理器架构、chunk结构、bin链表、malloc/free底层原理
各位同学,欢迎来到《堆利用技术异架构Pwn调试教程》的第一章。我是你们的老朋友,一个在二进制安全领域摸爬滚打了十几年的工程师。今天咱们不聊虚的,直接切入堆利用的核心——glibc堆管理器。
说实话,我刚开始学Pwn的时候,最头疼的就是堆。栈溢出多直观啊,覆盖返回地址就完事了。但堆?malloc和free像两个黑盒子,你根本不知道内存里发生了什么。后来我花了整整三个月,把glibc源码啃了一遍,才算是真正入了门。今天我就把当年踩过的坑、总结的经验,一次性讲给你听。
1.1 glibc堆管理器架构:一个微型操作系统
glibc的堆管理器(ptmalloc2),说白了就是一个微型的内存操作系统。它管理着进程堆区,负责分配和回收内存块。你想想看,如果每个malloc都直接调用brk或mmap去跟内核要内存,那性能得多差?所以glibc搞了一套缓存机制——先把大块内存从内核拿过来,切成小块,再分给应用程序。
它的核心架构可以概括为三个层次:
- arena(竞技场):每个线程有自己的arena,避免锁竞争。主线程用main_arena,子线程用非主arena。
- heap(堆区):每个arena管理一个或多个连续内存段,通过heap_info结构链接。
- chunk(块):最小的分配单元,所有操作都围绕chunk展开。
下面这张图是我当年手绘的架构图,现在用SVG重新画了一遍,你看一眼就能明白整体关系:
核心要点:glibc堆管理器本质上是一个内存缓存系统。它从内核批发内存,然后零售给应用程序。理解arena、heap、chunk三层结构,是后续所有堆利用技术的基础。
1.2 chunk结构:内存块的前世今生
每个malloc返回的指针背后,都藏着一个chunk结构。我当年第一次看这个结构时,觉得glibc的设计者真是把内存利用到了极致——一个chunk头只有8字节(32位)或16字节(64位),却承载了分配、释放、合并、分割等所有信息。
先看64位系统下的chunk结构:
// 一个已分配的chunk(in-use状态)
+------------------+
| prev_size (8B) | ← 前一个chunk的大小(仅当前一个chunk空闲时有效)
+------------------+
| size (8B) | ← 当前chunk的大小(低3位是标志位)
| |-- PREV_INUSE | ← 第0位:前一个chunk是否在使用中
| |-- IS_MMAPPED | ← 第1位:是否由mmap分配
| |-- NON_MAIN | ← 第2位:是否属于非主arena
+------------------+
| user data (N B) | ← malloc返回的指针指向这里
+------------------+
当一个chunk被free后,它的结构会发生变化——用户数据区会被复用为链表指针:
// 一个空闲chunk(free状态)
+------------------+
| prev_size (8B) | ← 前一个chunk的大小
+------------------+
| size (8B) | ← 当前chunk的大小(PREV_INUSE位清零)
+------------------+
| fd (8B) | ← 指向bin链表中下一个空闲chunk
+------------------+
| bk (8B) | ← 指向bin链表中上一个空闲chunk
+------------------+
| ... 剩余空间 ... |
+------------------+
我的经验:调试堆漏洞时,我习惯先看chunk的size字段。低3位标志位经常被忽略,但很多漏洞利用恰恰需要篡改这些位。比如伪造一个chunk时,PREV_INUSE位设错了,glibc的unlink操作就会崩溃——我当年因为这个bug调试了整整两天。
1.3 bin链表:空闲chunk的分类管理
glibc把空闲chunk分成几类,放在不同的bin里。为什么要分类?说白了就是为了提高分配效率——不同大小的chunk用不同的策略管理。
我整理了一个表格,把各种bin的差异列出来:
| bin类型 | chunk大小范围 | 数据结构 | 分配策略 | 合并策略 |
|---|---|---|---|---|
| fastbin | 0x20 ~ 0x80(64位) | 单链表(LIFO) | 直接取头节点 | 不合并相邻空闲chunk |
| unsorted bin | 任意大小 | 双向循环链表 | 遍历查找,找到就返回 | 会合并相邻空闲chunk |
| small bin | 0x20 ~ 0x3F0(64位) | 双向循环链表(FIFO) | 从尾部取chunk | 会合并相邻空闲chunk |
| large bin | ≥ 0x400(64位) | 双向循环链表(按大小排序) | 查找最合适的chunk | 会合并相邻空闲chunk |
这里有个关键点:fastbin的chunk不会合并。为什么?因为fastbin里的chunk都很小,合并的开销可能比分配还大。但这也给堆利用留下了空间——你可以反复malloc和free同一个大小的chunk,它们始终在fastbin里转悠,不会合并成大块。
避坑指南:我曾经在CTF比赛中犯过一个低级错误——以为free后的chunk会立即合并。实际上,只有相邻的非fastbin空闲chunk才会合并。如果你free了一个fastbin chunk,它只是被放回fastbin链表,不会触发合并操作。这个特性是很多堆漏洞利用的基础,比如double free。
1.4 malloc底层原理:从请求到内存
当你调用malloc(0x100)时,glibc内部到底发生了什么?我把它拆解成几个步骤:
- 检查fastbin:如果请求大小在fastbin范围内,直接从对应fastbin链表取头节点。时间复杂度O(1)。
- 检查small bin:如果请求大小在small bin范围内,从对应small bin链表尾部取chunk。时间复杂度O(1)。
- 检查unsorted bin:遍历unsorted bin,如果找到大小匹配的chunk就返回;否则把chunk分类放入small/large bin。
- 检查large bin:在large bin中查找最合适的chunk(大小≥请求大小且最接近)。
- 从top chunk切割:如果以上都找不到,从top chunk中切一块出来。如果top chunk不够大,则调用sbrk或mmap扩展堆。
你看,glibc的分配策略是从快到慢的。fastbin最快,top chunk最慢。这个设计很合理——大多数malloc请求都是小内存,用fastbin就能满足。
// 伪代码:malloc的核心逻辑
void *malloc(size_t size) {
// 1. 计算实际chunk大小(对齐+头)
size_t nb = request2size(size);
// 2. 检查fastbin
if (nb <= get_max_fast()) {
chunk = fastbin_pop(nb);
if (chunk) return chunk2mem(chunk);
}
// 3. 检查small bin
if (nb <= MAX_SMALLBIN_SIZE) {
chunk = smallbin_pop(nb);
if (chunk) return chunk2mem(chunk);
}
// 4. 处理unsorted bin + large bin + top chunk
return sysmalloc(nb);
}
1.5 free底层原理:回收的艺术
free的流程比malloc更复杂,因为涉及chunk合并。我总结为三步:
- 检查是否fastbin:如果chunk大小在fastbin范围内,直接放入fastbin链表。不合并,不检查前后chunk。
- 检查是否mmap:如果chunk是mmap分配的,直接munmap释放。
- 合并相邻空闲chunk:检查前后chunk是否空闲,如果是则合并成一个更大的chunk,然后放入unsorted bin。
合并操作是free中最关键的部分。glibc通过边界标记法来实现——每个chunk的size字段末尾记录了前一个chunk是否空闲。如果前一个chunk空闲,就通过prev_size找到它的起始地址,然后合并。
漏洞利用的关键:堆利用的核心思路就是破坏glibc对chunk状态的判断。比如伪造size字段的PREV_INUSE位,让glibc误以为前一个chunk空闲,从而触发unlink操作——这就是经典的unlink攻击。再比如通过溢出修改下一个chunk的size字段,制造chunk重叠——这就是overlapping chunks。
1.6 本章小结
好了,第一章的内容就到这里。我们覆盖了glibc堆管理器的三层架构、chunk的内存布局、bin链表的分类管理,以及malloc和free的底层执行流程。这些知识就像堆利用的"内功心法"——招式可以千变万化,但内力必须扎实。
我个人建议你花点时间,用gdb实际调试一下malloc和free的过程。设置断点在__libc_malloc和__libc_free,单步跟踪,看看chunk的size字段怎么变,fd/bk指针怎么连。我当年就是这么干的,虽然慢,但效果奇好。