Skip to main content
Be Bitwise

pwntools features most tutorials skip

If your pwntools knowledge starts and ends at recvuntil and sendline, you're using maybe ten percent of the library. The other ninety is where the time savings live.

#pwntools#exploit-development#python#tooling

Open any CTF writeup that uses pwntools and you'll see the same five functions: process, recvuntil, sendline, recvline, interactive. That's a competent enough subset to solve most challenges, but the library is much larger than that subset suggests. The features below aren't obscure (they're documented and stable), and skipping them means writing code that pwntools would have written for you.

pwn template scaffolds the boilerplate

Every exploit script begins with the same fifteen lines: imports, context setup, an ELF object, a process or remote, a wrapper around gdb.attach. You can stop typing them.

bash
pwn template ./vuln > exploit.py

The output is a complete script with the binary already loaded, the libc autodetected, a start() helper that switches between local process and remote depending on args.REMOTE, and a gdb.attach block gated on args.GDB. Edit it, run it.

Run it locally:

bash
python exploit.py

Attach GDB on launch:

bash
python exploit.py GDB

Aim at a remote:

bash
python exploit.py REMOTE HOST=ctf.example.com PORT=31337

Those args flags are pwntools magic — anything in capitals on the command line shows up in pwn.args. You'll find yourself adding more of them.

context controls what the library guesses

context is the global configuration object that everything else consults. Setting it once at the top of your script saves repetitive arguments downstream.

python
context.binary = './vuln' # sets arch, bits, endianness, log_level
context.terminal = ['tmux', 'splitw', '-h'] # where gdb.attach opens
context.log_level = 'debug' # show every byte sent and received

debug is the one most people miss. With it on, every send and recv is hex-dumped to your terminal in colour. When your exploit fails because the server returned b' \n' instead of b'\n ', you find out immediately. When you've got it working, dial back to 'info' or 'warning'.

context.binary is a quiet workhorse. Setting it autodetects architecture (so you don't have to pass arch='amd64' to every shellcode generator), it autodetects endianness (so p64 knows what to do), and it makes ELF and ROP objects implicitly aware of which binary they're operating on.

cyclic finds your offset in one command

The classic offset-discovery dance (generate a de Bruijn sequence, send it, look at the crashed RIP, search for the four bytes) is one command in pwntools.

bash
pwn cyclic 200

Send those bytes to a vulnerable program, observe the crash. Whatever value ended up in the instruction pointer (0x6161616c, 0x61616173, whatever), feed it back:

bash
pwn cyclic -l 0x6161616c
84

Done. Your offset is 84. Inside a Python script:

python
from pwn import *
payload = cyclic(200)
# crash, recover the pattern
offset = cyclic_find(0x6161616c)

Works for 32-bit too with -n 4, and works for arbitrary alphabets if you need them.

ELF gives you symbols, GOT, and PLT for free

You don't have to copy addresses out of objdump.

python
elf = ELF('./vuln')
elf.symbols['main'] # 0x401200
elf.got['puts'] # 0x404018
elf.plt['system'] # 0x401050
elf.search(b'/bin/sh') # iterator over occurrences

After a leak, you can rebase the whole thing:

python
libc = ELF('./libc.so.6')
libc.address = leaked_puts - libc.symbols['puts']
system = libc.symbols['system']
binsh = next(libc.search(b'/bin/sh'))

Now system and binsh are real runtime addresses. No more "PIE base offset 0x1234 + libc symbol offset 0xABCDE = ?" arithmetic in a comment. The ELF object also gives you elf.libc if pwntools can find a matching libc on disk, which it usually can.

ROP builds chains without manual packing

Hand-packing p64(pop_rdi) + p64(binsh) + p64(ret) + p64(system) is tedious and easy to get wrong (forgetting the ret for stack alignment, in particular). ROP does it for you.

python
rop = ROP(elf)
rop.call('puts', [elf.got['puts']])
rop.call('main')
payload = b'A' * offset + rop.chain()

rop.chain() returns the packed bytes. rop.dump() prints a human-readable view of the chain that's invaluable for debugging. The constructor finds gadgets automatically using the binary's symbols and a built-in gadget search; you don't run ROPgadget separately unless you want something exotic.

For a libc-based chain after a leak:

python
rop = ROP(libc)
rop.call('system', [next(libc.search(b'/bin/sh'))])

Stack alignment for movaps (the perennial Ubuntu glibc gotcha) gets handled automatically when you call functions through ROP.call.

gdb.attach with a script makes triage tractable

Reaching for GDB during exploit development normally means alt-tabbing, attaching by PID, setting breakpoints. Skip all of it.

python
io = process('./vuln')
gdb.attach(io, gdbscript='''
b *main+42
b *win
continue
''')

When you run with GDB on the command line (assuming you used pwn template), this fires automatically. Without it, the script runs without attaching. Combine with context.terminal to control which terminal pane the GDB window opens in.

gdb.debug is the cousin: it spawns the process under GDB rather than attaching, so breakpoints in early initialisation actually fire.

python
io = gdb.debug('./vuln', gdbscript='b *_start\ncontinue')

checksec from the command line, not the website

You don't need to upload your binary anywhere.

bash
pwn checksec ./vuln

Outputs canary, NX, PIE, RELRO, and a few related properties. Same information as the website you were about to use, faster, and works on binaries you can't send anywhere.

Inside a script: print(elf.checksec()) once you've made the ELF, but the CLI version is what you'll use most.

A handful of others that are worth knowing exist

asm() and disasm() for inline assembly that respects context.arch. shellcraft.sh() for "give me a shell shellcode that works on this architecture, please". flat() for laying out structured payloads with named fields instead of byte arithmetic. tube.recvregex() for when the server's framing is irregular. args.LOCAL_LIBC for swapping the loaded libc per-run. And pwn libcdb for matching a leaked libc against a local database of known versions.

There's a lot of pwntools. Five hours spent reading the docs return more than five days spent grinding through challenges with recvline and sendline.

Where to take this

The Vulnerability Research Foundations track on the modules page sequences exploit development from "what is the stack" through ROP, heap, format string bugs, and the modern bypass landscape. pwntools is the toolkit you'll use throughout. Worth investing the time to use it well.