1. 堆溢出,到底是个什么东西
如果你已经入门了栈溢出,大概率会对堆溢出又好奇又头疼。栈溢出靠几行精心构造的shellcode就能打通链路,流程相对固定,工具链也成熟。可一旦转到堆这块,情况就完全不一样了——没有固定的"溢出即劫持"路径,环境不同、堆结构不同、分配器不同,利用手法完全可能是另一套打法。这也是很多初学者在栈溢出走得挺顺,一接触堆溢出就懵圈的根本原因。
先给个大致定义。堆溢出指的是程序在堆上分配缓冲区后,写入数据时越过了缓冲区的边界,污染了堆内存中相邻的其他数据。这里的关键在于,这种"越界"不是简单的"程序崩了",而是能让你去破坏堆分配器用来管理内存的核心元数据——只要这些元数据结构被你控制住,就能进一步获得任意地址写、任意地址读,甚至直接改写函数指针、GOT表项,把执行流劫持到你自己手里。
那么问题来了:为什么堆溢出比栈溢出难这么多?因为栈上数据是线性的、分布紧凑,溢出后很容易直接打return address。而堆是动态分配、动态释放的复杂结构,你溢出的那块只是整片堆里的一个小碎片,它周围还住着其他数据块、管理头、空闲链表指针等等。你不能直接"跨过"这些结构让程序往你希望的地方跳,你需要非常清楚堆管理器会把内存排成什么样子,才能精准地把脏数据写进该写的地方。
从工程实践的角度说,堆溢出通常不是"我直接编写攻击代码"这么简单,更多的是一种"理解程序内存布局、理解分配器行为、利用特定接口构造数据"的能力。换句话说,想真正学会堆溢出,你首先要学会的是:读内存、看堆布局、理解分配器的每一步动作。
这篇文章我不会泛泛而谈堆溢出的所有利用手法,而是聚焦原理层——让你搞清楚堆到底是怎么组织的、分配和释放时发生了什么、为什么溢出能造成劫持,以及我在实际调试过程中踩过的一系列坑。如果你属于那种"原理知一点、实操全靠猜"的群体,这篇文章应该正合你的胃口。
2. 堆的核心机制,先弄明白这两件事
2.1 堆不是你以为的那个"堆"
很多人听"C语言动态内存分配"会以为堆就是一块"自由的空地",里面想放什么就放什么。这话对应用程序来说是成立的,但堆管理器为了实现这块"自由空地",给自己加了很多规则。
主流Linux下运行的堆管理器是ptmalloc2,它基于经典的dlmalloc做了多线程优化。这玩意儿维护的核心数据结构有两大类:一类是分配出去的chunk(也就是你用的那块内存),另一类是回收后未分配的空闲chunk。这两者在内存里长着不同的"脸"。
先说occupied chunk,也就是已经被用户持有的内存块。它的布局大致是这样的:
- 当前chunk的大小(size字段)
- 前一个chunk是否被使用的标志位(prev_inuse位)
- 用户数据区域(fd和bk字段对用户不可见)
- 如果真的存在下一个chunk,下一个chunk的头部
听着有点绕对不对?我直接画一下。
以64位系统为例,一个典型的allocated chunk长这样:
+-------------------+ | prev_size | 8字节,只有当前一个chunk是空闲时才有效 +-------------------+ | size | 8字节,包含当前chunk大小及标志位 +-------------------+ | user_data(fd) | 用户可读写区域,要多少给多少 +-------------------+注意,这不是全部。真正的chunk为了内存对齐,实际大小会向上取整到16的倍数。也就是说你请求malloc(1),实际分配出来的chunk size可能是0x20(32字节),前16字节被header占据,后16字节给你用——当然你可以用header之后的全部字节。
这里有个新手极容易忽略的点:ptmalloc不会为每个chunk都记录"前一个chunk的prev_size",只有在前一个chunk处于free状态时,它才会使用prev_size字段。如果前一个chunk正在被使用,那prev_size就作为普通用户数据的一部分,可以写入任何内容。
2.2 释放一个chunk时,堆管理器在做什么
接着往下说。当我们把指针传给free(),堆管理器做的第一件事就是检查这个指针指的位置附近是否有合法的chunk header。它通过size字段的低位判断前一个chunk是否空闲,如果空闲,就把两个相邻的空闲chunk合并成一个更大的空闲块,这个过程叫coalescing(合并)。之后,这个合并出来的大块会被放进对应的bin(箱子)里管理。
bin是啥?你可以理解为空闲chunk的分类链表。ptmalloc管理着多个bin,类型主要包括:
- fastbin:尺寸小于等于0x80的小块,释放后不会立即合并,存取都很快
- unsorted bin:所有中等大小的释放块都会先进这个篮子,等待后续切割或重新分配
- small bin:尺寸固定的64个链表,按大小精确匹配
- large bin:尺寸范围广,包含63个链表,用于更大的内存块
这里有一个很经典的陷阱:fastbin中的chunk不会设置prev_inuse标志位,它们永远是"看起来被使用"的状态。这导致当你尝试把fastbin chunk跟相邻chunk合并时,堆管理器经常会拒绝——因为它误以为前面的chunk还在使用。
那这和溢出有什么关系呢?有,太有关系了。你溢出时写入脏数据,脏数据可能恰好落在另一个chunk的header位置。如果你把这个header的size字段改大、修改prev字段、伪造标志位,堆管理器在进行"垃圾回收"或"合并"时就会把你的垃圾数据当作管理信息来解读,从而做出完全错误的内存管理决策。
这就是堆溢出利用的根基——你不需要在溢出后写什么魔法值,你只要想办法让堆管理器"相信"某个地址附近存在它想要的数据,然后诱导它把这块"假chunk"当作真实分配结果返回给你。一旦你拿到的指针不是程序原本期望的位置,而是你伪造的假地址,后面的事情就水到渠成了。
2.3 堆溢出的前提条件
在很多实际漏洞里,堆溢出并不是"逻辑上天然存在",而是一个常规的边界检查缺失。最常见的情况:
void parse_data(char *input, size_t len) { char buf[64]; memcpy(buf, input, len); // len 可能大于 64 }这里如果len受外部控制且没有校验,就能造成堆缓冲区溢出。但问题在于——buf不是分配在栈上,而是分配在堆上,那你溢出后能打到什么,完全取决于这段内存紧挨着谁。如果buf分配后紧接着就是另一个堆块,那么写入超过64字节的数据就会污染下一个chunk的header。
当然,现实中的堆溢出往往没有这么耿直,很多是发生在循环写、字符串操作、格式化输出的边界误判上。不管源头是什么,它们的共同点是:程序在某个点连续写入超过缓冲区长度的数据,且没有检查容忍边界。
这也是为什么做堆溢出分析时,第一步永远是搞清楚"这个溢出能打多远"——不是看代码直觉,而是实际用内存调试器去看堆底部的排列方式,确认哪些数据段可以被越界写入。
3. 堆溢出的利用思路:从破坏元数据到劫持控制流
3.1 直接击中下一个chunk的header
堆溢出最常见的"首发目标"就是紧邻缓冲区之后的下一个chunk header。如果你能在溢出过程中改写这个chunk header里的size字段和prev_inuse标志位,堆管理器在后续分配或释放时就会基于你构造的假数据行动。
具体做法有很多,常见的一种流程是:
- 申请两个相邻堆块A和B,A可溢出,B是要被释放的目标
- 溢出A的数据,修改B的header——把B的size改大(比如把0x20改成0x80)
- 释放B,堆管理器把这个"扩大的大小"当成真实大小,把B以及B之后原本属于别的chunk的内存统一合并成一个大块
- 下一轮malloc请求返回这块扩大后的区域,你能控制的内存范围远大于预期
这种"改size引发错位管理"的思路,是很多早期Linux堆利用的核心。一旦你能骗过分配器,让一个chunk覆盖到其他chunk甚至GOT(全局偏移表)、栈上返回地址等敏感位置,剩下的就是布局数据的问题。
这里有个实测经验:你修改size后,一定要保证所有flag位符合堆管理器的预期。尤其是prev_inuse位,如果前一个chunk明明空闲却又被你设成inuse,合并逻辑可能直接崩溃或走错分支。很多初学者在这上面栽跟头,改size改得爽,却没注意用调试器核对内存布局,结果一释放直接Segmentation Fault。
3.2 unlink攻击,经典中的经典
如果把堆溢出利用按"历史经典程度"排序,unlink绝对名列前茅。它利用的是双向链表在删除节点时的写操作——当一个空闲chunk被从small bin或unsorted bin中取出时,堆管理器会对这个chunk进行unlink操作,伪代码大概是这样:
if (P->fd->bk == P && P->bk->fd == P) { P->fd->bk = P->bk; P->bk->fd = P->fd; }也就是说,假设P是一个正在被释放或取出的chunk,堆管理器相信P->fd指向的那个内存地址处有一个chunk,而这个chunk的bk字段应该指向P。同理,P->bk指向的内存地址处应该有一个chunk,那个chunk的fd应该指向P。只有这个校验通过后,P->fd和P->bk之间才会进行互写。
那怎么利用呢?如果你能控制P->fd的值为"目标地址-0x18",P->bk的值为"写入地址-0x10",且这两个地址对应的空间都满足上述校验条件,那么unlink发生时,堆管理器就会执行"目标地址-0x18->bk = 写入地址-0x10"和"写入地址-0x10->fd = 目标地址-0x18"这两个写操作。虽然绝对值取决于环境,但本质就是:你拥有了一个向任意地址写任意数据的原语。
unlink利用的关键难点在于校验要求:P->fd->bk == P 和 P->bk->fd == P,这两条校验很难绕过。后期很多环境都加上了更严格的检查,但理解它依然必要,因为很多现代手法其实是unlink思路的变体——比如通过伪造chunk结构让堆管理器相信你构造的地址是合法的链表节点。
在实际学习中,我推荐你至少在调试器里亲手做一次unlink攻击,不用急着打远程环境,先把"伪造chunk + 触发unlink + 观察两次写操作"这套流程跑通,你才算真正理解了堆攻击的本质。
3.3 Fastbin dup,最容易上手的第一步
如果unlink让你头大,那先别急着啃,换个思路试试fastbin dup。fastbin是ptmalloc中管理小尺寸空闲块的容器,它的特点是LIFO(后进先出)——释放的chunk会先进链表头部,下次分配时优先从头部取。
利用思路极其简单:
- 分配三个大小相同的chunk A、B、C
- 释放A,释放B,再释放A(double free)
- 此时fastbin链表的顺序是:A -> B -> A
- 连续两次malloc请求,拿到的都是A
- 第三次malloc拿到的是B
关键点在于第三步:你把一个chunk释放了两次,导致fastbin链表里出现环形引用。这样攻击者就能反复拿到同一块内存,并在里面伪造数据。如果后续程序在某处把这块"重复返回"的内存当作不同的变量来处理,攻击机会就来了。
Fastbin dup之所以适合新手入门,是因为它的原理足够简单,只需要理解"链表头插、LIFO、重复释放导致重复分配"这三个概念,不需要去触碰复杂的内存布局计算。但它的局限性也很明显:fastbin只覆盖小块内存,且要求释放过程不发生合并,一旦chunk周围环境不满足条件,攻击就会失败。
我第一次做fastbin dup的时候,卡在了一个让人哭笑不得的环节上——我用的是glibc比较新的版本,里面给double free加了一个key字段校验,导致同样的操作在旧教程里没问题,在我环境里直接报错。这提醒我,学堆溢出一定要看版本、看环境、看配置,不能把教程里的流程当教条。
3.4 从溢出到shell的完整路径
上面讲了具体攻击原语,现在串一下完整APP利用路径。在真实CTF题目或真实漏洞利用里,堆溢出往往不是单独使用的,而是作为起点、配合其他能力逐步搭建成体系。
典型的利用链可以概括为:
- 触发堆溢出,污染相邻chunk的header
- 利用被污染的header实现任意地址分配(即malloc返回一个你指定的地址,称为arbitrary allocation)
- 利用任意地址分配,改写某个关键指针——比如__free_hook、__malloc_hook,或者更常用的GOT表项
- 等待程序调用被劫持的函数,执行system("/bin/sh")或执行一段ROP链
这里最核心的一环,是把堆溢出转成"任意地址写"。如果你能稳定地让分配器返回你想要的内存地址,后续的劫持几乎是势如破竹的。而一旦你做不到任意地址分配,那就退而求其次,利用溢出一点点改数据,做更精细的记忆体布局操作。
值得注意的细节是:新版本的glibc对__malloc_hook、__free_hook这类"钩子函数"机制做了各种调整,部分环境已经禁用了这些函数指针的赋值。在我实操时,我一般优先检查目标程序的导出符号,如果环境里还有__free_hook,那利用路径会简单得多;如果没戏,就要考虑通过攻击GOT表或者触发malloc_consolidate实现更复杂的利用。总之,"溢出→任意写→劫持"这个思路是通用的,具体落点则要看环境。
4. 从堆布局到实战利用:手把手走一遍完整流程
4.1 环境准备
老规矩,做堆溢出分析,你需要一套趁手的工具链。我自己的标配是:
- Ubuntu 18.04或更早版本(glibc 2.27及以下,便于利用经典的fastbin机制)
- gdb 调试器
- pwndbg 或 gef 插件(必须,没有人会手搓堆内存的十六进制视图)
- 一个提供堆内存可视化功能的脚本,这里我推荐pwndbg自带的heap命令,视野极其友好
注意一点:不同glibc版本对堆布局和校验逻辑影响巨大。比如glibc 2.23和2.27中,malloc的校验机制就完全不同,fastbin dup在2.23环境是畅通无阻的,但2.27加入了key字段校验后,double free就得再多加几步绕过手段。我强烈建议,初学阶段直接锁定一个"教学友好"的glibc版本,不要一上来就挑战新版本。
4.2 搭建可溢出的实验程序
为了讲实操,我准备了一个极简的堆溢出演示代码。它模拟了实际环境中常见的"用户可控长度写入固定缓冲区"场景:
#include <stdio.h> #include <stdlib.h> #include <string.h> int main() { char *a = malloc(0x20); char *b = malloc(0x20); char *c = malloc(0x20); printf("a: %p\nb: %p\nc: %p\n", a, b, c); // 模拟堆溢出:向a写入超长数据,覆盖b的header memset(a, 'A', 0x40); gets(a); free(b); free(c); return 0; }这段代码的逻辑比较直白:分配三个0x20的chunk,然后向a写入远超0x20大小的数据。如果没有检查,a的数据会一直往高地址方向蔓延,一路覆盖到b的chunk header,甚至可能污染b之后的数据。
4.3 观察堆布局,找到注入点
好,我们现在进入实操。先编译程序,然后在gdb里跑起来,用pwndbg的heap命令观察堆布局。
gdb ./heap_overflow pwndbg> break main pwndbg> run pwndbg> heap你会看到类似这样的输出:
Allocated chunk | 0x603000 | prev_size: 0, size: 0x31 Allocated chunk | 0x603030 | prev_size: 0, size: 0x31 Allocated chunk | 0x603060 | prev_size: 0, size: 0x31三块0x31大小的chunk分别位于0x603000、0x603030、0x603060,每个chunk的头部是0x10字节(prev_size+size),用户数据区从0x603020开始。所以当我们调用gets(a)并输入大量字符时,数据会从0x603020开始写,逐步越过0x603030(b的header),一直到0x603060(c的header),甚至越到更远处。
这里有一个非常重要的细节:即使你只是多输入了8字节,也可能把b的header的size字段的某些位改掉。在64位系统里,size字段的最低那个位是prev_inuse标志。如果你改掉了它,free(b)时堆管理器的合并逻辑会受到干扰,直接引发不可预测的行为。
那怎么利用这个溢出点呢?最经典的做法是:
- 溢出a区域,把b的prev_inuse位置为0(即"让堆管理器认为b前面的chunk也是空闲的")
- 同时把b的size字段改大,比如从0x31改成0x61
- 释放b,触发堆管理器把b以及b之后的大块内存合并成一个空洞
- 后续malloc会返回这块被扩大的区域
但如果目的是攻击unsorted bin,你还需要伪造prev_size字段——因为这个字段会被堆管理器当作前一个chunk的大小来使用,在合并操作中被加到新的偏移量里。这也是堆溢出最考验细致程度的部分:你不仅要改当前chunk的header,还要伪造"虚假的前一个chunk"来欺骗堆管理器。
4.4 实测:改装b的size字段,观察free行为
让我实际操作一下感受一下。
// 向a写入 0x20 字节的 'A',再把 b 的 size 改为 0x61 // 在 gdb 中这样操作: // set {long} (a+0x20) = 0x61执行后,a的空间被填满,b的header的size被改成0x61(原本是0x31)。然后我们调用free(b)。
此时堆管理器会查看b的size=0x61,发现它和b前的chunk(也就是a)之间的距离是0x20字节。它认为在这个位置存在一个chunk header,于是它读取"前一个chunk"的prev_inuse位。如果这个位是0,那么它就会执行合并,把b和"前一个chunk"(其实是我们伪造出来的那个位置)拼成一个0x90这样的大块。
在我调试时,执行这一步直接崩了。原因是:我的b前面并没有一个合法的空闲chunk,它的prev_size字段实际上是a的数据区内容,堆管理器读取到的是一个不可预测的值。这就告诉我——实际的利用需要对内存进行极其精细的"化妆",不是简单改个size就能成功的。
4.5 完整利用的简化步骤参考
给想看完整利用的读者一个思路导图,具体手法不同环境有区别,但骨架如下:
- 找出溢出点,确定可控长度(比如能写0x50字节)
- 设计"目标堆块",判断它会在溢出后被谁释放、在什么时候释放
- 构造payload:padding + 伪造的header区域 + 目标函数地址
- 触发释放,观察堆管理器的处理过程
- 验证能否通过堆溢出拿到任意地址写能力
- 将任意地址写转化为代码执行(改GOT、改hook、写ROP链)
这一路上每一步都需要用gdb验证内存状态,不要想着"一次到位"。我见过太多人在"崩溃后直接无脑盲调"里卡住,最有效的方法反而是:每次操作后都看一眼堆的实际布局,用x/20gx 地址这种命令检查内存变化,慢慢逼近目标状态。
5. 实际调试中避不开的那些坑
5.1 编译器优化影响堆布局,防不胜防
做漏洞分析时,我们经常拿本地编译的程序来测试。但你有没有发现,加了-O2优化后,堆布局和-O0完全不同?原因很简单:优化可能改变局部变量的生命周期,变量可能在函数结束前就被释放,也可能从栈上被搬进寄存器,导致you原本预期的"相邻堆块"根本不存在。
所以我的个人经验是:如果你是为了研究原理,那就用-O0 -g编译,这样便于调试;如果你是为了模拟真实场景,那最好用和目标程序相同的编译选项和glibc版本,不要贪图方便直接拿本地默认环境跑。
另外,ASLR(地址空间布局随机化)默认开启的机器,每次运行时堆的基址都会变化。这不会影响堆块之间的相对偏移,但会影响你需要硬编码的绝对地址。在实际利用时,最好通过信息泄露的方式动态计算地址,而不是写死。
5.2 64位和32位的差异,绝不是改个地址长度那么简单
很多堆溢出教程还在用32位系统讲原理,如果你直接照搬到64位,绝对会踩坑。最大的差异在于:
- chunk size的最小单位不同,64位下chunk大小按16字节对齐,32位是8字节
- 指针大小从4字节变成8字节,导致fd和bk字段的偏移变化
- 地址空间范围不同,64位下高地址往往包含不可读的canonical地址,写数据时容易触发无效地址
- 参数传递方式不同(64位用寄存器传参),对利用链的要求更高
以fastbin dup为例,32位下你只需要伪造一个4字节的fd指针,但64位下你必须保证这个地址上是合法的堆管理数据结构,否则malloc会直接拒绝分配。这意味着在64位下做堆利用,你往往要构造一个"假的chunk header",让堆管理器误认为这个地址处是一个合法的空闲chunk。
5.3 不要迷信"unlink一定可行"
我在一些入门博客上看到,很多人把unlink攻击当作堆溢出的万能钥匙。但实际环境中,新版glibc对unlink的校验已经做了加强,尤其是"P->fd->bk == P && P->bk->fd == P"这条校验,在没有先构造好特定内存布局时几乎无法通过。
如果你还是想用unlink,至少需要满足两个条件:一是目标堆块在small bin或unsorted bin中,且它的fd和bk字段是你能控制的;二是你必须在目标地址处预先准备两个"看起来像chunk"的数据结构,让校验通过。这两个条件在真实程序中往往很难同时满足。
所以我在实际项目里更倾向于用如下替代方案:
- 利用double free制造fastbin循环,实现任意地址分配
- 利用large bin中的fd_nextsize和bk_nextsize字段实现无校验的写原语
- 利用tcache(新版本glibc的线程局部缓存)机制做更直接的攻击
这些手法虽然对初学者来说略显复杂,但实际利用的成功率比unlink高得多。
5.4 tcache时代的堆攻击,思路要换代
如果你用的是glibc 2.26以上的环境(现在绝大多数发行版都是),那堆管理的样子又变了。tcache(thread local cache)引入后,每个线程都会有一个小型的缓存池,里面存着最多7个已释放的chunk。它的特点是单链表结构、无双重链表校验,导致攻击方式大幅简化。
关键点在于:tcache对double free的检测非常薄弱。如果你释放同一个chunk两次,只要第二次释放前把chunk的fd字段改成0(或改成干扰值),就能绕过大多数检测。然后你就能做"tcache poisoning":直接改写tcache链表的fd指针,让下一次malloc返回任意地址。
我给一个简单的tcache poisoning思路:
- 申请两个chunk A和B
- 释放A,释放B,释放A(double free)
- 修改A->fd = 目标地址(比如__free_hook)
- 连续malloc两次,第一次返回原来的A,第二次返回目标地址
- 在目标地址写入你的数据(比如system函数的地址)
流程和fastbin dup类似,但tcache的实现更简单、限制更少,所以在现代环境里几乎是最常用的堆攻击手段。
我个人建议,如果你是新手,可以从tcache poisoning入手,它比unlink和fastbin dup都容易理解,而且应用场景广。只要你理解了"链表头插、取出分配"这两个基本动作,就能推演出一整套tcache攻击变化。
5.5 学堆溢出,不能只靠读,得亲手"堆"一次
这是我最想强调的一点。堆溢出的原理并不复杂,但你若只在脑子里推演"我给chunk A写入超长数据,覆盖了chunk B的header,然后……",那你永远学不会。原因很直白:堆管理器有太多隐式行为——比如校验失败后的fallback路径、不同的malloc调用导致的切割行为、合并时对边界条件的处理——这些不通过实战观察根本无法真正内化。
我学堆溢出时的做法是,给自己定了一个"三步拆解"的练习方法:
- 构造最小实验:写一段20行以内的代码,制造一个可控的堆溢出,不追求利用,只追求"溢出后程序还能稳定运行",观察溢出点周围的内存变化。
- 目标劫持:在上一步基础上,尝试让某个无关变量被溢出数据改变,从而实现简单的逻辑跳转。
- 完整利用:尝试在开启ASLR、PIE、Full RELRO的环境下完成一个完整的shell获取流程。
每一步都记录下你观察到的内存状态、堆管理器的反应、以及你微调了哪几个字节导致行为变化。这套方法看上去笨,但它的好处是让你把"堆管理器的行为"当成一种可以被预测和操纵的现象,而不是黑盒魔法。
6. 调试工具与问题排查速查
6.1 常用的gdb调试命令
如果你要在堆溢出上继续深入,下面的命令必须用得行云流水。我把它们整理成了一张速查表。
| 命令 | 作用 | 使用频率 |
|---|---|---|
heap | 查看当前堆所有chunk的地址、大小、状态 | 极高 |
heap bins | 查看fastbin、small bin、unsorted bin等链表内容 | 极高 |
x/20gx 地址 | 以16字节为单位的十六进制查看指定地址内存 | 极高 |
set {long}地址 = 值 | 修改指定地址的内存值 | 高 |
find 起始地址 结束地址 值 | 在内存中搜索指定值 | 中 |
vmmap | 查看进程内存映射,定位堆段 | 高 |
break *地址 | 对指定地址下断点,方便观察函数调用点 | 中 |
另外你在调试时如果想让程序停在某个glibc内部函数里,可以break free或break malloc,这样能更直观地看到堆管理器何时介入。
6.2 常见问题及排查思路
跑堆溢出实验时,你大概率会遇到下面这些状况。我直接给排查方向。
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 崩溃在free()内部 | 目标chunk的header被破坏,堆管理器校验失败 | 用heap查看附近chunk布局,检查size和prev_inuse位是否合理 |
| 崩溃在malloc()内部 | 释放的chunk出现在非法链表,或链表指针被污染 | 用heap bins查看bin链表,检查fd、bk指针是否指向合法地址 |
| 直接Segmentation Fault | 写入地址超出可写内存区域 | 用vmmap查看内存权限,检查你是否写到不可写页 |
| 攻击后没有任何效果 | 溢出数据没有到达目标位置 | 用x/20gx观察溢出缓冲区周围内存,计算写入长度和偏移量 |
| 所有地址都不稳定 | ASLR开启,地址随机化 | 尝试关闭ASLR(setarch -R)或先练习固定地址环境 |
| 输出乱码或崩溃在printf | 缓冲区数据不是合法的函数指针 | 用info func确认目标函数地址,检查偏移是否正确 |
这些坑我都踩过,尤其是"攻击后没有效果"这种问题——很多时候不是你的payload写错了,而是溢出长度根本没算对,差了那么8字节,攻击落点完全偏移。所以我在调试时总会反复核对"我要从哪个字节开始篡改目标chunk的哪个字段",而不是一头扎进payload里。
6.3 一个福利向提示:常用防护机制速查
最后提一下常见的防护机制,因为堆溢出利用是否可以落地,极大程度上受这些机制影响。
| 防护机制 | 含义 | 对策 |
|---|---|---|
| NX | 栈不可执行 | 用ROP或直接调用现成函数 |
| ASLR | 地址随机化 | 靠信息泄露获取地址 |
| PIE | 程序基址随机化 | 泄露程序基址或利用相对偏移 |
| Full RELRO | GOT表不可写 | 改__free_hook、__malloc_hook等 |
| Partial RELRO | GOT表部分可写 | 可尝试改写GOT表项 |
| Stack Canary | 栈保护 | 堆溢出时通常不触发,但要注意write顺序 |
堆溢出研究到后半段,你会越来越感受到这些防护机制带来的"压迫感"。每次研究新目标,第一步永远是checksec看防护设置,主流的调试工具都内置了这条命令,五秒钟就能看清底牌。
7. 写在最后的几点经验
堆溢出学习的确是个陡峭的坡。它不像栈溢出那样,学完原理就能写出exp,而是需要你同时具备几种能力:对内存布局的直觉、对堆管理器行为的掌握、对调试工具的熟练运用、以及对程序运行流程的全局理解。
我个人的体会是,最有价值的学习路径不是照着某篇writeup一步步抄,而是拿一个你完全不了解的堆溢出漏洞,从定位溢出点开始,自己一步步推导出利用流程。过程中你会犯很多错,但这些错误恰恰是理解堆管理器行为的入口。
如果你现在正卡在某个堆溢出的细节上,试着手动用gdb把堆的完整布局打印出来,然后逐个字段去对照源码里堆管理器的处理逻辑。你可能会发现,原来那个让你百思不得其解的"崩溃",只是在某个标志位上差了一位而已。
另外,如果你想在这个方向上继续深入,之后可以去看看堆风水(Heap Feng Shui)、House of系列(如House of Orange、House of Einherjar)相关的内容。这些高阶技巧本质上仍然是在做同一件事:用堆管理器的规则塑造出攻击者想要的内存状态。原理框架不变,变的只是更精细的布局手法和更多样的触发路径。
最后再分享一个实用小技巧。调试堆溢出时,我习惯在每个关键操作前后各截一张"内存快照":分配前看一眼,分配后看一眼,释放前再看一眼。这样能直观暴露出堆管理器对每个操作产生的内存变化,找错点的时候省下大量时间。别怕麻烦,这些快照就是你的"现场笔录",等你能对着快照流利说出"这一步应该是unsorted bin,这里走了切割分支"的时候,堆溢出的底子就算真正打牢了。