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堆管理器架构总览 进程地址空间 main_arena(主线程) fastbinsY[10] bins[254](small/large/unsorted) top chunk指针 last_remainder mutex锁 非主arena(线程1) fastbinsY[10] bins[254] top chunk指针 last_remainder mutex锁 heap段(连续内存区域,由heap_info管理) chunk A chunk B chunk C chunk D chunk E top 每个arena管理多个heap段,每个heap段由连续chunk组成 top chunk是最后一个chunk,负责响应大内存请求

核心要点: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内部到底发生了什么?我把它拆解成几个步骤:

  1. 检查fastbin:如果请求大小在fastbin范围内,直接从对应fastbin链表取头节点。时间复杂度O(1)。
  2. 检查small bin:如果请求大小在small bin范围内,从对应small bin链表尾部取chunk。时间复杂度O(1)。
  3. 检查unsorted bin:遍历unsorted bin,如果找到大小匹配的chunk就返回;否则把chunk分类放入small/large bin。
  4. 检查large bin:在large bin中查找最合适的chunk(大小≥请求大小且最接近)。
  5. 从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合并。我总结为三步:

  1. 检查是否fastbin:如果chunk大小在fastbin范围内,直接放入fastbin链表。不合并,不检查前后chunk。
  2. 检查是否mmap:如果chunk是mmap分配的,直接munmap释放。
  3. 合并相邻空闲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指针怎么连。我当年就是这么干的,虽然慢,但效果奇好。