资讯动态

内存破坏排查指南:栈溢出、堆溢出与Use-After-Free调试实战

发布时间:2026/10/8 10:11:27 来源:尧图企业网站定制
做我们这行的人最怕的不是业务逻辑写错逻辑写错顶多跑出个错误结果看得见摸得着。真正让人头皮发麻的是那种“程序跑得好好的上线三天后崩一次还崩在完全不相干的代码里”的诡异问题。这时候你用 GDB 跟进去看调用栈是好的变量看着也是对的但你心里清楚内存已经烂掉了。内存破坏英文叫 memory corruption是 C/C 程序员绕不开的噩梦。这篇东西我打算把我这些年排查内存破坏类问题的经验做个彻底梳理讲清楚栈溢出、堆溢出、Use-After-Free 这类问题到底是怎么发生的、怎么调试的以及最重要的——如何在毫无头绪的时候找到那条隐藏的“作案路径”。这篇内容的受众很明确写 C/C、写系统底层、写游戏引擎、写嵌入式固件的朋友。如果你每天都在跟指针、堆、栈打交道那么这篇文章里的方法你迟早用得上。就算你是刚接触系统编程的初学者我也会尽量把原理讲得通俗一些保证你能看懂并且能上手操作。1. 内存破坏先搞懂它到底在哪发生的内存破坏这个概念看着高大上本质其实很朴素——程序访问了“不属于它的内存”而且这个访问操作是写操作于是把别人地盘上的数据给改坏了。问题是写坏之后往往不会立刻报错而是等到那块被污染的数据被再次使用的时候才爆炸所以这类 bug 的排查难度极高。1.1 最常见的四类内存破坏先认清症状我习惯把内存破坏分成四大类每类的成因和表现都不同排查思路也有明显差异。把它们放在一张表里对比着看会清楚很多。类型一句话解释典型症状常见根因栈溢出往栈上的数组越界写入破坏了相邻的栈变量或返回地址函数返回时崩溃、返回地址被篡改后跳去奇怪的地方strcpy拷贝超长字符串、局部数组越界索引堆溢出写入的数据超出了堆块边界破坏了相邻堆块或堆管理元数据free()时崩溃、malloc 报 corrupted top sizememcpy长度参数算错、动态数组越界写Use-After-Free内存释放后指针仍然被使用指针指向的内存已被复用读取到魔改数据、虚函数调用崩溃对象生命周期管理失误、多线程下释放与使用竞态Double Free同一块内存被释放两次free 时报 double free or corruption错误码路径重复释放、智能指针误用很多人一遇到内存破坏就慌了到处加打印、瞎试其实最应该做的第一件事是“认症状”。症状决定了你朝哪个方向查。比如程序每次都是在函数返回的时候崩优先怀疑栈被破坏如果是 free 的时候崩优先怀疑堆块元数据被踩了。认准类型排查范围能缩小一大半。1.2 为什么“出事地点”往往不是“作案地点”这是内存破坏调试最反直觉、也最关键的一点崩溃位置通常不是写入越界的位置而是受害者数据被使用的位置。用个生活例子类比就很好理解了——你在一本书的第 10 页把一个数字写错了这页看不出什么问题但翻到第 50 页发现账目对不上了。内存破坏也是这个道理。我印象很深的一次经历某个服务进程频繁在 hash 表插入节点时崩溃我盯着那一段代码看了一整天逻辑完全正确。后来用 AddressSanitizer 一跑真正的问题出在另一处完全无关的模块里——那里有个结构体数组少分配了一个元素越界写正好把 hash 表的 bucket 指针给踩了。从那一刻起我就明白了一个道理排查内存破坏目标永远是找到“污染源”而不是纠结于“崩溃点”。崩溃点只是一个提示告诉你数据坏了污染源才是那个真正该修的地方。2. 调试前的准备三个必须养成的习惯很多人遇到内存破坏类 bug第一反应就是开 GDB 在崩溃点看堆栈这个做法没错但远远不够。因为在动手之前你至少要完成三件准备工作稳定复现、选择正确的编译与工具配置、保留现场的 core dump。这三件事缺一件后面所有的调试都会事倍功半。2.1 稳定复现是整个调试的地基内存破坏类 bug 最大的敌人是“偶发性”。一个崩溃三天才出现一次你连抓现场的机会都没有何谈定位所以第一步永远是——努力把偶发问题变成必现问题。我的做法通常是这样去掉一切“不确定性因素”随机数、真实时间、网络输入都改成固定的种子值或录制好的回放数据如果程序有重试、自恢复机制先关掉让它一击即碎。用穷举的方式暴力触发把关键函数放进循环里跑几万次把并发线程数调到上限让数据碰撞的概率成倍提升。每改一次代码用二分法缩小范围注释掉一半功能模块看问题是否消失反复砍半。这个过程虽然笨但极其有效。稳定的复现是内存破坏调试的黄金前提。如果你连考卷都没办法拿到第二份那后面的任何高级技巧都是空中楼阁。2.2 先把编译选项和工具链调对再谈调试调试内存破坏用的编译选项和平时生产环境完全不同。牺牲性能换信息是这一阶段必须接受的代价。推荐的一组基础配置-g生成调试信息这个不用多说。-O0禁止优化防止变量被优化掉、代码顺序被重排导致调试时无法复现。-fno-omit-frame-pointer保留帧指针让回溯调用栈更可靠。-fsanitizeaddress开启 AddressSanitizer这是目前定位内存错误最锋利的刀后面专门讲。很多人问过我线上是-O2编译的我用-O0复现不了怎么办这个问题确实存在优化级别不同会导致代码路径变化。我的建议是先用优化版抓 core用非优化版做深入定位。如果两边的崩溃栈不一致优先相信优化版的崩溃点但用非优化版配合工具去推导污染源。2.3 做好兜底core dump 是你最后的现场记录哪怕有了 ASan也一定要养成查看 core dump 的习惯。因为生产环境往往没法挂 ASan能留下的证据就是一份 core 文件。用 core dump 有两个前置步骤必须做检查ulimit -c如果输出是0说明 core 被禁用了执行ulimit -c unlimited放开限制。确认 core 文件的生成目录。Linux 下默认是进程的工作目录但很多系统配置了 systemd 或自动重启机制core 文件可能被清除或改名建议通过/proc/sys/kernel/core_pattern查看规则必要时临时挂一个独立的 core 存放路径。拿到 core 之后gdb ./program core加载它会在崩溃位置停留。这时候第一个指令永远是bt查看调用栈。有了调用栈再结合下面章节的针对性手法一步步逼近污染源。3. ASan 实战定位内存破坏的效率之王如果说调试内存破坏只能选一个工具我毫不犹豫选 AddressSanitizer。它能在错误发生的那一刻当场拦截直接告诉你“哪条指令、访问了哪块地址、这块地址属于谁”——这几乎是上帝视角。很多我手动调了两三天都没头绪的问题丢给 ASan 跑一遍十分钟就水落石出。3.1 ASan 的原理很简单但确实好用ASan 的核心原理是“插桩 影子内存”。编译时它会自动在每一次内存读写操作前后插入检查代码然后在真实地址空间的每个字节旁边映射一小块“影子区域”标记这块内存合法的边界。当你访问越界地址时检查代码会立刻从影子区发现异常随即打印出详细的错误报告并中止程序。说起来有点抽象实际上你只需要记得三件事编译时加-fsanitizeaddress。运行时无需额外操作直接执行程序。看到报错后重点关注错误报告中标注的“WRITE of size X at 0x...”和“freed by”两行。举个典型例子gcc -g -O1 -fsanitizeaddress -fno-omit-frame-pointer -o test test.c ./test如果程序里有堆越界写ASan 会输出类似下面的报告ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000014 at pc 0x... WRITE of size 4 at 0x602000000014 thread T0 #0 0x... in main /home/user/test.c:8 0x602000000014 is located 0 bytes to the right of 16-byte region ... allocated by thread T0 here: #0 0x... in __interceptor_malloc #1 0x... in main /home/user/test.c:7这个报告已经替你完成了 90% 的排查工作哪一行代码越界写、越界了多远、这块内存是在哪里分配的。这种精度是肉眼追代码完全无法企及的。3.2 ASan 也有踩坑的时候误报与性能损耗ASan 不是银弹不能无脑在生产环境用。先说性能开了 ASan 的程序运行速度通常降到原来的 1/2 到 1/5内存占用也要涨好几倍在并发密集的线上服务里根本跑不动。所以 ASan 的最佳使用场景是本地复现、测试环境和 CI 流水线让它作为质量门禁的一段。另外还有一类场景会遇到“误报”或者“无意义报错”比如你用了自研的内存池、对象池、第三方虚拟机它们内部有自己的分配和释放管理ASan 无法感知就会当成 Use-After-Free 或者溢出报告出来。遇到这种问题可以用ASAN_OPTIONS环境变量做部分规避export ASAN_OPTIONSdetect_leaks0:halt_on_error0detect_leaks0关闭内存泄漏检测halt_on_error0让程序报错后不立即终止而是继续运行收集更多信息。还有一些场景需要过滤特定函数的检查ASan 也支持__asan_address_is_poisoned这类手动函数但用到的场景很少真遇到了再查文档也来得及。我的实操心得ASan 报告出来的“第一处错误”往往最关键修完第一处之后一定要重跑一遍——因为内存破坏经常是连环的第一处污染会引发后续一串症状修掉根源后后面那些表面错误可能全部自动消失。4. 没有 ASan 的日子GDB 与核心转储的手工排查法ASan 固然强大但总有它覆盖不到的场景。比如生产环境的 core dump你没法要求客户重新编译一个插桩版本再比如某些嵌入式环境性能开销完全不允许 ASan 生存。这种时候你就得用 GDB 加上 human brain手动还原案发现场。4.1 用 watchpoint 监视内存何时被“动手脚”手工排查内存破坏最核心的思路是用 GDB 的硬件断点能力——watchpoint去监视某个内存地址。一旦这个地址被写入CPU 会立刻停顿下来把我们精准地引导到“作案现场”。具体操作分两步第一步先确定可疑地址。比如我怀疑obj-len这个字段被踩了可以先打断点在该字段的读取处打印它的地址(gdb) p obj-len $1 (unsigned int *) 0x7fffffffe3a8第二步用 watch 命令监视这个地址(gdb) watch *(unsigned int *)0x7fffffffe3a8 Hardware watchpoint 2: *(unsigned int *)0x7fffffffe3a8接下来continue运行当程序试图写入这个地址时GDB 会在写入指令的下一行停下此时bt看到的调用栈就是真正的污染源。这个过程说白点就是你在数据旁边安了一个“抓握手”的警报器不管谁碰它当场落网。有一点需要特别注意watchpoint 数量有限而且基于硬件监视的内存宽度越大、数量越多CPU 资源消耗越高程序运行会明显变慢。所以别一口气 watch 十几个地址挑最可疑的两三个来。4.2 解读堆破坏的典型报错现场手工调试堆破坏最常见的一幕是程序 free 一块内存时崩了报错是各种 “malloc(): corrupted top size” 或者 “free(): invalid next size (fast)”。很多人看到这串英文就头皮发麻其实它的含义很直接glibc 在释放内存时检查堆块的头部元数据发现被破坏了。堆块的头部里存着前一块大小、当前块大小、是否被占用等信息。当你越界写入时很可能把这些元数据改掉了所以 free 的时候一路检查到问题当场报错。这时候我一般这么做在崩溃处p打印传入 free 的指针比如p ptr。用x/16gx ptr - 0x20查看该指针之前 32 字节的原始数据那里就是堆块头。观察 size 字段是不是一个明显离谱的值比如原本应该是0x51现在变成了0x4141414141414141这时候基本可以断定是某处字符串拷贝越界。这些字节特征很有识别度如果看到堆块里出现大量0x41也就是字符A、0x00等规律性数据说明是被某段 buffer 或字符串写坏了如果是杂乱无章的内容那就需要更仔细地回溯写入方。4.3 Use-After-Free 的手工追踪路径Use-After-Free 是内存破坏里最隐蔽的一类。对象释放之后堆内存可能马上被其他对象重新分配旧指针依然指向这块地址。最经典的崩溃场景你持有某个对象指针对象的虚函数表已被新内容覆盖调用成员函数时跳到非代码区瞬间 SIGSEGV。手工排查我建议按以下顺序做查看崩溃时的“this 指针”或目标指针的值用p/x ptr。在 GDB 里用x/8gx ptr看这块内存当前内容判断是否已经被新数据覆盖。如果内容是另一个同类对象的样子那么大概率是同一个分配地址被复用UAF 实锤。进一步在指针释放的位置和最后一次使用的位置分别打断点确认两者之间的代码路径。最彻底的防御手段其实不在调试中而在代码层面释放指针后立刻将指针置为 NULL并且统一封装 free 宏。这么做并不能阻止所有 UAF但能大幅减少“悬空指针仍然被访问”的概率。4.4 从蝴蝶效应反推虚函数表被覆盖的典型案例说一个我实际遇到过的典型场景帮大家建立一下直觉。某引擎模块加载配置文件后频繁在构造新对象时崩溃GDB 显示崩溃在invoke virtual的内部函数里。当时百思不得其解因为这个新对象显然不该有虚函数问题。后来盯着地址看发现崩溃对象的虚表指针值出奇地整——是 0x6666666666666666。这个特征太明显了八成是某个memset函数用 0x66 填充了一块缓冲区缓冲区的目标对象恰好覆盖了那块堆内存。顺着这个方向搜索代码里所有填充值为 0x66 的地方真凶很快浮出水面一个日志模块在写字符串时长度算错往后多写了 8 个字节正好把旁边对象的前 8 个字节——也就是虚表指针——覆盖成了固定的填充字符。这个案例告诉我们当崩溃点在虚函数调用时先看看崩溃对象的虚表指针值是不是一个“非常有规律”的数字。一旦是就离破案不远了。5. 常见问题与排查技巧实录到这一节我把这些年遇到的典型问题、现象、以及对应的排查方向全部整理成速查表方便你遇到问题能快速定位到下一步动作。5.1 典型报错与对应的排查方向报错/现象最可能的破坏类型推荐第一步操作free(): corrupted top size堆溢出破坏了堆管理元数据找到该次分配大小检查所有写入该块的 memcpy/strcpy 长度free(): invalid next size堆块头部被覆盖查看指针附近的堆块头部数据识别是否含规律字符double free or corruption (!prev)重复释放 / 堆块复用检查所有 free 路径打印每次释放时的调用栈函数返回时崩溃bt显示返回地址异常栈溢出回溯到返回地址被改写的指令处排查局部数组越界调用虚函数崩溃对象指针看起来“很怪”UAF 或对象内存被覆盖查看对象虚表指针值判断是否被已知填充字符覆盖随机、偶发、难以复现的崩溃多线程写并发或全局/静态区越界上 ASan ThreadSanitizer 组合跑压力测试这张表不敢说覆盖全部场景但覆盖了我实际工作中 80% 以上的内存破坏类问题。剩下那 20%往往需要更精细的代码审查和更复杂的工具介入。5.2 几个经常被忽略的独门技巧换一个内存分配器做对照实验。glibc 的堆管理有自己的算法某些破坏症状会被它掩盖。用 jemalloc 或 tcmalloc 重新编译链接如果问题从“堆破坏报错”变成了“明文越界崩溃”往往更容易定位。打印全部可疑指针的分配与释放路径。在分配和释放两处用宏打印文件行号和地址崩溃时对照日志看谁在“死后”访问了这块内存。这个土办法效率不高但在没有工具的嵌入式环境里非常救命。用 mprotect 制造保护页。对某些特别重要的内存区域比如对象池手工设置PROT_NONE保护页一旦有访问立刻触发 SIGSEGV能有效拦截 UAF 的读操作。这个手法在现场很难用但写测试代码复现问题时极其有效。5.3 内存破坏调试的标准化流程最后分享一套我自己的标准作战流程基本已经固化成肌肉记忆了复现优先先把偶发变必现这一步不完成绝不开始排查。上 ASan本地编译插桩版本跑一遍测试用例看能否直接抓到第一现场。不行就上 ValgrindValgrind 是纯动态分析工具不依赖编译插桩对某些场景更全面但速度极慢适合小规模用例。再看 core用 GDB 加载生产环境 core分析崩溃点和对象状态结合 watchpoint 追踪污染源。最后靠代码审查所有工具都用尽之后冷静地 review 一遍涉嫌模块尤其是 memcpy、strcpy、数组下标、指针类型转换这些高危操作点。关于指针类型转换这里特别提醒一句(char*)强转后做指针运算是内存越界的一大隐藏入口。很多人写着写着就把int*转成char*然后执行1指针粒度从 4 字节变成了 1 字节数量关系一旦算错写穿缓冲区的风险极高。凡是看到这类代码我建议都停下来多算一遍偏移量。我个人在实际操作中体会最深的一点是不要在崩溃现场急着改代码。忍一忍先找出污染源确认它和崩溃点的因果关系再动手修复。不依赖工具的“读代码”能力才是内存破坏调试真正分高下的地方——这个能力没有捷径多调几个这种 bug自然就有了。最后再分享一个我一直在用的小技巧每次修完一个内存破坏 bug我不会立刻收工而是把修复后的代码拿去做一轮更大规模的随机压力测试同时开着 ASan。很多时候第一个 bug 只是冰山一角同一片区域还潜伏着第二个、第三个类似问题。这种“修完一次再炸一轮”的成就感其实比一次性解决更踏实——因为它意味着你实实在在排掉了一整片雷区。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑