Skip to main content

heap 3 picoCTF 2024 Solution

Freeing a chunk before reallocating leaves a dangling pointer, so the next allocation lands there and plants the checked value.

Published: April 3, 2024Updated: August 25, 2026

Description

This program mishandles memory. Can you exploit it to get the flag?

Local + remote

Download chall and chall.c for local analysis.

Connect to tethys.picoctf.net <PORT_FROM_INSTANCE> to interact with the menu.

bash
wget https://artifacts.picoctf.net/c_tethys/6/chall && \
chmod +x chall && \
wget https://artifacts.picoctf.net/c_tethys/6/chall.c && \
nc tethys.picoctf.net <PORT_FROM_INSTANCE>

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
This is the final heap challenge in the series. After progressing through heap 0 (basic overflow), heap 1 (specific value), and heap 2 (function pointers), you now exploit use-after-free vulnerabilities. The menu offers: 2. Allocate heap object (controlled length). 3. Print current x value. 4. Check for win (requires x == "pico"). 5. Free x (this sets up the use-after-free). The Buffer Overflow and Binary Exploitation guide covers use-after-free and tcache poisoning in depth, and the Heap Exploitation for CTF guide shows how UAF chunk recycling works in modern glibc.
  1. Step 1Free the chunk first
    Observation
    The source keeps a pointer x alive across option 5, which frees the chunk. That is a use-after-free: free first so the tcache hands the same address back on the next allocation.
    Option 5 must run before anything else so the next allocation lands on the same chunk that x still points at. If you skip the free, option 2 returns a brand-new chunk somewhere else in the heap, your bytes never reach x, and option 4's win check silently fails.
    Learn more

    A use-after-free (UAF) vulnerability occurs when a program frees a heap chunk but retains a pointer to it (a dangling pointer) and later uses that pointer to read or write memory. The freed chunk may be reallocated for a different purpose, so data written through a new allocation overwrites memory that the old dangling pointer still points to.

    Tcache lifecycle of x's chunk:
    
      step 1 (init):  x = malloc(N)   -> chunk @ A; x = A
      step 2 (free): free(x)          -> tcache[size N]: A -> NULL
                                         (NB: x STILL points at A!)
      step 3 (re-alloc with our data):
                      buf = malloc(N) -> tcache pops A back
                                         buf = A
                      read(buf,...)   -> writes our bytes into chunk @ A
    
      step 4 (use after free):
                      print(*x)       -> reads chunk @ A
                                      -> sees our bytes, prints "pico..."
    
    Key: nothing zeroes the chunk on free or alloc - tcache literally
         pops the old address straight off a singly-linked list and
         returns it as-is. The old user data is still there until you
         overwrite it (which is exactly what we do).

    The sequence matters critically: free first, then allocate. If you allocate before freeing, the new allocation goes to a different chunk (the old one is still in use). Only after freeing does the chunk enter the allocator's freelist, available for the next allocation that falls in the same tcache size class.

    In modern glibc (the standard C library), freed chunks < 0x410 bytes go into the per-thread tcache as a singly-linked LIFO list keyed by size class. The next malloc() of that size pops the head of the list - which is the chunk we just freed. This deterministic recycling is what makes UAF exploitation reliable.

    Use-after-free is one of the most common vulnerability classes in modern software, particularly in web browsers (V8, SpiderMonkey, JavaScriptCore) and media parsers. The Chrome bug tracker regularly contains UAF vulnerabilities rated Critical. Browser sandboxes partially mitigate the impact, but UAF remains a primary pathway to browser exploitation.

  2. Step 2Allocate with controlled data
    Observation
    The win check compares a string at offset 30 of the struct x points to. Request 35 bytes with option 2 and put 30 filler characters before 'pico' so it lands on that offset in the recycled chunk.
    Option 2 asks for a length. The win check inside chall.c compares offset 30 of x's struct against "pico", so request 35 bytes and send 30 filler chars followed by pico to land the string in the right field.
    bash
    AAAAAAAAAAAAAAAAAAAAAAAAAAAAAApico
    What didn't work first

    Tried: Sending exactly 'pico' (4 bytes) as the allocation input without any padding

    The check reads offset 30 of the struct, not offset 0. Send 'pico' alone and it sits at the start of the chunk while the check finds null bytes where it looks. Pad 30 bytes first so the string lands on the field being compared.

    Tried: Skipping option 5 and going straight to option 2 to allocate with 'pico'

    Skip the free and the tcache for that size class is empty, so malloc returns a fresh chunk at an address x does not point to. The win check still reads the old address, which holds the original struct. The whole attack depends on free putting that chunk on the tcache list so the next same-size allocation pops it right back.

    Learn more

    After freeing the original chunk, requesting a new allocation that maps to the same tcache size class causes the allocator to return the same chunk that was just freed. Your new write then populates the freed chunk with fresh data, which the dangling pointer from the previous allocation will read.

    The 30-byte offset comes from chall.c: the struct that x points to has padding/header fields ahead of the string the win check inspects. Without source you can recover the same number in GDB by setting a breakpoint right after option 2 finishes its read, then comparing the chunk's base address to the field the check actually reads:

    pwndbg> b *check_win+offset_of_strcmp
    pwndbg> r
    ... (run options 5, 2, 4)
    pwndbg> p x                 # base of struct
    $1 = 0x55555556a2a0
    pwndbg> p (char *)$rdi      # the field strcmp reads
    $2 = 0x55555556a2be         # 0x1e = 30 bytes after base

    The length of 35 bytes is chosen so that the total write (30 filler bytes + "pico" + null terminator) fits within the freed chunk without overflowing into the allocator's metadata. Exceeding the chunk size would corrupt the heap metadata, likely causing a crash when the next allocation or free occurs.

    Heap grooming generalizes this: an attacker controls the sequence and size of allocations and frees to achieve a desired heap layout. Advanced UAF exploits may require dozens of carefully sized allocations to position the target chunk exactly where the dangling pointer expects it, especially in modern hardened allocators.

  3. Step 3Verify and print
    Observation
    Option 3 reads through the same dangling pointer that option 4 checks. Use it to confirm 'pico' is in place before triggering the flag.
    Option 3 now echoes pico, and option 4 prints the flag because the dangling pointer points to your crafted data.
    If the check fails, ensure you freed first and used exactly 30 filler characters before pico.
    Learn more

    Option 3 ("print current x value") reads through the dangling pointer that still references the freed chunk. Since you reallocated that chunk with your controlled data, the read now returns "pico". This demonstrates the UAF: the program believes it's reading from x, but the memory at that address now contains attacker-controlled data.

    Option 4's win check (x == "pico") uses the same dangling pointer, finds the string you wrote, and prints the flag. The original value of x (whatever was in the structure before freeing) is irrelevant - the UAF lets you substitute any value you can allocate.

    In real-world UAF exploitation, the "controlled allocation" step is often more complex. Browser exploits might allocate JavaScript ArrayBuffers, Uint8Arrays, or other typed arrays at a predictable size to reclaim freed DOM element structures. The attacker must understand both the target object's layout and the layout of available attacker-controlled objects to find a suitable "replacement" that the vulnerable code will misinterpret usefully.

    UAF mitigations layer at three different boundaries: allocator-level defences (PartitionAlloc segregates objects by type so a freed DOM node can't be reclaimed by a UInt8Array; hardened_malloc and Scudo quarantine freed chunks), OS-level defences (delayed free pools, kernel zero-on-free), and hardware-level defences (ARM MTE tags pointers and traps stale pointer dereferences in silicon, CHERI capabilities revoke entirely on free). The right one depends on what you control: app authors reach for smart pointers and hardened allocators, OS vendors enable MTE, and language designers pick borrow-checking (Rust) to prevent the bug at compile time.

Interactive tools
  • pwntools Payload BuilderPack integers into little-endian bytes (p32 / p64), unpack bytes back to integers, and build flat ROP payloads with offset-based insertion.

Flag

Reveal flag

picoCTF{now_thats_free_real_estate_a11...}

Once the freed chunk is reallocated with pico, the win check passes and prints the flag.

Key takeaway

A use-after-free happens when code keeps a pointer to freed memory that a later allocation reclaims, letting an attacker choose what the stale pointer reads. The glibc tcache makes it deterministic: same-size chunks come back last-in-first-out, so a free followed by a controlled malloc reliably places your bytes at that address. It is one of the most common bug classes in browsers, kernels, and media parsers. Rust's borrow checker rules it out at compile time; hardened allocators and ARM MTE make it expensive at runtime.

How to prevent this

Use-after-free is a lifetime bug. The fix is making freed pointers unusable, not adding more validation.

  • free(p); p = NULL; on every release. Then the next dereference crashes immediately instead of reading attacker data. Better: smart pointers (std::unique_ptr, std::shared_ptr) so the lifetime is enforced by the type system.
  • Use a hardened allocator: PartitionAlloc (Chrome), Scudo (Android/Fuchsia), or GrapheneOS's hardened_malloc. These segregate objects by type and quarantine freed chunks, making UAF reallocation attacks dramatically harder.
  • On ARMv8.5+, enable MTE (Memory Tagging Extension). Pointers carry a 4-bit tag that must match the tagged memory; stale pointers fault on dereference. For new code, write in Rust where the borrow checker prevents UAF at compile time.

Related reading

Tools used in this challenge

Where to go next