Step 211. Heap Fundamentals: Allocator Behavior and the Use-After-Free Concept — Reusing Returned Land
Level 3 — Real-World CTF & Advanced Attack Skills | Difficulty ★★★★☆ | Estimated time: 5 hours
Prerequisites: Step 61 (malloc and free — borrow/return/dangling-pointer basics), Steps 209–210 (Pwn fundamentals, GOT). Basic gdb usage.
⚠️ 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, gdb). The measured environment is Ubuntu 24.04, gcc 13.3.0, glibc 2.39.
- Caution: the heap manager’s detailed behavior differs across glibc versions. Today’s numbers (chunk sizes, reuse order) were measured on glibc 2.39 and may differ on other versions — the concepts are the same.
In Step 61 we learned malloc/free as "the rules of borrowing and returning," and we even built a dangling pointer on purpose. Today we go one layer down — where returned memory is actually kept, in what form, and why the next malloc hands you that same spot again. This reuse mechanism is the stage of the use-after-free (UAF) attack, a fixture of real CVE lists.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Draw the structure of a heap chunk (header + user data) and compute its size
- Measure chunk size from the address gap between two mallocs
- Observe that an address gets reused on free → malloc, and the order of that reuse (LIFO)
- Reproduce a UAF situation in code and confirm the moment "an old pointer reads new data"
- Explain the existence of tcache/bins and their glibc-version dependence
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 commands/code | malloc/free, pointer[-1] (peeking at the chunk header), gcc -Wall, gdb address observation |
| Concepts needed | Chunk header, alignment (16 bytes), tcache/bins (reuse queues), LIFO reuse, dangling pointer, UAF |
| Today’s deliverables | A chunk-structure diagram + a reuse observation log + a UAF reproduction program |
2-1. The Chunk — What malloc Actually Gives You
Borrow 32 bytes with malloc(0x20), and the allocator prepares land larger than 32 bytes. That’s because a header is attached in front of the user data:
Actual chunk: [ header 8–16 bytes: size and status ][ user data: the part we receive ]
↑ the pointer we receive points "after" the header
The header records this chunk’s size and flags, and free reads it to know "how many bytes of land were returned." Today we peek at this header directly with pointer[-1].
2-2. The Reuse Queues — tcache and bins
Returned chunks go into the heap manager’s queues. glibc has several kinds of queues, but only two matter today:
- tcache (thread cache): a small per-thread cache. Up to 7 entries per size class, LIFO (last in, first out — the most recently returned leaves first). It’s the first queue checked, so all of today’s reuse experiments happen in the tcache.
- bins (fastbin, unsorted bin, etc.): the main warehouse, used when the tcache fills up. The details can wait until Step 212 and beyond.
One core fact — a returned chunk is a candidate for the next malloc. This reuse is the source of performance, and the doorway of UAF attacks.
2-3. Use-After-Free — The Old Owner Reads New Data
As seen in Step 61, free returns the land but doesn’t empty the pointer variable. Add reuse and the scenario is complete:
- The program allocates object A and keeps it in pointer p
- A is freed — but p is still alive (dangling)
- The attacker allocates the same size → by the reuse rule, it lands in A’s spot
- The attacker writes their data into that spot
- The program reads through p → it uses the attacker’s data, believing it’s A
If the object held a function pointer or a permission flag, it’s hijacked as-is. Today we reproduce these five steps precisely in code.
3. Follow Along
3-1. Measuring a Chunk — The Header and the Gap
Input (chunk.c)
#include <stdio.h>
#include <stdlib.h>
int main(void) {
char *a = malloc(0x20); /* request 32 bytes */
char *b = malloc(0x20);
printf("a = %pn", (void *)a);
printf("b = %pn", (void *)b);
printf("b - a = %#lxn", (unsigned long)(b - a));
printf("8 bytes before a (chunk header): %#lxn", ((unsigned long *)a)[-1]);
printf("8 bytes before b (chunk header): %#lxn", ((unsigned long *)b)[-1]);
free(a);
free(b);
return 0;
}
Compile and run
mkdir -p ~/lab209_213 && cd ~/lab209_213
gcc -Wall -o chunk chunk.c
./chunk
a = 0x5aebe35972a0
b = 0x5aebe35972d0
b - a = 0x30
8 bytes before a (chunk header): 0x31
8 bytes before b (chunk header): 0x31
(Measured 2026-09-09. Addresses differ on every run.)
How to read the output: three numbers are today’s first discovery.
b - a = 0x30(48 bytes): we borrowed 32 bytes, yet the chunk gap is 48. The header and alignment took their share.- Header value
0x31: the chunk size0x30with one flag bit set in the lowest bit (a marker that the previous chunk is in use, PREV_INUSE). Thanks to 16-byte alignment, the bottom 4 bits are always 0, so flags get tucked into those bits. - In other words, a 32-byte request actually occupies one 0x30-byte chunk.
Prediction: what happens to the gap and the header if you change
malloc(0x20)tomalloc(0x28)? (Hint: 16-byte alignment.) Change it and check yourself.
3-2. Return and Reuse — The Queue’s Last-In-First-Out
Input (reuse.c)
#include <stdio.h>
#include <stdlib.h>
int main(void) {
char *a = malloc(0x20);
char *b = malloc(0x20);
printf("a = %p, b = %pn", (void *)a, (void *)b);
free(a);
free(b);
printf("free order: a first, b secondn");
char *c = malloc(0x20);
char *d = malloc(0x20);
printf("c = %p (reusing b's spot?)n", (void *)c);
printf("d = %p (reusing a's spot?)n", (void *)d);
free(c); free(d);
return 0;
}
Compile and run
gcc -Wall -o reuse reuse.c
./reuse
a = 0x610edddd22a0, b = 0x610edddd22d0
free order: a first, b second
c = 0x610edddd22d0 (reusing b's spot?)
d = 0x610edddd22a0 (reusing a's spot?)
(Measured 2026-09-09.)
How to read the output: the chunks came out in the exact opposite of the return order (a → b) — c got b’s spot, d got a’s spot. The most recently returned leaves first — LIFO, a queue that stacks like a stack. This is tcache behavior, and the fact that "borrowing the same size likely gives you the spot you just returned" is the key to UAF reproduction.
3-3. Reproducing UAF — The Old Pointer Reads New Data
Input (uaf.c)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
char name[16];
int is_admin;
int pad;
} Account;
int main(void) {
Account *acc = malloc(sizeof(Account));
strcpy(acc->name, "guest");
acc->is_admin = 0;
printf("[1] allocated: %p, name=%s, is_admin=%dn",
(void *)acc, acc->name, acc->is_admin);
free(acc); /* acc is now a dangling pointer that still remembers the address */
/* new allocation of the same size → the just-returned chunk is reused */
char *evil = malloc(sizeof(Account));
memset(evil, 0, sizeof(Account));
strcpy(evil, "hacker");
*(int *)(evil + 16) = 1; /* write 1 into the is_admin slot */
printf("[2] new allocation evil: %p (same address!)n", (void *)evil);
/* read through the old pointer — what do we see? */
printf("[3] reading via dangling pointer acc: name=%s, is_admin=%dn",
acc->name, acc->is_admin);
if (acc->is_admin) {
printf("[!] the old object became an admin — the backbone of a UAF attackn");
}
free(evil);
return 0;
}
Compile and run
gcc -Wall -o uaf uaf.c
./uaf
uaf.c:30:12: warning: pointer 'acc' used after 'free' [-Wuse-after-free]
(same warning on lines 28 and 29 — the compiler pinpoints it exactly)
[1] allocated: 0x61812ce802a0, name=guest, is_admin=0
[2] new allocation evil: 0x61812ce802a0 (same address!)
[3] reading via dangling pointer acc: name=hacker, is_admin=1
[!] the old object became an admin — the backbone of a UAF attack
(Measured 2026-09-09.)
How to read the output: the 2-3 scenario, five steps exactly.
accpoints to the guest object → 2. free → 3. a same-sizeevillands at exactly the same address (0x…2a0) — the 3-2 reuse rule as promised → 4. evil writes "hacker" and is_admin=1 into that spot → 5. the program reads through the old pointeraccand sees hacker, admin.
In Step 61, reading after free gave "garbage"; today we filled that spot with values we chose. If reading garbage is a bug, controlling the contents is an attack. And the compiler’s -Wuse-after-free warning — gcc knew exactly what this code was doing. When this warning appears in real code, an attack surface is open.
3-4. Watching Reuse in gdb — An Observation Habit
If you want one more confirmation that reuse is a "rule," look with gdb:
gdb -batch -ex "break main" -ex run -ex "info proc mappings" ./reuse 2>&1 | grep heap
Early in the run there’s no [heap] line; it appears after malloc — the heap region itself is created by malloc. Serious debugger-based heap inspection (listing chunks, viewing bins) is the territory of extension tools like gef, which this environment doesn’t have — we’ll meet the concept again in Step 212. Today we saw that the "print and compare addresses" method alone is enough to prove reuse.
4. Missions & Exercises
Mission — UAF Experiments with Different Sizes
Modify uaf.c to run two experiments and record the results in your notes:
- Different size: if evil is allocated larger (e.g.,
malloc(0x100)) instead ofmalloc(sizeof(Account)), what address comes out? Is acc’s spot reused? - Different order: if you read acc right after free with no malloc in between, what value do you see? (Hint: traces of the queue borrowing the chunk for its ledger — compare with Step 61’s "garbage value")
- From both results, summarize "the condition for reuse" in one sentence.
Exercises
Problem 1. The chunk gap for malloc(0x20) was 0x30, not 0x20. Where is the 0x10 difference spent? What is the final 1 in header value 0x31?
Problem 2. In 3-2, c got b’s spot and d got a’s spot. Name the tcache queue’s behavior in one word from this order, and why does it favor UAF attack planning?
Problem 3. Why does "allocating the same size" matter in a UAF attack? If evil had landed at a different address in 3-3, what would have happened to the attack?
Problem 4. The compiler can emit -Wuse-after-free, yet UAF vulnerabilities keep appearing in real software. Why? (Hint: can the warning only fire for cases visible inside one function, like today’s example?)
5. Model Answers & Completion Criteria
Mission Model Answer
- With a different size: evil receives a new address, not acc’s spot. The tcache is divided into per-size classes, so a 0x100 request never looks at the 0x20 chunk queue. What acc reads is unchanged traces (internal values the queue wrote).
- Reading immediately without reallocating: the name slot shows traces of an unfamiliar large integer — the queue uses the front of a returned chunk as its ledger ("next free chunk address," the next pointer). That ledger is the attack target of Step 212 (tcache poisoning).
- Summary example: "Reuse happens when a just-returned chunk of the same size sits in the queue; a different size gets different land."
How to verify: ① did you compare the two experiments’ addresses in the output, ② does your one-sentence summary contain the condition "same size."
Exercise Answers
Problem 1 answer. The 0x10 (16 bytes) goes to the 8-byte header and 16-byte alignment padding. The header records the chunk size, and the always-zero bottom bits (guaranteed by 16-byte alignment) hold flags — the 1 in 0x31 is PREV_INUSE, a marker that "the previous chunk is in use."
Problem 2 answer. LIFO (last in, first out). It favors attack planning because of predictability — the next malloc gives "the spot you just freed," so the attacker knows exactly where the reuse will land and can plant data there.
Problem 3 answer. Because the queues are divided by size. Only a same-size request allocates from the class holding the just-returned chunk. If evil had landed elsewhere, acc would merely read the traces left in the returned spot, and the attacker’s data would go nowhere — attack failed.
Problem 4 answer. The compiler warning works only for simple cases where the free and the use are visible close together in one function. Real-world UAF happens when an object flows into callbacks, lists, and other modules, gets freed, and is used much later through a different path — static analysis can’t follow. So warnings are a supplement; design (clear ownership, NULL after free) and runtime checkers are needed alongside.
Completion Criteria Checklist
- [ ] I can draw the chunk structure (header + user data)
- [ ] I can explain the actual chunk size of
malloc(0x20)(0x30) and the meaning of the header value (0x31) - [ ] I confirmed address reuse on free → malloc and the LIFO order by experiment
- [ ] I reproduced the 5 UAF steps (allocate, free, reallocate, overwrite, read via old pointer)
- [ ] I can explain why
-Wuse-after-freefires and its limits - [ ] I know the tcache is split into per-size classes, and that behavior depends on the glibc version
- [ ] Mission: I summarized the reuse condition through the size/order experiments
6. Common Pitfalls & Fixes
Wall 1. Addresses change every run, so I can’t compare
Symptom: a and b’s addresses change on every execution.
Cause: ASLR and PIE — the heap’s start address is randomized each run.
Fix: don’t memorize absolute addresses; watch only differences and matches. Today’s discovery isn’t "0x…2a0" but the relationships "the gap is 0x30" and "c equals b." Relationships hold regardless of ASLR.
Wall 2. pointer[-1] shows a weird huge number
Symptom: you read the header and got something other than 0x31.
Cause: a casting mistake. You must cast to 8-byte units like ((unsigned long *)a)[-1]. Reading with a[-1] (char-based) reads only 1 byte.
Fix: memorize the casting form whole. And remember this peeking is for learning — reading or writing headers directly in real code breaks your contract with the allocator.
Wall 3. The value stays after free, so I think "reuse didn’t happen"
Symptom: you read right after free and guest is still there.
Cause: right after return, only the queue ledger (next pointer) is written into the chunk’s front; the rest survives for a while. "The value is visible" and "the land is mine" are different things.
Fix: insert a new allocation like 3-2 to confirm reuse. UAF’s danger isn’t "visible now or not" — it’s "it can change at any time."
Wall 4. Same code behaves differently in a different environment
Symptom: the reuse order or detections differ from course materials (e.g., Ubuntu 18.04).
Cause: glibc version differences. tcache arrived in 2.26, next-pointer obfuscation (safe-linking) in 2.32, and various detections in later versions. This environment is 2.39.
Fix: the concepts (reuse, LIFO, UAF) hold across versions. When numbers differ, make it a habit to check the glibc version first with ldd --version.
Wall 5. It’s a UAF but there’s no compile warning
Symptom: it’s clearly use-after-free, yet -Wuse-after-free doesn’t appear.
Cause: the warning only works in simple cases (Exercise 4 in Wall 4’s section). Cross-function flows stay silent.
Fix: don’t mistake silence for safety. Conversely, if the warning fires, that code’s danger is already visible to the naked eye.
7. Summary
Today’s Concepts
| Concept | One-line description |
|---|---|
| Chunk | The unit of allocation — header (size + flags) + user data |
| Header flag bits | Status markers tucked into the bottom bits left zero by 16-byte alignment (the 1 in 0x31 = PREV_INUSE) |
| tcache | Per-thread reuse cache — per-size classes, LIFO, max 7 entries |
| Address reuse | Borrow the same size and you get the spot just returned |
| UAF | An attack where new data lands in a returned spot and an old pointer then uses it |
| safe-linking, etc. | Allocator defenses that vary by glibc version — check the version for the numbers |
Today’s Commands & Code
| Code/command | What it does |
|---|---|
((unsigned long *)p)[-1] |
Peek at the chunk header (for learning) |
b - a (pointer subtraction) |
Measure the chunk gap → the actual chunk size |
gcc -Wall |
Enable warnings like -Wuse-after-free |
gdb -ex "info proc mappings" |
Confirm the heap region’s creation |
ldd --version |
Check the glibc version — the baseline for heap behavior |
The Sense That Matters More Than Commands
Today’s key sentence: "returned land doesn’t vanish — it goes into a queue." And the queue is regular: same size, LIFO. Attacking meant exploiting that regularity to predict where the next allocation lands. Heap attacks feel hard not because of concepts but because the rules’ details change per version — which is why checking the version is step one.
One more thing — what acc read today wasn’t garbage; it was the attacker’s data. Step 61 taught "don’t read after free"; today you saw "if it gets read after free, you’re owned." And what happens when that queue ledger (the next pointer) gets overwritten directly is the topic of tcache poisoning (Step 212) — the ledger, too, is memory in the end, and memory gets overwritten.
Once every box is checked, Step 211 is complete.