free 之后那块内存里有什么:值还在,却已不属于你

发布时间:2026/10/21 2:27:02
free 之后那块内存里有什么:值还在,却已不属于你
int*pmalloc(sizeof(int));*p42;free(p);printf(%d\n,*p);/* 先猜崩溃0还是 42 */free之后又读了一次。在常见的 Linux glibc 环境下这段程序多半会安静地打印 42一个字都没变。此刻的p已经是悬空指针它指向的内存所有权已归还分配器是野指针家族里最常见的一种。这行printf是货真价实的未定义行为C11 §6.2.4 与附录 J.2对象生命周期结束后使用其指针值行为未定义但它的典型表现不是崩而是演戏。悬空指针use-after-free最阴险的地方恰恰是它不崩。篇 7 结尾那块没人认领的内存现在进去看个究竟。一、三个反常现象值还在、不崩、旧值还会穿越现象一值还在。free没有任何清零步骤那 4 个字节原样躺在内存里。你读到 42 不是应该读到而是还没人动它的赛跑结果下一行再来一次malloc这块地随时可能被新数据覆盖。现象二旧值会穿越。看一段更诡异的char*amalloc(32);memcpy(a,pwdhunter2,12);free(a);char*bmalloc(32);/* glibc 上大概率拿回同一块 */printf(%s\n,b);/* pwdhunter2 */刚free掉的 32 字节下一次同尺寸申请大概率原址奉还连里面的旧数据一起奉还。这不是标准承诺是 glibc 的实现行为2.26 起的小块走 tcache 缓存后进先出机制下一节拆。安全视角看这就是为什么口令和密钥用完必须清零它们不会随free消失只会等下一个malloc来认领。现象三同是悬空换个尺寸就真崩。char*bigmalloc(120);/* 1MB超过 mmap 阈值 */free(big);*big1;/* 这一次多半真崩SIGSEGV */三个现象摆在一起问题就变成了free到底做了什么为什么它既能放任旧值存在又能当场翻脸二、free 到底做了什么三件事三不碰glibc 的malloc并非系统调用它是个库函数向内核批发内存再向应用零售。free也不是把内存还给系统它做的只是三件簿记翻账本malloc返回的指针前面垫着 chunk 头部记录本块大小与邻居占用标志位。free靠它知道这块多大、前一个邻居空闲吗这也是free不用传 size 的原因。挂链子把这块空闲块插进空闲链。glibc 2.26 起小块先走 tcache每线程缓存后进先出分配时 O(1) 直接摘走每个 tcache 桶只存 7 块装不下的去 fastbin 等后备队列排队。看邻居小块走 tcache/fastbin 快速通道不合并落到常规空闲链后相邻块空闲就合并成大块抑制碎片glibc 对最常见的重复free也有检测能拦住的当场 abort拦不住的照样未定义。三不碰不清零内容原样保留、不归还 OS小块进空闲链等复用只有堆顶累计空闲超过阈值才可能收缩归还mmap 大块是例外下一节见、不通知指针p还是原来那个值只是它指向的地已经易主。为什么不清零清零是真金白银的写流量谁需要干净内存谁自己来calloc自带清零。为什么急着复用后进先出意味着刚free的块大概率还在 CPU 缓存里是热的摘链一步到位还省掉在空闲链里翻找合适块的功夫。分配快、命中高tcache 的设计动机就在这不是巧合。图free 只改头部标志、把块挂进 tcache数据区原样不动三、悬空的下场分配渠道决定崩不崩三个现象一把钥匙那块内存当时躺在哪个渠道。小块走brk批发的堆free只改账本挂链子那几页虚拟地址依然映射、物理页依然存在读写它 MMU 一路绿灯。所以不崩值还在。大块glibc 默认 128KB 以上走mmap独立映射free即munmap映射当场解除访问它就是访问一片没有映射的地址MMU 直接拒绝段错误当场崩。同一个悬空指针两种下场。有时崩有时不崩不是玄学是渠道。但不崩只是暂时的。tcache 急着复用下一次malloc就可能把这块地翻新你读到的不再是 42而是别人的 int你写进去的 42 也悄悄污染了别人的数据。崩溃尚有栈可查这种静默的数据错乱没有崩溃现场是 use-after-free 难查的根源。浏览器内核的 CVE 榜单上 use-after-free 长期霸榜安全研究者更怕的正是崩与不崩之间的那种。UB 的边界再钉清楚一步。C 标准直到 C23 才专门写清这条线无效指针值可以无条件复制但复制之外的任何使用首先是解引用都是 UBC23 之前标准对此着墨不明。无论如何演进解引用悬空指针从 C89 到今天始终是板上钉钉的 UB。还要多想一层UB 不只是运行时可能出事。开-O2后编译器有权假定 UB 不发生直接把读了已释放内存的代码折叠掉你在源码里看到的逻辑和可执行文件里跑的可能根本不是同一套。所以值还在连编译器这关都过不了它只是恰好的赛跑结果。图brk 堆上悬空不崩mmap 大块 free 即解除映射访问必崩四、防御让误用崩在第一现场第一招释放后立即置空。free(p);pNULL;之后再误用p是空指针Linux 这类平台上解引用几乎必崩栈帧直接指向肇事行。悬空指针读旧值时那些看起来正常的日子才是最难查的。让 bug 崩得早是工程上划算的交易。空指针为什么几乎必崩内存系列后续专篇拆解。第二招敏感数据先清零再释放。memset有被编译器优化掉的风险死存储消除会认定没人再读这块内存把清零整个删掉。口令、密钥这类数据用explicit_bzeroglibc 2.25语义上保证清零不被删除。第三招编译期和测试期各上一道闸。GCC 13 起提供-Wuse-after-free对free之后直接使用指针的典型模式发警告怀疑有漏网的用 AddressSanitizer-fsanitizeaddress跑一遍测试。它的思路和 glibc 反着来每块内存前后垫红区free的块扔进隔离区延迟复用。生产环境里急着复用让 bug 隐身测试环境里拒绝复用让 bug 现形悬空读写当场报错附完整分配与释放的调用栈。第四招把所有权写进契约。谁分配谁释放接口注释写清realloc可能搬移搬移后旧指针即刻悬空只允许使用返回值。写在最后free做三件事读 chunk 头、挂空闲链、合并邻居三不碰不清零、不归还 OS、不通知指针。悬空崩不崩取决于分配渠道brk堆上页仍映射多半安静mmap 大块 free 即解除映射访问必崩。free后立即p NULL让误用崩在第一现场敏感数据用explicit_bzero别赌memset不被优化。读到旧值是赛跑结果不是承诺下一次malloc随时翻新这块内存静默数据错乱比崩溃更难查。GCC 13 的-Wuse-after-free加测试期的 ASan红区 隔离区把隐身的悬空访问逼到台前。堆上的悬空至少要等一次free栈上的悬空随手就能造出来return一个局部数组的地址函数一返回它就悬空了。返回指针有四条死路三条踩了就炸一条最隐蔽。下一篇《函数返回指针的四条死路》。同系列文章会陆续发布关注后即可收到更新。回复「交流」我很乐意分享我的学习经验和工作经验直接来聊。感谢阅读。转载请注明出处与作者。