sice_cream picoCTF 2019 Solution

Published: April 2, 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 1
    Understand the heap menu
    Observation
    I noticed the challenge description says 'pwn this heap challenge' and the setup downloads a binary alongside a libc.so.6, which suggested I needed to reverse-engineer the binary's menu structure and identify its memory-allocation primitives before attempting any exploit.
    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 syscall and library call traces, but they do not reveal heap metadata corruption or pointer aliasing. The double-free bug only manifests as observable behavior when the allocator detects it (and aborts), so dynamic tracing alone will not expose the root cause. Static analysis in Ghidra combined with manual review of each menu path is required to find the unguarded free pointer.

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

    checksec reveals that NX is enabled and PIE may be present, ruling out shellcode on the stack and making raw return addresses unreliable. The challenge explicitly involves heap allocations (create/eat menu), so the vulnerability class is heap-based. Assuming a stack overflow and building a ROP chain against the wrong memory region will never reach 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 2
    Identify the vulnerability and forge a fake chunk
    Observation
    I noticed that Ghidra analysis showed no bounds check on the pointer passed to free and that the BSS name buffer sits directly above the creams array, which suggested I could forge a fake fastbin chunk header inside name and use a double-free to get malloc to return 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 was introduced in glibc 2.26. The provided libc.so.6 is version 2.23, which has no tcache at all. Attempting to use tcache_perthread_struct offsets or tcache fd corruption will not affect any real data structure in this build. The correct technique is fastbin dup, which exploits the singly-linked LIFO fastbin free list that exists in glibc 2.23.

    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 here is a double-free on an unguarded pointer, not an overflow past the chunk boundary. Allocations are capped at 0x58 bytes and the write is bounded, so there is no overflow path. Focusing on overflow-based heap corruption (e.g. off-by-one into the next chunk size field) wastes time; the correct 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 3
    Leak libc and overwrite __malloc_hook
    Observation
    I noticed that the fake BSS chunk we controlled was in a readable region, which suggested I could upgrade it to an unsorted-bin chunk to trigger glibc writing main_arena pointers into name, then use a second fastbin dup to walk the top chunk pointer to __malloc_hook and overwrite it 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 the exact binary build of libc. The provided libc.so.6 may be a patched Debian or custom build where the gadget appears at a different offset. Running one_gadget libc.so.6 directly against the file in the challenge directory is required; reusing a gadget address from another machine will redirect execution to garbage and crash. Additionally, one_gadget checks register constraints at the call site - an offset whose constraint (e.g. rdx==NULL) is not met will also fail.

    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 uses that exact build. The main_arena+88 offset differs between builds; using the wrong libc causes the base address calculation to be off, making every subsequent symbol lookup (malloc_hook, one_gadget) land at the wrong address. Always load the provided file with ELF('./libc.so.6') in pwntools and use libc.sym to resolve offsets.

    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) lives adjacent to user data in memory. A double-free bug lets an attacker place the same chunk into a fastbin twice, then redirect the linked-list pointer to an attacker-controlled address so that a future malloc returns a pointer into arbitrary memory. The same class of primitives (use-after-free, double-free, heap overflow) persists across allocator generations; tcache keys, safe-linking, and randomized chunk headers in newer glibc versions raise the bar but do not eliminate the attack surface.

Related reading

Want more picoCTF 2019 writeups?

Useful tools for Binary Exploitation

What to try next