← Back to list

Free and its hidden details (tcache)

free()

M Rajagopalaswamy · 2026-01-11 04:01 · 1 claps · 5.9 min read
#malloc #free #tcache #glibc
Open on Medium ↗

Free and its hidden details (tcache)

What happens after free():

In our previous blog, we explored how malloc() works internally and why it behaves the way it does.

Today we go one step further and look at free**()**, and what really happens to memory after it is released.

Consider this small program:

int main(int argc, char *argv[])
{
    char *a = malloc(50);
    strncpy(a, "Is this enough ? Let's dive deep in memory", 50);
    printf("This is the message : %s\n", a);
    free(a);
    printf("After free This is the message : %s\n", a);
}

Before Free() : a has address 0x5555555592a0 we could see that it has the string Is this enough ? Let's dive deep in memory in below dump 👇

(gdb) x/50cb a
0x5555555592a0: 73 'I'  115 's' 32 ' '  116 't' 104 'h' 105 'i' 115 's' 32 ' '
0x5555555592a8: 101 'e' 110 'n' 111 'o' 117 'u' 103 'g' 104 'h' 32 ' '  63 '?'
0x5555555592b0: 32 ' '  76 'L'  101 'e' 116 't' 39 '\'' 115 's' 32 ' '  100 'd'
0x5555555592b8: 105 'i' 118 'v' 101 'e' 32 ' '  100 'd' 101 'e' 101 'e' 112 'p'
0x5555555592c0: 32 ' '  105 'i' 110 'n' 32 ' '  109 'm' 101 'e' 109 'm' 111 'o'
0x5555555592c8: 114 'r' 121 'y' 0 '\000'        0 '\000'        0 '\000'        0 '\000'        0 '\000'        0 '\000'
0x5555555592d0: 0 '\000'        0 '\000'

After Free() : a has address 0x5555555592a0 we could see that it has the string YUUU.(some junks)...Let's dive deep in memory in below dump 👇

(gdb) x/50cb a
0x5555555592a0: 89 'Y'  85 'U'  85 'U'  85 'U'  5 '\005'        0 '\000'        0 '\000'        0 '\000'
0x5555555592a8: 119 'w' -17 '\357'      -78 '\262'      81 'Q'  -123 '\205'     -126 '\202'     -22 '\352'      112 'p'
0x5555555592b0: 32 ' '  76 'L'  101 'e' 116 't' 39 '\'' 115 's' 32 ' '  100 'd'
0x5555555592b8: 105 'i' 118 'v' 101 'e' 32 ' '  100 'd' 101 'e' 101 'e' 112 'p'
0x5555555592c0: 32 ' '  105 'i' 110 'n' 32 ' '  109 'm' 101 'e' 109 'm' 111 'o'
0x5555555592c8: 114 'r' 121 'y' 0 '\000'        0 '\000'        0 '\000'        0 '\000'        0 '\000'        0 '\000'
0x5555555592d0: 0 '\000'        0 '\000'

Notice something strange:

  • The first few bytes are overwritten (YUUU...)
  • But most of the string is still there

So **free() did not erase your data. It only repurposed part of the memory. Why?**

Where freed memory really goes — tcache

Since glibc 2.26, freed chunks go first into tcache (Thread Cache).

What is Tcache? Think of tcacheas a personal memory recycling bin for each thread in your program. It’s designed with one goal speed, we gonna see it’s purpose in next blog.

Here’s what makes tcache special:

  • Per-thread storage: Each thread maintains its own independent cache
  • Recently freed chunks: Stores your most recently deallocated memory
  • Fast allocation: When you call malloc() again, it can instantly reuse freed chunks
  • Lock-free access: No contention with other threads, avoiding expensive synchronization (we gonna see it in next blog)

How Tcache Works in Practice

When you free a chunk (like our variable a), here's what happens:

  1. The freed chunk is added to tcache
  2. Tcache organizes chunks by size (bin index formula!) bin_index = (chunk_size / 16) — 2
  3. On the next malloc() request, tcache is checked first
  4. If a matching size is found, that chunk is returned immediately — no new allocation needed!

This is why tcache is so fast: it’s a thread-local recycling system that keeps freed memory ready for instant reuse.

Seeing Tcache in Action

Let’s examine our earlier example and see what tcache looks like before and after freeing chunk a.

Before free(a): Chunk a contains our user data, and tcache is empty 👇

Allocated chunk:
----------------
pwndbg> p *(struct malloc_chunk*)(a-16)
$4 = {
  mchunk_prev_size = 0,
  mchunk_size = 65,
  fd = 0x2073696874207349,          ---> Is this 
  bk = 0x3f206867756f6e65,          ---> enough ?
  fd_nextsize = 0x6420732774654c20, --->  Let's d
  bk_nextsize = 0x7065656420657669, ---> ive deep
}
pwndbg> 

Address of a:
-------------
pwndbg> p/x a
$1 = 0x5555555592a0

tcache struct:
--------------
pwndbg> p *tcache
$2 = {
  counts = {0 <repeats 64 times>},
  entries = {0x0 <repeats 64 times>}
}

After free : chunk abecomes a tcache entry

Freed chunk a: user data seems corrupted
--------------
pwndbg> p *(struct malloc_chunk*)(a-16)
$18 = {
  mchunk_prev_size = 0,
  mchunk_size = 65,
  fd = 0x555555559,
  bk = 0x22a843ceb220fa8e,                                      
  fd_nextsize = 0x6420732774654c20, --->  Let's d                 
  bk_nextsize = 0x7065656420657669, ---> ive deep                 
}

tcache struct:
--------------                                                        
pwndbg> p *tcache
$19 = {
  counts = {0, 0, 1, 0 <repeats 61 times>},
  entries = {0x0, 0x0, 0x5555555592a0, 0x0 <repeats 61 times>}
}
pwndbg> 

freed chunk a's corrupted data: (typecasted into tcache_entry)
-------------------------------
pwndbg> p *(struct tcache_entry*)a
$21 = {
  next = 0x555555559,
  key = 2497320548407769742
}
pwndbg> 
  • The Counts array in tcahe shows one Freed Chunk counts = {0, 0, 1, 0 <repeats 61 times>} , The value 1 at index 2 tells us there’s one freed chunk in this bin of size 64 bytes. Using formula: chunk_size = (bin_index + 2) × 16 => (2 + 2) × 16 => 64 bytes.
  • The Entries array points to the freed chunk. The address 0x5555555592a0 in entries[2] is the head of the linked list for 64-byte chunks—which is exactly where our freed chunk a lives. entries = {0x0, 0x0, 0x5555555592a0, 0x0 <repeats 61 times>}
  • Inside the freed chunk a, the next pointer doesn't directly show the next chunk's address. It's XOR-mangled for security. To decrypt it: Raw_Next = (Address >> 12) ^ Mangled_Next pwndbg> p/x (0x5555555592a0 >> 12) ^ 0x555555559 $24 = 0x0
  • The result is 0x0 (NULL), which makes perfect sense—this is the only freed chunk in the chain, so there's no "next" chunk to point to.
  • The key field stores the random bits, which generates once for the thread
  1. When a chunk is freed, key is set.
  2. On subsequent free() calls for same pointer, malloc checks: "Does this chunk's key already match tcache_key?” (which already generated and global)
  3. If yes → double-free detected! The program aborts to prevent exploitation.

Resusing Memory : How malloc reclaims freed chunks from the tcache

When multiple chunks of the same size are freed:

  • They form a linked list (LIFO: Last In, First Out)
  • Each chunk’s next pointer points to the previously freed chunk
  • The most recently freed chunk becomes the head of the list

When you call malloc() again with a matching size, tcache returns the head of the list (the last freed chunk), updates the head pointer, and decrements the count—all in constant time O(1).

#include <stdio.h>
#include <stdlib.h>

int main()
{
    char *a = malloc(50);
    char *b = malloc(50);
    char *c = malloc(50);
    free(a);
    free(b);
    free(c);

    char *d = malloc(50);
    printf("It's now in pointer c");
}
  • In above exampel after three free() this is the gdb trace 👇
Address of a,b,c:
------------------
pwndbg> p/x a
$10 = 0x5555555592a0
pwndbg> p/x b
$11 = 0x5555555592e0
pwndbg> p/x c
$12 = 0x555555559320
pwndbg> 

tcache structure data:
----------------------
pwndbg> p *tcache
$1 = {
  counts = {0, 0, 3, 0 <repeats 61 times>},
  entries = {0x0, 0x0, 0x555555559320, 0x0 <repeats 61 times>}
}

head of entry points to last freed chunk c:
-------------------------------------------
pwndbg> p *(struct tcache_entry*)0x555555559320
$3 = {
  next = 0x55500000c7b9,
  key = 1543625295978182647
}

c's next points to  b:
-----------------------
pwndbg> p/x (0x555555559320>>12)^0x55500000c7b9
$7 = 0x5555555592e0
pwndbg> p *(struct tcache_entry*)0x5555555592e0
$8 = {
  next = 0x55500000c7f9,
  key = 1543625295978182647
}

b's next points to a:
---------------------
pwndbg> p/x (0x5555555592e0>>12)^0x55500000c7f9
$9 = 0x5555555592a0
pwndbg> 
pwndbg> p *(struct tcache_entry*)0x5555555592a0
$13 = {
  next = 0x555555559,
  key = 1543625295978182647
}

a's next point's to null:
-------------------------
pwndbg> p/x (0x5555555592a0>>12) ^ 0x555555559
$14 = 0x0
pwndbg> 
  • After freeing chunks a, b, and c, the tcache structure reveals, the value 3 at index 2 confirms we have three freed chunks of size 64 bytes waiting in tcache. counts = {0, 0, 3, 0 <repeats 61 times>},
  • Entries array points to the most recent freed chunk, which is c’s address. entries = {0x0, 0x0, 0x555555559320, 0x0 <repeats 61 times>}
  • c has mangled next — 0x55500000c7b9 which is decrypted using p/x (0x555555559320>>12) ^ 0x55500000c7b9 = 0x5555555592e0 which is b.
  • And bpoints to 0x55500000c7f9which is 0x5555555592a0i.e. address of a
  • And a points to NULL.

After next malloc char *d = malloc(50); last chunk gets returned to d and removed from tcache free list, and the entries now points to b 0x5555555592e0 👇

What Happens on the Next malloc?

When we call char *d = malloc(50);, here's what tcache does:

  1. Returns chunk c (the head of the list) to d
  2. Updates the head to point to chunk b
  3. Decrements the count from 3 to 2
pwndbg> p/x b
$11 = 0x5555555592e0
pwndbg> p *tcache
$16 = {
  counts = {0, 0, 2, 0 <repeats 61 times>},
  entries = {0x0, 0x0, 0x5555555592e0, 0x0 <repeats 61 times>}
}
pwndbg> p/x d
$17 = 0x555555559320

Result: d now points to the same memory that c used to occupy—instant recycling with zero overhead!

upcoming : why tcache ? how free handled before tcache ?


메타데이터
post_id
4a49dd3b2f08
slug
free-and-its-hidden-details-tcache-4a49dd3b2f08
url
https://medium.com/@mrajagopalaswamy/free-and-its-hidden-details-tcache-4a49dd3b2f08
canonical_url
https://medium.com/@mrajagopalaswamy/free-and-its-hidden-details-tcache-4a49dd3b2f08
author_url
https://medium.com/@mrajagopalaswamy
status
ok
fetched_at
2026-06-24 23:31:39