Skip to main content

sice_cream picoCTF 2019 Solution

A heap exploitation challenge targeting classic glibc allocator weaknesses to gain code execution.

Published: April 2, 2026Updated: August 13, 2026

Description

Just pwn this heap challenge. Connect with nc to get the flag.

Download the binary, libc, and connect to the server.

bash
wget <url>/sice_cream
bash
wget <url>/libc.so.6
bash
chmod +x sice_cream
bash
nc <HOST> <PORT_FROM_INSTANCE>

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Understand the heap menu
    Observation
    The description says to pwn a heap challenge, and the setup ships a binary alongside its libc.so.6. So the job is reversing the menu structure and finding the allocation primitives before any exploit gets written.
    Run the binary locally. It is a heap-based challenge with options like: create ice cream (malloc), eat ice cream (free), and read flavors (read). Understand the data structures used and look for a use-after-free or heap overflow vulnerability.
    bash
    ./sice_cream
    bash
    checksec sice_cream
    What didn't work first

    Tried: Run the binary under strace or ltrace to find the vulnerability automatically

    strace and ltrace show syscalls and library calls, but not heap metadata corruption or pointer aliasing. The double-free only becomes observable when the allocator notices and aborts, so dynamic tracing never exposes the root cause. Finding the unguarded free pointer takes Ghidra plus manual review of each menu path.

    Tried: Skip checksec and assume standard stack exploitation (ret2libc) since it is a netcat challenge

    checksec shows NX enabled and possibly PIE, which rules out stack shellcode and makes raw return addresses unreliable. The challenge is built around heap allocations, the create and eat menu, so the vulnerability class is heap-based. Assuming a stack overflow and building a ROP chain against the wrong region never reaches controllable execution.

    Learn more

    Heap exploitation targets the dynamic memory allocator (glibc malloc). Common vulnerabilities: use-after-free (accessing freed memory), double-free (freeing the same chunk twice), heap overflow (writing past the end of an allocated chunk). These can corrupt allocator metadata (size fields, free lists) to redirect future allocations.

    Run checksec to identify protections: ASLR, PIE, NX, RELRO, Stack Canary. These affect which exploitation techniques are feasible.

  2. Step 2Identify the vulnerability and forge a fake chunk
    Observation
    Ghidra shows no bounds check on the pointer passed to free, and the BSS name buffer sits directly above the creams array. So a fake fastbin chunk header can be forged inside name, and a double-free will make malloc hand back a pointer into that controlled region.
    Analyze the binary in Ghidra. The menu offers create (malloc up to 0x58 bytes), eat (free), and rename (writes up to 256 bytes into a global 'name' buffer on the BSS). There are no bounds checks on the pointer passed to free, enabling a double-free. Because the BSS name buffer sits just above the creams pointer array, you can forge a fake heap chunk header inside name and then use fastbin dup to get malloc to return a pointer into that buffer - giving you read/write over the creams array.
    bash
    ghidra sice_cream &
    What didn't work first

    Tried: Attempt tcache poisoning instead of fastbin dup because tcache is the modern technique

    tcache arrived in glibc 2.26, and the provided libc.so.6 is 2.23, which has none. tcache_perthread_struct offsets and tcache fd corruption touch no real data structure in this build. The right technique is fastbin dup, against the singly-linked LIFO free list that 2.23 does have.

    Tried: Try to trigger the heap overflow on the cream buffer by passing the maximum size to create, then overflow into adjacent metadata

    The bug is a double-free on an unguarded pointer, not an overflow past a chunk boundary. Allocations cap at 0x58 bytes and the write is bounded, so there is no overflow path at all. Chasing off-by-one corruption into the next chunk's size field wastes time; the primitive is freeing the same index twice to corrupt the fastbin fd pointer.

    Learn more

    Why fastbins, not tcache. tcache was introduced in glibc 2.26. This challenge provides glibc 2.23, so all freed small chunks land in fastbins (up to 0x80) or the unsorted bin (larger). Fastbins are singly-linked LIFO lists with no integrity checks on fd in 2.23, which makes fastbin dup (double-free) straightforward.

    Fastbin dup: free A, free B, free A
      fastbin[sz]: A -> B -> A -> (loop)
    malloc(sz) -> A   (fd of A now points into list)
      WRITE A->fd = &fake_chunk_in_BSS
    malloc(sz) -> B
    malloc(sz) -> A   (fd now &fake_chunk_in_BSS)
    malloc(sz) -> &fake_chunk_in_BSS  <- arbitrary alloc

    The fake chunk in the BSS name buffer needs a plausible size field so the allocator accepts it. Since all allocations are at most 0x58 bytes, craft a header of size 0x60 (matching the fastbin) with the prev-in-use bit set.

  3. Step 3Leak libc and overwrite __malloc_hook
    Observation
    The fake BSS chunk sits in a readable region, so it can be upgraded to an unsorted-bin chunk to make glibc write main_arena pointers into name. A second fastbin dup then walks the top chunk pointer to __malloc_hook, which gets overwritten with a one-gadget.
    Stage 1 - libc leak: use the fake BSS chunk to free a forged unsorted-bin-sized chunk (size ~0x90 with correct in-use bits on the next chunk). When the unsorted chunk is freed, glibc writes main_arena pointers into its fd/bk fields, which are now inside the readable name buffer. Read them back to compute the libc base. Stage 2 - hook overwrite: run fastbin dup a second time to corrupt the fastbinsY field inside malloc_state so that the top chunk pointer is redirected to an address just behind __malloc_hook. Sequential mallocs walk the top chunk forward until the allocation lands at __malloc_hook. Write a one-gadget address there. The next call to malloc (or a deliberate double-free that triggers the allocator's abort path) fires the gadget and spawns a shell.
    python
    python3 << 'EOF'
    from pwn import *
    
    elf  = ELF('./sice_cream')
    libc = ELF('./libc.so.6')
    p    = process(['./sice_cream'], env={'LD_PRELOAD': './libc.so.6'})
    # p  = remote('<HOST>', <PORT>)
    
    # helpers -- adapt indices to the actual binary menu
    def create(size, data): ...
    def eat(idx):           ...
    def rename(data):       ...
    def view():             ...
    
    # --- Stage 1: fastbin dup -> fake BSS chunk -> unsorted-bin leak ---
    # Forge chunk header in name buffer via rename()
    # Double-free two real chunks, redirect fd to &fake_chunk_in_name
    # Allocate through to get malloc to return &fake_chunk_in_name
    # Upgrade fake chunk size to unsorted-bin range, free it
    # libc ptrs now written into name; read them back
    
    leaked      = u64(view()[:8])
    libc.address = leaked - libc.sym['main_arena'] - 88
    log.success(f'libc base: {hex(libc.address)}')
    
    # --- Stage 2: fastbin dup -> malloc_state corruption -> __malloc_hook ---
    # Double-free again; redirect top chunk ptr to near __malloc_hook
    # Burn allocations to walk top chunk to __malloc_hook
    one_gadget  = libc.address + 0xf02a4   # verify with: one_gadget libc.so.6
    malloc_hook = libc.sym['__malloc_hook']
    # write one_gadget at malloc_hook via controlled allocation
    # trigger: call malloc or cause a double-free to invoke allocator
    
    p.interactive()
    EOF

    Expected output

    picoCTF{...}
    What didn't work first

    Tried: Use the one_gadget offset found for a different libc 2.23 build (e.g. 0x45226 from a Ubuntu 16.04 system) without verifying against the provided libc.so.6

    one_gadget offsets are specific to an exact libc build, and the provided libc.so.6 may be a patched Debian or custom build with the gadget somewhere else. Run one_gadget against the file in the challenge directory; a gadget address borrowed from another machine sends execution into garbage and crashes. one_gadget also checks register constraints at the call site, so an offset whose constraint is not met fails too.

    Tried: Compute the libc base by subtracting the main_arena offset from the leaked pointer and using the offset from the system's installed libc rather than the provided libc.so.6

    The challenge ships its own libc.so.6 because the remote server runs that exact build. The main_arena+88 offset differs between builds, so the wrong libc throws off the base address calculation and every later symbol lookup lands somewhere wrong. Load the provided file with ELF('./libc.so.6') in pwntools and resolve offsets through libc.sym.

    Learn more

    Unsorted bin leak mechanics. When a non-fastbin chunk is freed, glibc links it into the unsorted bin by writing main_arena+88 into both its fd and bk fields. Because the fake chunk lives in the readable name buffer, the next call to rename or view exposes those 8 bytes. Subtract the known offset of main_arena+88 within the provided libc to obtain the libc base.

    leaked       = u64(p.recvn(8))
    libc.address = leaked - 0x3c4b78   # main_arena+88 offset in libc 2.23
    malloc_hook  = libc.sym['__malloc_hook']
    one_gadget   = libc.address + 0xf02a4   # check constraints with one_gadget tool

    Top chunk hijacking. The second fastbin dup writes a valid-looking chunk header into a slot of malloc_state.fastbinsY, which glibc then treats as the top chunk. Because glibc 2.23 has no top-chunk size sanity check at this point in the free path, the top chunk can be pointed arbitrarily -- in this case, to an address a few bytes before __malloc_hook. Sequential malloc calls split off from that fake top chunk until one lands exactly at __malloc_hook. Writing a one-gadget address there means the very next malloc call (or the internal malloc triggered by the allocator's double-free detection) spawns a shell.

    One-gadget selection. Use the one_gadget tool against the provided libc.so.6 to enumerate candidates and their register/stack constraints. The gadget at offset 0xf02a4 is a common hit for 2.23 builds; verify the constraints are met at the point __malloc_hook is called (rdi holds the requested allocation size).

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.
  • Cyclic Pattern GeneratorGenerate de Bruijn cyclic patterns and find buffer overflow offsets. The browser equivalent of pwntools cyclic and cyclic_find.

Flag

Reveal flag

picoCTF{...}

Key insight: the binary ships with glibc 2.23, which has no tcache. The exploit chains fastbin double-free twice -- first to forge a fake BSS chunk, leak libc via the unsorted bin, then a second time to redirect the top chunk pointer near __malloc_hook and overwrite it with a one-gadget.

Key takeaway

Heap metadata, chunk headers and free-list pointers, sits right next to user data in memory. A double-free lets an attacker put the same chunk into a fastbin twice, then redirect the linked-list pointer at an address they control, so a later malloc returns a pointer into arbitrary memory. The same family of primitives, use-after-free, double-free, and heap overflow, survives every allocator generation. tcache keys, safe-linking, and randomized chunk headers raise the bar without closing the surface.

Related reading

Useful tools for Binary Exploitation

Where to go next