free 完再 malloc 同样大小,为什么常拿回刚还回去的那块?
🎯 交互式可视化 :点击这里体验 free / malloc 的 LIFO 链表动画 你可以一步步 free 几块、再 malloc 同样大小,实时看空闲块怎么被头插进链表、又怎么被后进先出地摘回来,以及
next指针是怎么写进你那块"尸体"里的。
上一篇《一次 malloc(16),到底是问谁要的内存》我们跟着三行 C 把堆挖到了底:malloc 先翻 libc 自己的池子、池子空了才向内核批发、内核给的还是张"空头支票",直到第一次写才配上物理页。结尾留了个钩子------free 之后那块地并不会消失,"地还在,只是主人随时会换",并说这就是 use-after-free 危险的物理根源。这一篇就来兑现:主人到底怎么换、为什么换得那么有规律,顺带把 use-after-free 为什么是个能被武器化的重磅漏洞讲透。
入口是一个谁都能复现、看着像巧合的现象:
c
#include <stdio.h>
#include <stdlib.h>
int main(void){
int *p = malloc(16);
printf("第一次拿到: %p\n", (void*)p);
p[0] = 42;
free(p);
int *q = malloc(16); // 同样要 16 字节
printf("free 后再拿到: %p\n", (void*)q); // 大概率和上面一模一样
return 0;
}
本文用的环境是 x86-64 + glibc 2.36(Debian 12 里的 gcc:13 容器,和前两篇同一套 System V ABI),文中所有输出都是真跑出来的。先把它编译运行:
bash
gcc -O0 -o exp exp.c && ./exp
真实输出(地址受 ASLR 影响每次不同,但两次相等这个关系是稳定的):
c
第一次拿到: 0x4062b0
free 后再拿到: 0x4062b0 <== 同一块!
p 和 q 是同一个地址 0x4062b0。多跑几次,地址变了,但"两次相等"雷打不动。这不是巧合,是 glibc 的 malloc 板上钉钉的行为。本文就回答两个问题:
- 为什么偏偏拿回"刚还回去的那块",而不是别的空闲块?
- 既然 free 之后那块地能这么规律地被重新发出去,use-after-free 的危险究竟落在哪?
说明同前两篇:汇编和程序输出都是真编真跑的,但桶的数量、地址加扰这类内部细节受 glibc 版本影响,结论看关系、不看具体数字。
一、先把现象钉死:free / malloc 是后进先出
一次只看一块,看不出门道。把规模放大------连续 free 三块、再连续 malloc 三块,看拿回来的顺序:
c
int *a = malloc(16), *b = malloc(16), *c = malloc(16);
printf("分配 a=%p b=%p c=%p\n", a, b, c);
free(a); free(b); free(c); // 还回去的顺序:a → b → c
int *x = malloc(16);
int *y = malloc(16);
int *z = malloc(16);
printf("再拿 x=%p y=%p z=%p\n", x, y, z);
真实输出:
ini
分配 a=0x4062b0 b=0x4062d0 c=0x4062f0
free 顺序: a -> b -> c
再拿 x=0x4062f0 y=0x4062d0 z=0x4062b0
把它对齐着看就清楚了:
css
还回去: a(b0) → b(d0) → c(f0) 先还 a,最后还 c
再拿回: x=c(f0) y=b(d0) z=a(b0) 先拿 c,最后拿 a
最后还回去的 c,第一个被拿回来;最先还的 a,最后才轮到。 这不是"随便给一块空闲的",而是严格的后进先出(LIFO)------像一摞盘子,最后放上去的最先被取走。
能后进先出,背后多半藏着一个栈。下面几节就把这个栈揪出来:它长在哪、用什么连起来、为什么 free 是入栈、malloc 是出栈。
二、空闲块仓库不是一个大筐,是按大小分好的一排链表
上一篇第三节提过一句:"glibc 内部把空闲块按大小分成 fastbin、smallbin 等好几类链表来加速查找。"当时按下不表,现在正式展开。
libc 收回的空闲块,不是 全扔进一个大筐里乱放。那样每次 malloc(16) 都得把筐翻一遍找够大的块,太慢。它的做法是按大小分桶(size class):相近大小的块归一个桶,每个桶挂一条链表,只串同一档大小的空闲块。
先搞清楚"大小"指的是哪个大小
这里有个最容易讲糊的地方:分桶看的不是你 malloc 时要的字节数 ,而是 libc 给这块配的实际 chunk 大小。两者差在哪?
- 每块除了给你用的数据区,还得在前面附 8 字节的头部(记着这块多大、前一块是否空闲等元信息);
- 凑上头部之后,再向上对齐到 16 的倍数(x86-64 下分配粒度是 16);
- 并且有个下限:最小的 chunk 也得 32 字节。
把这条规则直接跑出来看------读每块 chunk 头里记的 size,列出"请求大小 → 实际 chunk 大小":
ini
请求大小 -> 实际 chunk 大小:
malloc( 0) chunk = 32
malloc( 8) chunk = 32
malloc( 16) chunk = 32
malloc( 24) chunk = 32
malloc( 25) chunk = 48
malloc( 40) chunk = 48
malloc( 41) chunk = 64
malloc(1032) chunk = 1040
malloc(1033) chunk = 1056
看出门道了:malloc(0)、malloc(8)、malloc(16)、malloc(24) 拿到的是同一档 chunk 32------它们四个其实落进同一个桶 。直到 malloc(25)(25+8=33,对齐到 48)才跳到下一桶。所以严格说,本文开头那句"同样要 16 字节"应该理解成"落进同一个 chunk 桶"------下面这个实验把这点钉死:
c
int *a = malloc(16);
printf("malloc(16) = %p\n", a);
free(a);
int *b = malloc(24); // 不同请求,但 chunk 同为 32
printf("free 后 malloc(24) = %p (同一块? %s)\n", b, a==b?"是":"否");
int *c = malloc(25); // chunk 跳到 48,换桶
printf("再 malloc(25) = %p (同一块? %s)\n", c, a==c?"是":"否");
真实输出:
scss
malloc(16) = 0x4062b0
free 后 malloc(24) = 0x4062b0 (同一块? 是)
再 malloc(25) = 0x4062d0 (同一块? 否)
free(16) 之后 malloc(24) 真的拿回了同一块 0x4062b0------因为它俩 chunk 都是 32,共用一个桶;而 malloc(25) chunk 变 48、换了桶,自然另给一块。分桶分的是 chunk,不是你要的字节数。
桶是怎么排的:等差 16,一桶一档
明白了"大小"指 chunk,桶的排布就清楚了:从最小的 chunk 32 起,每隔 16 字节一个桶 ------chunk 32 一桶、48 一桶、64 一桶......一直排上去。malloc 拿到请求后,先按上面的规则算出 chunk 大小,再用一个除法 (chunk - 32) / 16 直接算出该去第几个桶,不用遍历、不用比较,这才是 O(1) 的来历。
专门加速小块的有两层缓存,也是本文的主角,都按这套 chunk 分桶:
- tcache :每个线程私有的一层缓存(glibc 2.26 引入)。共 64 个桶 ,覆盖 chunk 32 到约 1040 字节(即请求 ≲1032 字节的小块都归它管,上面表里
malloc(1032)chunk=1040 正好是末档)。每桶一条单向链表 ,默认每桶最多囤 7 块。malloc小块时第一个就翻它------因为是线程私有,全程不用加锁,最快。 - fastbin :tcache 那一桶满了(攒够 7 块)之后,多出来的同档空闲块落到这里。同样按 chunk 分桶的单向链表,但有两点和 tcache 不同:
- 不是线程私有的,所以要上锁。 tcache 每个线程一份、互不相干,读写不用加锁;fastbin 则可能被多个线程共用,每次操作得先抢一把锁。这就是它比 tcache 慢、排在 tcache 后面的原因。("谁和谁共用、那把锁到底锁的是什么",牵出的是 glibc 的 arena(分配区) 机制------本篇用不到,留到下一篇专门讲。)
- 只盖最小的那几档。 默认
global_max_fast = 128字节,也就是只接收 chunk ≤ 128 的块------chunk 32 / 48 / 64 / 80 / 96 / 112 / 128 共 7 档,对应请求约 1~120 字节。实测请求 120(chunk 128)溢出后进 fastbin,请求 121(chunk 144)就不进了,转去 unsorted/smallbin。比起 tcache 那 64 个桶覆盖到 chunk 1040,fastbin 的覆盖面窄得多。
于是 malloc(16) 根本不用满世界找:算出它要 chunk 32 → 定位到 0 号桶 → 拿链表头第一个,O(1) 命中。
scss
按 chunk 大小分桶的空闲链表(tcache,示意):
桶 0 [chunk 32] ──→ [空闲块] ──→ [空闲块] ──→ NULL ← malloc(0~24) 都来这
桶 1 [chunk 48] ──→ [空闲块] ──→ NULL ← malloc(25~40)
桶 2 [chunk 64] ──→ NULL ← malloc(41~56)
...
桶 63 [chunk 1040]──→ ... ← tcache 末档
每桶最多 7 块,下标 = (chunk - 32) / 16,直接算出来
桶找到了,可"为什么总是拿链表头第一个、而且偏偏是刚还回去那块"?关键在这条链表是怎么串起来的------串它的指针,藏在一个会让你愣一下的地方。
三、关键机关:free 把"下一块的地址"写进了你那块尸体里
这是全文最该停下来想三秒的一节,也是后面讲透 use-after-free 的支点。
单向链表得有个 next 指针指向下一个节点。问题来了:tcache 这条链表的 next,存在哪?
按常理,链表节点该另外开一小块内存存指针。但 libc 没这么干------它把 next 直接写进那块被 free 掉的 chunk 的用户数据区,也就是你当初写 42 的那几个字节里。
逻辑其实特别自然:这块都已经 free 了、数据区反正没人用了,libc 正好废物利用,拿这块"尸体"自己来当链表节点,一分钱额外内存都不花。
光说不够,做实它。下面这段:free 之前往数据区写一个扎眼的值,free 之后再读同一个地址,看它变成了什么:
c
long *p = malloc(32);
long *q = malloc(32);
p[0] = 0x4242424242424242L; // 往 p 的数据区写个扎眼的值
printf("free 前 p[0] = 0x%016lx\n", p[0]);
free(q); // q 先入桶,成为链表头
free(p); // p 再入桶,成为新头,next 指向 q
printf("free 后 p[0] = 0x%016lx\n", p[0]);
printf(" q 的地址 = %p\n", q);
真实输出:
css
free 前 p[0] = 0x4242424242424242
free 后 p[0] = 0x0000000000406746 (不再是 0x42..,变成了一个指针)
q 的地址 = 0x406340
看见了吗------free(p) 之后,p[0] 里那个 0x4242... 没了 ,变成了 0x406746,一个指针模样的值,指向的正是前一个入桶的 q 附近(0x406340)。
你可能会问:为什么不是干净地等于
q的地址0x406340?因为新版 glibc(2.32+)给next做了一层叫 safe-linking 的加扰------把指针和它自己的存放地址异或一下再存,专门用来防后面要讲的攻击。所以存进去的是加扰后的值,解扰后才指向 q。这里只要记住一件事:那 8 个字节,已经从"你的数据"变成了"libc 的链表指针"。
free(p)(tcache 命中时)说穿了就三步:
- 把当前桶链表头的地址,写进
*p(p 的数据区)------于是 p 的next指向了原来的头; - 把桶的头指针更新成 p------p 成了新的链表头; 3.(glibc 2.29+)再往这块写一个
key字段,专门用来探测同一块被 free 两次。
less
free(q) 之后: 桶[32B] ──→ [q | next=NULL]
free(p) 之后: 桶[32B] ──→ [p | next ─┐ ]
└─→ [q | next=NULL]
▲ p 是新头,next 指向 q
关键:那个 [next] 占的,就是你当初写数据的头 8 字节
记住这句,第五节要用它引爆 use-after-free:free 之后,你那块数据区的头 8 字节,已经不是你的 42 了,是 libc 的链表指针。
四、LIFO 的真相:free 是头插,malloc 是头摘
有了第三节的机关,第一节那个"后进先出"现在能一句话讲清。
回看 free(p) 的三步------新还回来的块,被插到了链表的最前面 (成为新的头)。这就是头插(push)。
那 malloc 呢?它去桶里拿块,拿的也是链表头第一个------头摘(pop)。
头插 + 头摘,这就是一个标准的栈。 后进先出不是 malloc 故意设计的"策略",而是这个数据结构天然的性质:
less
free(a): 头 → a
free(b): 头 → b → a 每次新块都插到最前
free(c): 头 → c → b → a
malloc: 头 → c → b → a,摘走 c (拿到最后还的)
malloc: 头 → b → a,摘走 b
malloc: 头 → a,摘走 a (最先还的最后拿)
所以"为什么 free 完再 malloc 同样大小,常拿回刚还回去那块"------答案就这么简单:刚 free 的块正躺在链表头,下一次同 size 的 malloc 头一个就把它摘走了。 第一节里 c→b→a 的逆序,正是一个栈被依次弹空的样子。
顺带说一句为什么这么设计划算:刚 free 的块,大概率还在 CPU 的高速缓存里(你刚访问过它)。优先把它发出去复用,下一次读写命中 cache 的概率最高。LIFO 不只是实现简单,还顺手省了 cache miss------又快又省,可谓一箭双雕。
那"每桶 7 块"满了之后呢?再多 free 的同 size 块会溢出到 fastbin。下面这个实验一次看全:连续 free 9 块同样大小,再连续 malloc 9 次:
rust
free 顺序: 0 -> 1 -> 2 -> ... -> 8 (9 块依次还回去)
重新 malloc 的顺序:
第0次 = 0x405360 ┐
第1次 = 0x405340 │
第2次 = 0x405320 │ 先掏空 tcache:
第3次 = 0x405300 │ 吐出 6,5,4,3,2,1,0(前 7 块,LIFO)
第4次 = 0x4052e0 │
第5次 = 0x4052c0 │
第6次 = 0x4052a0 ┘
第7次 = 0x4053a0 ┐ tcache 空了,转 fastbin:
第8次 = 0x405380 ┘ 吐出 8,7(溢出的那 2 块,也是 LIFO)
前 7 块(0~6)进了 tcache,第 8、9 块(7,8)tcache 满了、溢出到 fastbin。重新 malloc 时先把 tcache 掏空(吐 6 到 0),再去 fastbin 拿(吐 8、7)。两层缓存各自后进先出,无缝拼接。 LIFO 这个性质,在 glibc 的小块缓存里是贯穿的。
五、回头看 use-after-free:危险到底落在哪
上一篇结尾只点了一句"地还在、主人随时会换"。现在前四节的机制铺齐了,可以把 use-after-free(用已释放的内存,简称 UAF)为什么是个能被武器化的重磅漏洞,分三层讲透。
5.1 第一层:free 后去读,读到的根本不是你的数据
第三节已经埋好雷了:free(p) 之后,*p 的头 8 字节被 libc 改写成了链表 next 指针。
所以这种代码:
c
free(p);
printf("%d\n", p[0]); // 看着"还能读",读到的却是 libc 写的指针碎片
看着"free 完还能读到值",但读到的早不是当初的 42,而是 safe-linking 加扰过的指针低 4 字节------一个脏值,且脏得没规律。悬空指针读出来的东西不可信,bug 就从这里开始。
5.2 第二层:那块马上被发给新主人,两个指针指向同一块
更要命的是第四节的 LIFO:刚 free 的块躺在链表头,下一次同 size 的 malloc 第一个就把它摘走、交给新主人。
c
free(p); // p 进桶,躺在链表头
char *new = malloc(16); // 头摘,new == p,拿到的就是 p 那块!
// 此刻 p 和 new 别名同一块内存
strcpy(new, "hello"); // 新主人写数据
printf("%d\n", p[0]); // 你以为读自己的,其实读到的是 "hell"
*p = 0; // 你以为清自己的,其实砸了新主人的数据
你手里的旧指针 p 和新主人的 new 指向同一块内存 :你写 p,改的是别人的数据;别人写,毁的是你以为还在的东西。UAF 就此从"读到脏数据"升级成"写穿别人的数据"。两个互不知情的代码路径共用一块内存,行为彻底失控。
5.3 第三层:武器化------next 就在数据区,攻击者能改它
最致命的一层,根子还在第三节那个机关:链表的 next 就躺在用户数据区。
设想程序有个 UAF 写漏洞------free 之后还能往 p 写。那么攻击者写进去的,正好覆盖在 next 指针上 。他把 next 改成一个自己指定的地址,链表头的"下一块"就指向了那里。于是:
c
正常: 桶 ──→ [刚 free 的块] ──next──→ [另一个空闲块]
被改: 桶 ──→ [刚 free 的块] ──next──→ [攻击者指定的地址]
▲ UAF 写覆盖了 next
接下来两次 malloc:
第一次 malloc → 摘走"刚 free 的块"
第二次 malloc → 顺着被改的 next,把【攻击者指定的地址】当成空闲块返回!
第二次 malloc 会把攻击者控制的地址 当成一块正常内存交给程序去读写------等于让程序在攻击者指定的地方(比如某个函数指针、某个返回地址)任意读写。这就是经典的 tcache poisoning(tcache 投毒)。
它的近亲是 double-free (同一块 free 两次):会把同一块插进链表两次,制造出环或重叠,同样能诱导 malloc 发出可被操纵的块。正因如此,glibc 2.29 才加了第三节那个 key 字段做检测,2.32 又加了 safe-linking 给 next 加扰------都是在给这个"数据区当链表节点"的设计打补丁。
这里只讲清原理和危害,不展开可直接利用的攻击步骤。要点是理解漏洞的物理根源,不是教人写 exploit。
一句话点题:UAF 之所以能从一个"读到旧值"的小毛病,升级成可以劫持程序控制流的重磅漏洞,物理根源正是这条 LIFO 链表------它把死内存自身当节点复用,复用得又快又确定。 快和确定本是性能优点,落到攻击者手里,就成了可预测、可操纵的把柄。
六、把链子连起来,再说怎么防
三节机制,一个现象,串成一条线:
css
free(p) 头插:把 p 插到 tcache 桶链表最前,
把原链表头地址写进 p 的数据区当 next
(你那块的 42 就此被覆盖)
│
malloc(同size) 头摘:第一个就摘回 p ------ 于是拿到同一个地址
│
你手里的旧 p 成了悬空指针:
├─ 读:读到的是脏的 next,不是 42
├─ 写:砸的是新主人的数据(别名同一块)
└─ 被攻击者借 next 做 tcache poisoning / double-free
回到开头那个问题"为什么 free 完再 malloc 拿回同一块"------因为那个按大小分桶的空闲仓库,每个桶都是一个把死内存自身当链表节点的栈,后进先出。 刚还回去的躺在栈顶,下一个就轮到它。
怎么防,对应三层各有一招:
- 代码层(最该养成的习惯) :
free(p)之后立刻p = NULL。悬空指针清零后,再误用就直接崩在 NULL 上(一眼能查),而不是悄悄读写别人的数据、埋下隐患。 - 运行时排查 :开发期挂上 ASan(AddressSanitizer),它能在 UAF 读写的当场就报错、指出哪行;glibc 自带的 tcache
key检测也能逮住明显的 double-free。 - 分配器层 :一些加固型分配器会故意延迟或隔离复用,让"刚 free 立刻拿回同一块"不再成立,从根上削弱这种可预测性。
理解了这条 LIFO 链子,再看那些"为什么 free 完拿回同一块""为什么 free 后还能读到旧值""为什么 UAF 这么危险"的问题,你看到的就不再是孤立的怪现象,而是底下同一个数据结构在起作用------它正面是性能,反面是漏洞的土壤。
关于 glibc 版本 :本文按 glibc 2.26+ 描述。几个机制有明确的版本门槛,环境不同则现象会变:
- tcache 是 glibc 2.26(2017)才引入的。在更早的版本里没有这一层线程私有缓存,小块 free 后直接进 fastbin / unsorted bin ------ "free 完再 malloc 拿回同一块"的 LIFO 现象依然成立(fastbin 本身也是后进先出),但链表头插/头摘发生在 fastbin 那一层,没有"每桶 7 块"这个上限。
key双重释放检测是 glibc 2.29(2019)加的 ,safe-linking(next指针加扰)是 glibc 2.32(2020)加的 。在这两个版本之前,第三节实验里读到的next就是未加扰的真实地址 (能直接等于前一块的地址,不需要解扰),且 double-free 不会被key当场拦下 ------ 也正因为没有这些防护,老版本上的 tcache poisoning 利用起来更直接。
这是"一条代码的冒险之旅"系列的第三篇。上一篇讲
malloc到底问谁要内存、free又把内存还给了谁:《一次 malloc(16),到底是问谁要的内存》。