Description
It's a race against time. Solve the binary exploit ASAP.
Setup
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.
Step 1
Check protections - NX is disabledObservationI 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 ajmp rspgadget + shellcode injected on the stack.bashchecksec --file=./vulnbash# Expected: NX disabled, no canaryExpected 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.
Step 2
Find the jmp rsp gadgetObservationI 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 ajmp rspgadget - 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.bashROPgadget --binary vuln | grep 'jmp rsp'bash# or:bashropper -f vuln --search 'jmp rsp'bash# Confirm the input function so you know which bad bytes to avoid:bashobjdump -s -j .rodata vuln | grep -E 'gets|fgets|scanf|read'bashobjdump -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 rspgadget 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; retsequence is actually equivalent tojmp rsp: at the point the gadget runs, RSP already points to your shellcode (one slot past the gadget address in the payload).push rspstores that shellcode address on the stack, andretpops it into RIP, landing execution on the shellcode. Use it freely ifjmp rspis not available.call rspis the other commonly-equivalent primitive. If none of these exist, look forjmp [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.
Step 3
Build and send the shellcode payloadObservationI 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 ofjmp rspgadget + shellcode. The shellcode can be a /bin/sh shell or a directcat /flag.txtpayload.pythonpython3 << '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() EOFWhat 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'shellcraftmodule generates platform-appropriate shellcode, andasm()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 toshellcraft.sh(), then runls /flag*,ls /, andfind / -name 'flag*' 2>/dev/nullin 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, eaxinstead ofmov eax, 0 - Avoid newlines (0x0a) if input is read by
fgets - pwntools'
asm(..., avoid=b'\x00\x0a')can encode around bad bytes
- Avoid null bytes: use
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
How to prevent this
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 rspinto 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 withPROT_WRITE | PROT_EXECsimultaneously (W^X).