free 完再 malloc 同样大小,为什么常拿回刚还回去的那块?

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   <== 同一块!

pq 是同一个地址 0x4062b0。多跑几次,地址变了,但"两次相等"雷打不动。这不是巧合,是 glibc 的 malloc 板上钉钉的行为。本文就回答两个问题:

  1. 为什么偏偏拿回"刚还回去的那块",而不是别的空闲块?
  2. 既然 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 命中时)说穿了就三步:

  1. 把当前桶链表头的地址,写进 *p(p 的数据区)------于是 p 的 next 指向了原来的头;
  2. 把桶的头指针更新成 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),到底是问谁要的内存》。

相关推荐
触底反弹2 天前
拷个 .exe 到新电脑就跑不起来?你缺的不是文件,是对链接的理解
c++·windows·操作系统
杊页2 天前
第一板块:Android 系统基石与运行原理 | 第二篇:Android 编译、打包与安装机制
android·操作系统
壮Sir不壮2 天前
GO语言——GMP调度模型
linux·开发语言·golang·go·操作系统·线程·协程
Surest2 天前
OpenHarmony 技术拆解(二):从 capability 看懂分布式软总线与任务迁移
操作系统
OpenAnolis小助手3 天前
如何利用 AI Agent 实现热补丁的自动化生成
人工智能·安全·ai·操作系统·agent·龙蜥
小宇子2B4 天前
缺页中断不是“出错”,是内核最忙的一条正常路径
操作系统
小宇子2B4 天前
内存不够时,内核怎么把"冷"页踢出去——swap 与页面回收
操作系统
磊 子4 天前
二.内核讲解
开发语言·操作系统·系统
下午写HelloWorld5 天前
Linux系统及Ubuntu常用指令
linux·ubuntu·操作系统