Forcing glibc to Hand You the Flag: Tcache Saturation, Three-Way Consolidation, and a UAF
Most "first real heap" challenges push you toward tcache poisoning or double-free tricks. The scenario in this post is a little different. There is no memory-corruption primitive at all so that means no overflow, no format string, no off-by-one, no write primitive of any kind. The whole exploit happens inside glibc's normal behaviour, using only the intended functions.
We'll get the flag or another sensitive value by convincing the allocator to hand us a pointer that still happens to sit in one of our own pointers that hasn't been cleared after a free, so a UAF that we can print.
The Setup
Imagine a menu-driven heap playground that looks roughly like this:
[*] Function (malloc/free/puts/read_flag/quit):
The four primitives behave like the pseudo-C snippet below. Pay attention to what is, and especially what is not, there.
#define MAX_SLOTS 16
#define MAX_SIZE 0x400
void *allocations[MAX_SLOTS];
void do_malloc(void) {
size_t idx = read_index(); // idx < 16
size_t size = read_size(); // size <= 0x400
allocations[idx] = malloc(size); // store the pointer
}
void do_free(void) {
size_t idx = read_index();
free(allocations[idx]); // NOTE: slot is not cleared
// allocations[idx] still points at the freed memory
}
void do_puts(void) {
size_t idx = read_index();
puts(allocations[idx]); // prints whatever is there
}
void do_read_flag(void) {
char *flag_buffer = malloc(0x52a); // internal allocation
int fd = open("/flag", O_RDONLY);
read(fd, flag_buffer, 0x80); // flag_buffer is never exposed
close(fd);
}
So the primitives the attacker actually has are:
- Allocate up to 16 chunks of any size up to
0x400each. - Free any of them. The slot keeps pointing at the freed memory.
- Print the bytes at any stored pointer.
- Ask the program to internally
malloc(0x52a)and read/flaginto it.
What the attacker does not have is just as important:
- No overflow, no off-by-one, no format string, no double-free primitive.
- No direct access to
flag_buffer:do_read_flagnever stores that pointer anywhere you can reach. - No size big enough to directly allocate a chunk that matches
flag_buffer(you are capped at0x400user bytes,flag_bufferis0x540chunk size).
The "free does not clear the slot" detail is doing all of the heavy lifting. Whenever the allocator later reuses that memory for something sensitive, a subsequent puts(idx) will happily read it back.
The Goal
Make flag_buffer land on the exact same address as allocations[7], so that puts(7) prints the flag.
The Plan
Eleven chunks of 0x400 user bytes each, which is 0x410 including the chunk header:

Think of the last four as the working set: A = 7, B = 8, C = 9, and D = 10 is a guard chunk that keeps C from sitting directly next to the top chunk. Without D, free(9) would be swallowed into top instead of landing in the unsorted bin.
Then in order:
- Free
0..6to filltcache[0x410]to 7/7. free(7)so A lands in unsorted (alone).free(9)so C lands in unsorted (alone).free(8)so B triggers a three-way merge: A, B, and C become one0xC30chunk in unsorted.read_flagcallsmalloc(0x52a), which carves0x540off the head of the merged chunk. The head address is exactly A's base, which is still the value inallocations[7].puts(7)reads the flag through that stale pointer.
The Mechanic: Why _int_free Merges
Every chunk's header has a PREV_INUSE bit describing its left neighbour, not itself. When _int_free runs on a chunk p, it asks two questions:
- Is
p.PREV_INUSE == 0? If so, the left neighbour is free: backward-consolidate. - Is
PREV_INUSEof the chunk afterp's right neighbour equal to0? If so, the right neighbour is free: forward-consolidate.
Those bits are only cleared when a chunk is inserted into a non-tcache, non-fastbin bin (unsorted, small, or large). Tcache entries don't clear PREV_INUSE on neighbours, which is why you can't accidentally merge with a tcache chunk, and why filling tcache is critical. Tcache-full is what forces our frees into the path that does clear those bits.
Step 1: Fill Tcache
After malloc 0..10 and free 0..6, chunks 0 through 6 sit in tcache[0x410] in LIFO (last in first out) order. Chunks 7 through 10 are still allocated.

Tcache[0x410]: FULL (7/7) is the precondition for everything that follows. Freed tcache chunks still show flags=PREV_INUSE in GEF, which is confusing but correct. The bit is about the left neighbour, not the freed chunk itself.
Step 2: free(7), A Goes to Unsorted Alone
Tcache is full, so _int_free takes the main path. A's left neighbour is chunk 6 (in tcache, still "looks" in-use), A's right neighbour is B (allocated). No merge.

A goes into unsorted alone. Side-effect: B's PREV_INUSE bit is cleared to 0, the first of two tripwires we need armed.
Step 3: free(9), C Goes to Unsorted Alone
Same story. C's left neighbour B is allocated, C's right neighbour D is allocated, no merge.

Side-effect: D's PREV_INUSE is cleared to 0. Both tripwires are now armed:
B.PREV_INUSE = 0means "my left neighbour (A) is free".D.PREV_INUSE = 0means "my left neighbour (C) is free".
One thing worth noticing about the unsorted bin: unlike tcache, it is doubly-linked (fd and bk) and the pointers are not mangled. Safe-linking (glibc 2.32+) only applies to tcache and fastbin.
Step 4: free(8), the Three-Way Merge
Now _int_free runs on B and sees both tripwires:
B.PREV_INUSE == 0, so backward-consolidate. Unlink A from unsorted, mergeA+Bat A's base, size0x820.- Check
D.PREV_INUSE == 0, so forward-consolidate. Unlink C from unsorted, mergeA+B+Cat A's base, size0xC30. - Insert the merged chunk into unsorted and clear
D.PREV_INUSEagain.

The unsorted bin now holds exactly one chunk at what used to be A's address, and allocations[7] still points there.
Why "B Last" Isn't the Only Order
Worth calling out, because it's easy to overfit to the specific sequence. The mechanic only cares that the last free in the sequence observes both neighbours as free. "B last" is the cleanest version because B sits between the two arms and collects both merges at once. But free(8), free(7), free(9) also works (B alone, then A forward-merges with B, then C backward-merges with A+B). The rule is about which PREV_INUSE bits are cleared by the time each free runs, not about which index is freed last.
What you cannot do is let any of these frees happen while tcache has room. Tcache accepts the free silently, no PREV_INUSE bit gets cleared, and the whole scheme collapses.
Step 5: read_flag, the Split
read_flag calls malloc(0x52a), which is 0x540 including the header. Too big for tcache (max bin is 0x410). Fastbin is empty. But unsorted has our one 0xC30 chunk, which is larger than 0x540, so glibc:
- Sorts the
0xC30out of unsorted into a large bin. - Finds it as best-fit for
0x540. - Splits the front
0x540off as the returned chunk. The remainder (0x6F0) goes back into unsorted.

The returned chunk is the head of the merged region, same base address as A. That's the entire point of the merge. We needed a free chunk big enough that its head coincided with a pointer we still had stored.
Landing the Flag
With flag_buffer == allocations[7], the final puts(7) reads the flag string up to its terminating null byte. In my run:
malloced[7] = 0x595a9d922f10
flag_buffer = 0x595a9d922f10 <-- same address!
puts(7) -> flag{...}
allocations[7] was set at malloc time and was never touched again. Not by free(7) (doesn't null), not by the consolidation (merged chunk's head is still at A's base), not by the split from read_flag (split returns the head). By the time puts(7) runs, that pointer is aimed straight at the flag buffer.
The Exploit Script
# allocate ALL chunks up front so they are contiguous
for i in range(11):
malloc(i, 1024)
# fill tcache[0x410] completely
for i in range(7):
free(i)
# route the next three frees through the _int_free main path
free(7) # A -> unsorted (clears B.PREV_INUSE)
free(9) # C -> unsorted (clears D.PREV_INUSE, D=10 is the guard)
free(8) # B -> three-way merge, one 0xC30 chunk at A's base
# read_flag's malloc(0x52a) splits 0x540 off the head of the merged chunk
read_flag()
# allocations[7] now points into the flag buffer, UAF read
puts(7)
Common Footguns
- Don't free before every allocation is in place. If you free and then allocate, the new allocations come straight back out of tcache in LIFO order. Your chunks land at reused addresses, and tcache ends up too empty to block the consolidation frees later.
- Don't forget guard chunk D. Without it,
free(9)meets the top chunk and gets swallowed. Unsorted ends up with only A, which is too small (0x410) forread_flagto split0x540off. - Don't try
read_flagbefore consolidating. If unsorted is empty,malloc(0x52a)falls through to fresh top memory, and nothing you have a pointer to overlaps with it.
Takeaways
- Tcache isolates frees from the consolidation machinery. The moment you need neighbours to see each other as free, you need tcache to be unavailable for that size, and "full" is the easiest way to guarantee that.
PREV_INUSEis about the left neighbour, not the chunk itself. Knowing this makes the two tripwire bits (B's and D's) obvious. It's also why seeingPREV_INUSEon a freed chunk in GEF's output isn't a bug.- A guard chunk between your working set and the top chunk is a cheap way to block "helpful" top-consolidation that would otherwise dissolve your setup.
- The UAF here is entirely in the challenge code. The binary does
free(allocations[i])and stops. No null. That's the bug. glibc's behaviour is completely standard.
If you are just getting into heap exploitation, this is a solid first tcache-aware exercise that doesn't require writing a single byte of bogus metadata. The whole attack is a sequence of perfectly legal malloc and free calls. It's the ordering that does the damage.