Skip to main content

Guessing Game 1 picoCTF 2020 Mini-Competition Solution

A binary exploitation challenge combining buffer overflow and return-oriented programming to gain code execution.

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

Description

Guess the correct number, then exploit a buffer overflow in the winner function to get a shell.

The binary is 64-bit, statically linked, has no stack canary, and is not position-independent.

Remote

Download the binary and Makefile from the challenge page.

Install pwntools: pip install pwntools

Install ROPgadget: pip install ROPgadget

bash
pip install pwntools ROPgadget

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 Makefile to understand protections
    Observation
    The challenge ships a Makefile alongside the binary. The compiler flags there say exactly which mitigations are on, which decides whether ROP, shellcode injection, or something else is needed.
    The Makefile reveals that the binary is 64-bit, statically linked, compiled without a stack canary (-fno-stack-protector), and is not a position-independent executable (no PIE). Statically linking means every libc function is compiled into the binary, giving an enormous pool of ROP gadgets.
    Learn more

    Statically linked binaries bundle all library code directly into the executable. This is the opposite of dynamically linked binaries, which rely on shared libraries loaded at runtime. For a ROP exploit, static linking is advantageous because thousands of gadgets are available inside the single binary itself.

    The Makefile is always worth reading first. It reveals compiler flags that determine exactly which mitigations are present: -fno-stack-protector disables the stack canary, -no-pie disables ASLR for the binary itself, and -static enables static linking. These three facts together shape the entire exploit strategy.

  2. Step 2Predict the random numbers
    Observation
    The binary calls rand() with no matching srand(). Per the C standard the seed then defaults to 1, so the whole output sequence is deterministic and a small local program reproduces it.
    The binary never calls srand(), which means the C standard specifies it behaves as if srand(1) were called. The sequence of rand() outputs is therefore always identical. Copy the relevant portion of the source code into a small local program that prints the random numbers, and run it to learn the entire sequence in advance.
    bash
    gcc -o randoms randoms.c && ./randoms

    Expected output

    0x00000000004163f4 : pop rax ; ret
    What didn't work first

    Tried: Trying to guess the number by brute-forcing the remote server repeatedly until it accepts

    The server closes the connection after a wrong guess, so every attempt needs a full reconnect. The PRNG is fixed at srand(1) and the first value is always 84, so brute-forcing just burns time and may trip rate limits. Reproducing rand() locally answers it instantly.

    Tried: Using Python's random module or /dev/urandom to generate the expected number

    Python's random module uses Mersenne Twister, not the C standard library rand() algorithm seeded at 1. The sequences are completely different. Only compiling and running C code that calls rand() without srand() (or with srand(1)) reproduces the exact value the binary uses.

    Learn more

    Pseudo-random number generators (PRNGs) are deterministic algorithms. When no seed is provided, C specifies that rand() behaves as if srand(1) were called first, making the output sequence completely fixed and identical on every run, on every machine. This is a well-known property documented in the C standard.

    The first correct guess is 84. The sequence can be confirmed by running the binary locally and verifying that sending 84 prints a congratulations message. From there the sequence is known for every subsequent call, so the exploit script can always send the right answer.

  3. Step 3Identify the buffer overflow
    Observation
    winner() reads up to 360 bytes into a 100-byte buffer. That mismatch is a stack buffer overflow, and a cyclic De Bruijn pattern will pin down the exact offset to the return address.
    After a correct guess, the winner() function reads up to 360 characters into a 100-byte stack buffer (BUFF_SIZE). The return address is 120 bytes from the start of the buffer. Confirm with a cyclic pattern: the segfault occurs at the bytes corresponding to offset 120, giving the precise padding needed.
    python
    python3 -c "from pwn import *; print(cyclic(360).decode())" | ./guessing_game_1
    What didn't work first

    Tried: Assuming the return address offset equals BUFF_SIZE (100 bytes) and skipping the cyclic pattern step

    The compiler inserts alignment padding and the saved base pointer between the buffer and the return address, so the real offset is 120 bytes, not 100. Pad with 100 and the return address stays untouched, the binary exits cleanly, and you get no shell.

    Tried: Running the cyclic pattern without first sending the correct guess to enter winner()

    The binary only calls the vulnerable winner() after a correct guess. Pipe the cyclic pattern in without sending 84 first and the guessing loop rejects it and exits before the overflow is ever reached. No crash happens, and gdb reports the wrong offset.

    Learn more

    A stack buffer overflow occurs when a function writes more data into a stack-allocated buffer than it was sized to hold. The excess bytes overwrite adjacent stack memory, including the saved return address. By controlling that value, an attacker redirects execution anywhere they choose.

    cyclic() generates a De Bruijn sequence where every N-byte substring is unique. When the program crashes, reading which bytes ended up at the instruction pointer tells you exactly how many bytes of padding precede the return address. This confirms the offset is 120.

  4. Step 4Build a ROP chain with ROPgadget
    Observation
    The Makefile confirms no canary, no PIE, and static linking, so ROP is the approach. ROPgadget's auto-chain can build the whole execve('/bin/sh') syscall chain from the gadgets already bundled in the static binary.
    Run ROPgadget with --rop to automatically build a chain that calls execve('/bin/sh'). The generated chain is too long (about 480 bytes) because it increments rax to 59 one step at a time using roughly 59 'add rax' gadgets, each 8 bytes. The limit is 360 characters, so the chain must be shortened.
    bash
    ROPgadget --binary ./guessing_game_1 --rop
    What didn't work first

    Tried: Pasting the ROPgadget auto-generated chain directly into the exploit without trimming it

    The auto-generated chain increments rax one at a time from 0 to 59, which is roughly 480 bytes of payload, and winner() accepts only 360 characters. Send the full chain and it truncates mid-way, so the binary either crashes on a partial gadget or stops reading before the chain finishes.

    Tried: Running ROPgadget with --multibr to find more gadgets instead of looking for a direct pop rax

    The --multibr flag allows gadgets with multiple branches, which adds noise without solving the size problem. The real fix is swapping the 59-gadget rax increment sequence for a single 'pop rax ; ret' loaded with the value 59. Searching with --rop and grepping for 'pop rax' finds it in one step.

    Learn more

    Return-Oriented Programming (ROP) bypasses the no-execute (NX) protection by reusing existing executable code inside the binary. A gadget is a short sequence of instructions ending in a ret. By chaining gadgets, the attacker builds arbitrary computation without injecting any new code.

    ROPgadget can automatically assemble a complete chain for a syscall-based execve("/bin/sh"). For 64-bit Linux, this requires setting rax=59, rdi=address of "/bin/sh", rsi=0, rdx=0, then executing a syscall gadget. ROPgadget emits the chain with pack() calls that you replace with pwntools p64().

  5. Step 5Set rax=59 with a pop rax gadget
    Observation
    The auto-generated chain uses about 59 separate increment gadgets to reach rax=59, roughly 480 bytes, which overshoots the 360-byte input limit. Replacing that whole sequence with one 'pop rax; ret' gadget at 0x4163f4 fixes it.
    The auto-generated ROPgadget chain sets rax by incrementing it from 0 to 59 one step at a time, using roughly 59 separate gadgets that total around 480 bytes - well over the 360-byte limit. The fix is simple: search for a single 'pop rax; ret' gadget in the binary and use it to load 59 (0x3b) directly. One gadget replaces 59, cutting the chain to comfortably fit inside the limit.
    bash
    ROPgadget --binary ./guessing_game_1 --rop | grep 'pop rax'
    What didn't work first

    Tried: Using the syscall number 11 (0xb) for execve instead of 59 (0x3b)

    Syscall number 11 is execve on 32-bit x86, not 64-bit. On x86-64, execve is syscall 59. Loading 11 into rax causes the kernel to execute a completely different syscall (which on 64-bit is munmap), producing an error instead of a shell.

    Tried: Trying to find a 'mov rax, 59 ; ret' gadget instead of 'pop rax ; ret'

    A 'mov rax, imm ; ret' form with an arbitrary 64-bit immediate is rarely a real gadget, because compilers do not usually emit that exact pattern. 'pop rax ; ret' is far more common, since the value 59 rides on the stack as part of the payload rather than being encoded in the instruction. Grepping for 'mov rax' in this binary finds no usable single-instruction form.

    Learn more

    On 64-bit Linux, the syscall convention requires rax to hold the syscall number. For execve that number is 59 (0x3b). Because the binary is statically linked, it almost certainly contains a pop rax ; ret gadget somewhere inside libc code - and ROPgadget confirms one at address 0x4163f4. Loading 59 directly with p64(0x4163f4) + p64(0x3b) is a single 16-byte replacement for the 472-byte increment loop.

    The other registers follow the x86-64 syscall ABI: rdi holds the path pointer ("/bin/sh"), rsi holds the argv pointer (NULL), and rdx holds the envp pointer (NULL). Gadgets for each of those are also present in the static binary.

  6. Step 6Write '/bin/sh' to .bss and send the full exploit
    Observation
    execve needs rdi pointing at a '/bin/sh' string at a known fixed address. With no PIE, the .bss section is writable and sits at a predictable virtual address, which makes it the standard place to plant the string via a preliminary read() stage.
    The string '/bin/sh' must live at a fixed, known address for rdi to point at it. The .bss section is writable and at a fixed address (no PIE), making it the standard landing pad. The full exploit connects with pwntools, sends 84 as the answer to enter winner(), then sends 120 bytes of padding followed by a two-stage ROP chain: stage 1 calls read() to write '/bin/sh\x00' into .bss; stage 2 sets rax=0x3b, rdi=bss address, rsi=0, rdx=0, then executes a syscall gadget to launch the shell.
    python
    python3 exploit.py
    What didn't work first

    Tried: Pointing rdi at a '/bin/sh' string found inside the binary with strings -a instead of writing it to .bss

    ROPgadget's auto chain may point at a '/bin/sh' address baked into the binary, but this binary is stripped and the string may not be there at all. Even when it is, the address shifts with the exact build. Writing '/bin/sh' to a known .bss address through the read() stage puts it exactly where rdi expects, whatever the binary contains.

    Tried: Placing the /bin/sh string on the stack inside the overflow payload and pointing rdi at a hardcoded stack address

    Even without PIE, ASLR randomizes the stack base, so a hardcoded stack address is wrong on nearly every run. The .bss section of a non-PIE binary sits at a fixed virtual address that never moves, which makes it the reliable landing pad for a string rdi has to point at precisely.

    Learn more

    Stage 1 uses pop rdi; ret, pop rsi; ret, pop rdx; ret, and then calls the binary's read() function to read 9 bytes from stdin into .bss. The exploit script immediately sends /bin/sh\x00 after triggering the read. This avoids needing the string already present in the binary.

    Stage 2 is the execve chain: pop rax; ret + p64(0x3b), pop rdi; ret + bss address, pop rsi; ret + p64(0), pop rdx; ret + p64(0), then the syscall gadget. pwntools p64() packs every address in little-endian order. Sending 84 first is all that is needed to pass the guessing check - the PRNG sequence is fixed at srand(1) and 84 is the very first value.

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.
  • Pwntools ForgeGenerate a complete pwntools exploit script from a template: ret2win, shellcode, ret2libc, ROP chain, format string, or blank scaffold. Fill the form, copy or download the .py file. Fully editable before saving.

Flag

Reveal flag

picoCTF{r0p_y0u_l1k3_4_hurr1c4n3}

The PRNG is never seeded, so srand(1) is the effective default. The first rand() value is always 84. The ROP chain is shortened by replacing the auto-generated increment loop with a single 'pop rax; ret' gadget that loads 59 (the execve syscall number) directly.

Key takeaway

Return-Oriented Programming chains short sequences of existing instructions ending in 'ret' to build arbitrary computation without injecting any code, which sidesteps NX and DEP entirely. Statically linked binaries are especially rich targets, because every libc routine is baked in and supplies thousands of gadgets for setting registers and invoking syscalls. An unseeded C PRNG is deterministic too: with no explicit srand call, rand() produces a fixed sequence on every machine, so any 'random' check is trivially predictable.

Related reading

Useful tools for Binary Exploitation

Where to go next