June 27, 2026

Use-After-Free for CTF: Dangling Pointers and tcache

Use-after-free for CTF: turn a dangling pointer into a shell. Read freed chunks for heap and libc leaks, tcache poisoning, hook targets, and double-free bypass.

A use-after-free is a pointer the program forgot to forget

Here is the whole bug in one sentence: free(p) hands the chunk back to the allocator but does not change p, so if the program ever dereferences p again after you have reclaimed that memory, it reads or writes data you now control. That stale pointer is called a dangling pointer, and a use-after-free (UAF) is what happens when the program trusts it.

If you only remember one thing, remember the exploit recipe. It is four moves and it is deterministic on modern glibc:

  1. Make the program allocate an object and stash the pointer.
  2. Make the program free it without nulling the pointer.
  3. Allocate a same-size chunk yourself. Tcache is last-in-first-out (LIFO), so malloc hands the freed chunk straight back with your bytes in it.
  4. Make the program use the dangling pointer. It now reads your data, calls your function pointer, or links your forged fd into the free list.
The allocator gives the memory back because it does not know you still remember where it used to live. Your exploit is just a second reader of a note the program left for itself.

That is the entire idea. The rest of this post is the four things you do once you have a dangling pointer: read a freed chunk to leak heap and libc addresses, poison tcache to allocate at an arbitrary address, pick the right write target on a 2026 glibc, and slip past the double-free detector when the challenge wants you to free twice. This is the focused companion to the broader Heap Exploitation guide; that post walks all four heap primitives, this one drills into the one that trips up the most beginners.

Note: UAF is its own catalogued weakness class, not a heap quirk. It is CWE-416, and the same dangling-pointer logic shows up in browsers and kernels, not just CTF menu binaries. The CTF version is the cleanest place to learn the mechanics because you control every allocation by hand.

What is a dangling pointer, and why is using it exploitable?

When your program calls malloc(40), the allocator carves a chunk out of the heap and returns a pointer into the middle of it. When the program calls free(p), three things happen and one thing does not. The chunk goes back on a free list, its first bytes get repurposed as allocator metadata, and it becomes eligible to be handed out again. What does not happen is any change to the variable p. It still holds the same address. It is now a dangling pointer: a valid-looking pointer to memory that no longer belongs to the program.

The danger is that the next malloc of a matching size can return that exact chunk to a completely different part of the program. Now two pointers refer to the same bytes with two different ideas about what those bytes mean. One thinks it is a freed User struct, the other thinks it is a fresh Note buffer. Whoever writes last wins, and as the attacker you arrange to write the bytes the other half will trust.

char *a = malloc(0x28); // a -> 0x55...5060
free(a); // 0x55...5060 returned to tcache
// a STILL holds 0x55...5060 (dangling)
char *b = malloc(0x28); // b -> 0x55...5060 (same chunk!)
strcpy(b, attacker_data); // we write through b
puts(a); // program reads through a -> our data

Three flavors of damage come out of that aliasing, in increasing order of power. A read through the dangling pointer after you reclaim the chunk leaks whatever you planted, which is how you turn a UAF into an information leak. A write through the dangling pointer lets you corrupt the new owner's data, which matters most when the new owner holds a function pointer or a length field. A free through the dangling pointer (a double-free) puts the same chunk on the list twice, which is the seed of tcache poisoning.

Key insight: The reason UAF is the friendliest heap bug to learn is that it asks nothing of your byte counting. There is no off-by-one to measure, no overflow length to tune. You only need two facts: the program kept a pointer it should have dropped, and tcache will hand the same chunk back if you ask for the same size. Get those two lined up and the primitive is deterministic.

How do you leak heap and libc pointers by viewing a freed chunk?

The single most useful thing a read-after-free buys you is a leak, because the allocator writes its own pointers into the body of a freed chunk. View that chunk and you read the allocator's bookkeeping, which is made of live addresses. Two leaks fall out depending on which free list the chunk landed on.

Heap leak from tcache or fastbin. When a small chunk is freed it goes on the tcache list, and the allocator writes a forward pointer fd into the first 8 bytes of the chunk body. That fd is a heap address (the next freed chunk, or null if the list is empty). View a freed chunk and the first 8 bytes you read back are a heap pointer.

create(0x28, b'A'*8) # note 0
create(0x28, b'B'*8) # note 1
free(0) # tcache[0x30]: chunk0 -> NULL
free(1) # tcache[0x30]: chunk1 -> chunk0
leak = u64(view(1)[:8].ljust(8, b'\x00'))
log.info('fd of chunk1 -> chunk0: %#x', leak)

Libc leak from the unsorted bin. This is the big one. If you free a chunk that is too large for tcache (larger than 0x408 bytes, so request roughly 0x420 or more), there is no tcache bin for that size at all, so the chunk goes to the unsorted bin. There the allocator writes fd and bk pointers that point back into main_arena, which lives inside libc. View that freed chunk and you read a libc address. Subtract the known offset of main_arena from the leak and you have the libc base, which defeats ASLR for the whole library.

create(0x500, b'A'*8) # note 0, too big for tcache
create(0x28, b'B'*8) # note 1, a guard so chunk0 is not
# merged into the top chunk on free
free(0) # chunk0 -> unsorted bin, fd/bk -> main_arena
leak = u64(view(0)[:8].ljust(8, b'\x00'))
libc.address = leak - 0x21ace0 # offset of the arena slot
# confirm with: vmmap / p &main_arena
log.info('libc base: %#x', libc.address)
Warning: That 0x21ace0 offset is glibc-version-specific and arena-slot-specific. Do not copy it. Attach with GDB, run p &main_arena, subtract the leaked value, and read the real offset off your target's libc.so.6. Get this wrong by a single bin and every later address is shifted. The GDB guide covers attaching to a menu binary and dumping the arena.

With a libc base in hand the challenge stops being a heap puzzle and becomes a normal ret2libc problem: you know where system, /bin/sh, and every useful gadget live. The only thing left is to get one controlled write, which is exactly what tcache poisoning gives you next.

How does tcache poisoning turn a UAF into an arbitrary allocation?

A write-after-free lets you overwrite the freed chunk's fd pointer. The allocator treats fd as gospel: whatever address sits there is the next chunk it will hand out for that size class. So if you edit a freed chunk and write a target address into its fd, the second malloc of that size returns a pointer to your target. That is tcache poisoning, and it converts "I can write to a freed chunk" into "I can allocate, and therefore write, at an address of my choosing."

The forward pointer is the next malloc. Control the fd of a freed chunk and you control where the allocator builds the next object.
create(0x28, b'A'*8) # note 0
free(0) # tcache[0x30]: chunk0 -> NULL
# overwrite chunk0's fd with the target via the UAF edit
edit(0, p64(target)) # tcache[0x30]: chunk0 -> target
create(0x28, b'junk') # malloc #1 pops chunk0
ptr = create(0x28, p64(value)) # malloc #2 returns target,
# and we write 'value' there

That clean version works on glibc before 2.32. After that you hit safe-linking, which mangles every fd on the way in and out:

#define PROTECT_PTR(pos, ptr) ((((size_t) pos) >> 12) ^ ((size_t) ptr))
// pos = address of the slot that stores the fd (the freed chunk itself)

A raw target written into fd now gets XORed with the chunk's own page number on read, so the allocator jumps to garbage. To beat it you mangle the pointer yourself, which is why the heap leak from the previous section is not optional: you need the freed chunk's address to compute pos >> 12. The helper is two lines:

def mangle(pos, ptr): # pos = address of the fd slot
return (pos >> 12) ^ ptr
def demangle(leak, pos): # invert a value you read out of an fd
return (pos >> 12) ^ leak
# poison, safe-linking aware:
edit(0, p64(mangle(chunk0_addr, target)))
Note: The safe-linking dance is a leak then a forge. Read a mangled fd out of a chunk already on the list, run it through demangle to recover the heap address, then use mangle to write the pointer you actually want. If your poisoned allocation lands on garbage, print the mangled bytes: a correctly mangled pointer looks scrambled, so if yours still looks like a clean heap address you forgot to mangle it.

What do you overwrite once you can allocate anywhere?

Tcache poisoning hands you a write primitive. The question becomes where to aim it. The target you pick depends entirely on the glibc version, and this is the place most stale tutorials send you down a dead end.

The classic move (glibc up to 2.33): a hook. For years the canonical finish was to allocate over __free_hook or __malloc_hook, function pointers in libc that the allocator calls on every free or malloc. Overwrite __free_hook with the address of system, then free a chunk whose contents are /bin/sh, and free("/bin/sh") becomes system("/bin/sh"). Clean, two writes, instant shell.

# glibc <= 2.33 finish: __free_hook = system
poison_alloc_at(libc.sym['__free_hook']) # tcache -> &__free_hook
write_there(p64(libc.sym['system']))
create(0x28, b'/bin/sh\x00') # note N
free(N) # free(&'/bin/sh') -> system('/bin/sh')
Warning: glibc 2.34 (August 2021) removed __free_hook, __malloc_hook, and __realloc_hook from the runtime. On Ubuntu 22.04 (glibc 2.35) or anything newer, writing to those symbols does nothing. Every tutorial that ends with "overwrite __free_hook" is dated. Check your target's glibc before you plan the finish.

The modern move (glibc 2.34+): FSOP or the exit handlers. With the hooks gone, the two replacements you reach for are File Stream Oriented Programming (FSOP) and the exit-handler list. FSOP overwrites the vtable pointer (or, on newer glibc, the _IO_FILE structure's _wide_data and vtable pair) of a standard stream so that the next puts, fflush, or the final flush at exit calls a pointer you control. The exit-handler route corrupts __exit_funcs, the linked list of functions exit walks on the way out; the pointers there are mangled by pointer encryption, so it needs a __pointer_chk_guard leak, but it is reliable when a clean program exit is the only trigger you have.

# glibc 2.34+ finish, FSOP sketch (use a known house template):
# 1) forge a fake _IO_FILE in a buffer you control
# 2) point _IO_list_all (or stdout's vtable) at the forgery
# 3) trigger the flush: program calls exit() or returns from main
fake = FileStructure(null=heap_addr) # pwntools helper
fake.flags = unpack(b' /bin/sh\x00')
fake.vtable = libc.sym['_IO_wfile_jumps'] # version-specific
Key insight: The hook era made heap exploitation feel like a fixed recipe, and that recipe is why so many people get stuck: they learn the move that the allocator deleted. The honest version of the skill is "get an arbitrary write, then pick the live target on this glibc." On a 2026 binary that target is almost never a hook. Treat __free_hook as a history lesson and budget time to learn one FSOP template instead.

How does tcache detect double-frees, and how do you bypass it?

Often the cleanest way to set up tcache poisoning is to get the same chunk onto the list twice, because then a single allocation hands you a pointer you can still edit through the dangling reference. Naively that means free(p); free(p);, and on any glibc from 2.29 onward that aborts the program. Knowing exactly what the check inspects is what lets you walk around it.

glibc 2.29 added a key field to every tcache chunk. When a chunk is freed into tcache, the allocator writes a per-thread tcache sentinel into the chunk at offset +0x8 (right after the fd). On the next free, before pushing the chunk, it checks whether that key already matches. If it does, the chunk is probably already on the list, and the program aborts with free(): double free detected in tcache 2.

// tcache chunk body after free():
// +0x00 fd -> next chunk on the list (mangled on 2.32+)
// +0x08 key -> per-thread tcache sentinel <-- the tripwire
free(a); // key written
free(a); // key already matches -> abort

The check is a heuristic, not a guarantee, so there are two standard bypasses. The first uses the menu's own edit handler: if you have a write-after-free, overwrite the key field (the 8 bytes at +0x8) with anything that is not the sentinel, and the second free sails through because the tripwire no longer matches.

create(0x28, b'A'*8) # note 0
free(0) # on the list, key set
edit(0, p64(0) + p64(0)) # clear fd and clobber key at +0x8
free(0) # key no longer matches -> double free accepted

The second bypass needs no write at all. It cycles the chunk through a different list so the key gets cleared along the way. The simplest version interleaves a second chunk so the same chunk is never freed back to back, which is enough on its own to dodge the "already on this list" comparison in many challenges:

free(0) # tcache[0x30]: c0
free(1) # tcache[0x30]: c1 -> c0 (different chunk in between)
free(0) # accepted: c0's key check is satisfied by the interleave
# list is now corrupted with c0 present twice
Tip: When you fill a tcache list to its limit of 7 chunks, extra frees of that size spill into the fastbin or unsorted bin, which run different (and sometimes looser) checks. A common trick is to free 7 chunks to saturate tcache, then perform the double-free into the fastbin, where the only guard is that the same chunk is not at the very top of the bin. Watch the lists grow in pwndbg with tcachebins and fastbins before you commit.

Which picoCTF challenges drill the UAF primitive?

picoCTF keeps its heap track approachable, so the UAF challenges isolate the primitive before they ask you to chain it. Two are worth doing in order:

  • picoCTF 2024 heap 3 is the UAF at its purest. The menu frees a chunk without nulling the slot, you reallocate it with a chosen string, and a later read through the dangling pointer prints the planted bytes and passes the flag check. No leak, no poisoning, no libc. Just the free-then-reallocate sequence so the mechanic sticks.
  • picoCTF 2021 Unsubscriptions Are Free is the next rung: a UAF on an object that holds a function pointer. You unsubscribe (free the user struct without clearing it), allocate a same-size object whose bytes you control, place the address of the win function at the function-pointer offset, and trigger the call through the dangling pointer. UAF plus function-pointer overwrite in one binary.

Do them in that order. heap 3 teaches you that the freed chunk comes back; Unsubscriptions teaches you that what you put in it can be an address the program will call. Once both are muscle memory, the leak and poisoning sections above are just longer versions of the same two moves.

Quick reference

UAF workflow, in order

  1. Spot the four-verb menu (create / free / edit / view). Probe: create, free, view the same index. If it still reads, you have the UAF.
  2. Read a freed small chunk for a heap leak (its fd). Free a 0x420+ chunk into the unsorted bin and read it for a libc leak (its fd/bk point into main_arena).
  3. Edit a freed chunk's fd to a target. On glibc 2.32+, mangle it with (pos >> 12) ^ ptr. Two mallocs of that size return the target.
  4. Finish on the live target: a function pointer or __free_hook up to glibc 2.33, FSOP or __exit_funcs on 2.34+.
  5. Need a double-free? Clear the key at +0x8 via the edit, or interleave a second chunk between the two frees.

pwntools cheat sheet

# Confirm the UAF
create(0x28, b'A'*8); free(0); print(view(0)) # prints -> UAF
# Heap leak from a freed tcache chunk
create(0x28,b'A'); create(0x28,b'B'); free(0); free(1)
heap_fd = u64(view(1)[:8].ljust(8, b'\x00'))
# Libc leak from the unsorted bin
create(0x500,b'A'); create(0x28,b'guard'); free(0)
libc.address = u64(view(0)[:8].ljust(8,b'\x00')) - ARENA_OFF
# Safe-linking mangle for tcache poisoning (glibc 2.32+)
def mangle(pos, ptr): return (pos >> 12) ^ ptr
edit(0, p64(mangle(chunk0_addr, target)))
# Watch the lists
gdb.attach(io, 'tcachebins')

Primary references

The whole technique is one missing line in the program (notes[i] = NULL that never ran) and one fact about the allocator (the freed chunk comes back). Line those two up and a dangling pointer is just a second handle on memory the program still trusts.

One concrete move. Open heap 3, run the create-free-view probe, and watch the freed chunk hand your bytes back. A use-after-free is not a dark art: it is the bug of remembering an address the program already gave away.