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.
Setup
Download the binary and Makefile from the challenge page.
Install pwntools: pip install pwntools
Install ROPgadget: pip install ROPgadget
pip install pwntools ROPgadgetSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Read the Makefile to understand protections
ObservationThe 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-protectordisables the stack canary,-no-piedisables ASLR for the binary itself, and-staticenables static linking. These three facts together shape the entire exploit strategy.Step 2Predict the random numbers
ObservationThe 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.bashgcc -o randoms randoms.c && ./randomsExpected 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.
Step 3Identify the buffer overflow
Observationwinner() 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.pythonpython3 -c "from pwn import *; print(cyclic(360).decode())" | ./guessing_game_1What 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.Step 4Build a ROP chain with ROPgadget
ObservationThe 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.bashROPgadget --binary ./guessing_game_1 --ropWhat 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 withpack()calls that you replace with pwntoolsp64().Step 5Set rax=59 with a pop rax gadget
ObservationThe 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.bashROPgadget --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
execvethat number is 59 (0x3b). Because the binary is statically linked, it almost certainly contains apop rax ; retgadget somewhere inside libc code - and ROPgadget confirms one at address 0x4163f4. Loading 59 directly withp64(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.
Step 6Write '/bin/sh' to .bss and send the full exploit
Observationexecve 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.pythonpython3 exploit.pyWhat 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'sread()function to read 9 bytes from stdin into .bss. The exploit script immediately sends/bin/sh\x00after 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 thesyscallgadget. pwntoolsp64()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.