Skip to main content
When a binary has NX (No-Execute) enabled, the CPU will raise a fault the moment execution reaches any non-executable region — your shellcode sitting on the stack is dead before it runs a single instruction. Return-Oriented Programming sidesteps this completely by never injecting new code at all. Instead, you chain together small sequences of existing instructions — called gadgets — that already live in executable memory. Each gadget ends in a ret, which pops the next address off the stack and hands control to the next gadget. String enough gadgets together and you can perform arbitrary computation using only code the binary already contains.

What Is a Gadget?

A gadget is a short instruction sequence ending in ret. The canonical examples look like:
When you control the stack, you control what ret pops into the instruction pointer. By placing gadget addresses and their argument values in sequence on the stack, you construct a program out of borrowed pieces. The technique is called Return-Oriented Programming because every “instruction” in your program is a return from some borrowed snippet.

When You Need ROP

You reach for ROP whenever checksec shows NX enabled and you cannot simply jump to shellcode. On modern CTF binaries, NX is almost always on. Even on old 32-bit challenges, practicing ROP now pays dividends when you face heap exploits and kernel challenges later.
The minimum viable ROP chain for a CTF is a call to system("/bin/sh"). To make that happen on x86-64 you need three things:
  1. The runtime address of system in libc (requires defeating ASLR first).
  2. The address of the string /bin/sh in libc.
  3. A pop rdi; ret gadget to load the /bin/sh pointer into the first argument register before calling system.

Step 1 — Find Gadgets with ROPgadget

ROPgadget statically scans a binary for every usable gadget and prints its address and bytes. Run it against your challenge binary:
Sample output for the pop|ret filter:
If the binary is PIE, these addresses are offsets from the binary base — you need to add the leaked base before using them. If PIE is disabled, these addresses are absolute and fixed across every run.
Always search for a bare ret gadget (just 0xc3). You will need it for 16-byte stack alignment on x86-64 — explained below.

Step 2 — Understand Stack Alignment

The x86-64 System V ABI requires the stack pointer to be 16-byte aligned at the point of a call instruction. Many libc functions (including system) use SSE instructions internally that will segfault on a misaligned stack. If your exploit crashes inside system rather than giving you a shell, insert a bare ret gadget immediately before the system call to consume one 8-byte slot and restore alignment.

Step 3 — Leak a libc Address (ret2plt)

ASLR randomizes libc’s base address on every run. Before you can call system, you need to know where libc actually landed. The standard technique is ret2plt: call a PLT stub (like puts@plt) with a GOT address as its argument. The GOT entry holds the resolved runtime address of that function in libc. Printing it gives you a fixed point from which you can compute libc base.
1

Identify a suitable leak function

You need a function that both prints data and is already in the PLT. puts is ideal — it takes a single pointer in rdi and prints until a null byte. printf and write work too.
2

Build the leak payload

Call puts(puts@got) to print the runtime address of puts in libc, then return to main to get a second input window.
3

Parse the leak and compute libc base

ljust(8, b'\x00') pads shorter outputs to 8 bytes before unpacking — puts stops at null bytes, so the printed address is often shorter than 8 bytes.

Step 4 — Call system(“/bin/sh”)

With libc_base known, compute the absolute runtime addresses of system and /bin/sh, build the second payload, and send it.
1

Compute target addresses

2

Build and send the shell payload

Complete Exploit

Here is the full two-stage exploit assembled from all the pieces above:

Using the pwntools ROP Class

The pwntools ROP class can build chains automatically when the gadgets it needs are present in the binary or a loaded library. This is faster for standard call sequences:
Setting libc.address tells pwntools the base, so all subsequent symbol lookups return absolute runtime addresses automatically.

ROPgadget Quick Reference

If you’re working against a remote server, always use the exact libc.so.6 provided with the challenge (check the challenge files or Dockerfile). Using your local libc to compute offsets will produce wrong addresses and a crash instead of a shell. When no libc is provided, tools like libc-database or pwntools’s LibcSearcher can identify the version from the leaked address’s last three nibbles.

Troubleshooting Common Failures

Almost always a stack alignment issue. Add a bare ret gadget immediately before the system address in your payload. On x86-64, rsp must be 16-byte aligned at the moment system is entered.
puts stops printing at a null byte. If the leaked address contains a null byte in the middle (common when ASLR isn’t fully randomizing), use write(1, got_addr, 8) instead of puts — it prints exactly 8 bytes regardless of content.
The binary may be very small. Search inside libc instead: ROPgadget --binary libc.so.6 --only 'pop rdi|ret'. If you set libc.address before building your ROP([binary, libc]) object, pwntools will find gadgets in libc as well.
The offset depends on the specific buffer size and stack layout of your binary. Re-run the cyclic pattern technique from the Buffer Overflow writeup to measure your exact offset. Never assume an offset from another writeup applies to your binary.
Last modified on July 23, 2026