heap 3 picoCTF 2024 Solution

Published: April 3, 2024

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 1
    Free the chunk first
    Observation
    I noticed the challenge is named 'heap 3' and the source shows a dangling pointer x that survives a call to option 5, which suggested this is a use-after-free vulnerability where freeing the chunk first is necessary so the tcache can recycle that same address 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 of the same or smaller size.

    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 2
    Allocate with controlled data
    Observation
    I noticed that chall.c's win check compares a string at offset 30 of the struct that x points to, which suggested using option 2 to request 35 bytes and padding 30 filler characters before 'pico' so the magic string lands at exactly the right offset in the recycled tcache 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 win check inside chall.c compares the string at offset 30 of the struct, not at offset 0. Sending only 'pico' places it at the start of the chunk, so the check reads garbage or null bytes at offset 30 and fails silently. The correct input pads 30 filler bytes before 'pico' to align the magic string with the field the comparison actually reads.

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

    Without freeing first, the tcache for this size class is empty, so malloc returns a fresh chunk at a new address that x does not point to. Option 4's win check reads through the original dangling pointer at the old address, which still holds the initial struct data. The UAF only works because free puts the chunk onto the tcache freelist so the next same-size allocation pops that exact address back.

    Learn more

    After freeing the original chunk, requesting a new allocation of the same or slightly smaller size 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 *)$rsi      # 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 3
    Verify and print
    Observation
    I noticed that option 3 reads through the same dangling pointer x and option 4 runs the win check against it, which suggested using option 3 first to confirm 'pico' is visible before triggering option 4 to print 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

Use-after-free bugs arise when code retains a pointer to freed heap memory and that memory is later reclaimed by a new allocation, letting an attacker substitute controlled data wherever the stale pointer is read. The glibc tcache makes this deterministic: freed chunks of the same size are recycled LIFO, so a carefully sequenced free followed by a controlled malloc reliably lands attacker bytes at the dangling pointer's address. This is one of the most prevalent vulnerability classes in browsers, kernels, and media parsers; Rust's borrow checker prevents it at compile time, while hardened allocators and ARM MTE raise the runtime cost of exploitation.

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 GraphenOS'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

Want more picoCTF 2024 writeups?

Tools used in this challenge

Do these first

What to try next