Guessing Game 1 picoCTF 2020 Mini-Competition Solution

Published: April 2, 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 1
    Read the Makefile to understand protections
    Observation
    I noticed the challenge provided a Makefile alongside the binary, which suggested the compiler flags would reveal exactly which mitigations are present and determine whether ROP, shellcode injection, or another approach would be necessary.
    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 2
    Predict the random numbers
    Observation
    I noticed the binary calls rand() with no corresponding srand() call, which suggested the seed defaults to 1 per the C standard, making the entire output sequence deterministic and reproducible by compiling a small local program.
    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, requiring a full reconnect each attempt. The PRNG sequence is fixed at srand(1) and the first value is always 84 - brute-forcing wastes time and may trigger rate limits. Reproducing rand() locally gives the answer 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. Martin confirms the sequence 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 3
    Identify the buffer overflow
    Observation
    I noticed winner() reads up to 360 bytes into a 100-byte buffer named BUFF_SIZE, which is a clear mismatch that suggested a stack buffer overflow and required a cyclic De Bruijn pattern to find the exact return address offset.
    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 (rbp) between the buffer and the return address. The actual offset is 120 bytes, not 100. Using 100 as padding leaves the real return address untouched and the binary exits cleanly with no crash, giving no shell.

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

    The binary only calls the vulnerable winner() function after a correct guess. Piping the cyclic pattern directly into the binary without first sending 84 causes the guessing loop to reject it as a wrong answer and exit before reaching the overflow. The crash never happens and gdb reports an incorrect 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. Martin uses this to confirm the offset is 120.

  4. Step 4
    Build a ROP chain with ROPgadget
    Observation
    I noticed the Makefile confirmed no canary, no PIE, and static linking, which suggested ROP was the right approach; ROPgadget's auto-chain feature can build the full execve('/bin/sh') syscall chain directly from the gadgets bundled in the statically linked 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 unit at a time from 0 to 59, producing roughly 480 bytes of ROP payload. The winner() function only accepts 360 characters. Sending the full chain means the payload is truncated mid-chain and the binary either crashes at a partial gadget or stops reading before the chain completes.

    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 increases noise without solving the size problem. The real fix is replacing the entire 59-gadget rax increment sequence with a single 'pop rax ; ret' gadget loaded with the value 59. Searching with --rop and piping to grep 'pop rax' finds that gadget 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 5
    Set rax=59 with a pop rax gadget
    Observation
    I noticed the auto-generated chain used roughly 59 separate increment gadgets to reach rax=59, totaling around 480 bytes and exceeding the 360-byte input limit, which suggested replacing the entire increment sequence with a single 'pop rax; ret' gadget found at 0x4163f4.
    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 most compilers do not emit that exact pattern. 'pop rax ; ret' is far more common because the value 59 is placed on the stack as part of the payload rather than encoded in the instruction itself. Grepping for 'mov rax' in this binary returns 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 6
    Write '/bin/sh' to .bss and send the full exploit
    Observation
    I noticed that execve requires rdi to point to a '/bin/sh' string at a known fixed address, and with no PIE the .bss section is both writable and at a predictable virtual address, making it the standard location to plant the string via a preliminary read() ROP 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 suggest a '/bin/sh' address baked into the binary, but this binary is stripped and that string may not be present at all. Even if it appears, its address can vary based on the exact build. Writing '/bin/sh' to a known .bss address via the read() stage guarantees the string is exactly where rdi expects it regardless of the binary's contents.

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

    Even without PIE, the stack base is randomized by ASLR. A hardcoded stack address will be wrong on almost every run. The .bss section of a non-PIE binary sits at a fixed virtual address that never changes between runs, making it the reliable landing pad for a string that rdi must point to 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, bypassing NX/DEP entirely. Statically linked binaries are especially rich targets because every libc routine is baked in, providing thousands of gadgets for setting registers and invoking syscalls. An unseeded C PRNG is also a deterministic sequence: without an explicit srand call the output of rand() is fixed and reproducible across every machine, making any 'random' check trivially predictable.

Related reading

Want more picoCTF 2020 Mini-Competition writeups?

Useful tools for Binary Exploitation

What to try next