function overwrite picoCTF 2022 Solution

Published: July 20, 2023

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 1
    Read the source and find the bug
    Observation
    I noticed that fun[num1] uses a signed int index with no upper-bound check, which suggested a specific out-of-bounds index could reach the adjacent check function pointer that sits 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 will list symbol names and values but omits the section and size columns that confirm fun is a .bss array and check is a separate .bss pointer adjacent to it. Without the section context it is easy to misread which symbols are variables versus functions, leading to wrong address arithmetic. objdump -t with the grep filter shows the section, size, and address in one view.

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

    GDB can print symbol addresses at runtime, but if ASLR is on (or PIE were enabled) the runtime addresses would differ from the static ones used in the exploit calculation. This binary is a non-PIE 32-bit ELF so static addresses are correct, but relying on GDB runtime values trains a habit that breaks on PIE binaries. Reading the symbol table statically with objdump -t gives the load-time addresses that are valid for the exploit.

    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 2
    Redirect check to easy_checker
    Observation
    I noticed from the symbol table that check sits immediately after fun in .bss and the operation is += not assignment, which suggested computing the byte-offset index into fun that aliases check and adding 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 is fun[num1] += num2, not an assignment. If you supply easy_checker's address directly as num2, the result is hard_checker + easy_checker, a garbage address that crashes the process when called. The correct num2 is easy_checker - hard_checker so the addition transforms 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 indexing steps by 4 bytes. Dividing the byte offset by 1 produces an index 4 times too large, sending the write far past check into unrelated memory. The correct divisor is 4 (the element size), which gives the index 10 that lands 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 3
    Submit a story that scores 1337
    Observation
    I noticed that easy_checker validates a score of exactly 1337 by summing the ASCII values of the story characters, which suggested crafting a byte string whose values add up to precisely 1337 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 prompts for the story first, then asks for num1 and num2. If you send num1 before the story prompt arrives, pwntools receives it as part of the story input, so the story contains your index as ASCII digits and the pointer corruption never happens. The correct order is story first, then num1, then num2, matching the program's prompt sequence.

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

    1337 / 65 = 20.57, which is not a whole number, so you cannot reach exactly 1337 with only 'A' characters. You either overshoot or undershoot, and easy_checker's strict equality check rejects the score. Mixing two different byte values (or using the python3 one-liner that pads with a remainder byte) is necessary to hit 1337 exactly.

    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

Function pointers stored in writable global memory become attacker-controlled when any adjacent buffer can be indexed without bounds checking. Corrupting a function pointer redirects control flow to arbitrary code without touching the stack, bypassing stack canaries entirely. This pattern appears in real-world exploits against event dispatch tables, vtables, and jump tables whenever an unbounded signed index or an overflow reaches them. The defense is a combination of bounds checking on every index and placing sensitive pointers in read-only memory.

Related reading

Want more picoCTF 2022 writeups?

Useful tools for Binary Exploitation

What to try next