pwntools
pwntools is a Python toolkit built for binary exploitation challenges. It turns the
tedious parts of pwn work into one-liners: spawning a local process or connecting to
a remote service, sending and receiving bytes without fighting buffering, packing
integers into the right byte order, and assembling small ROP chains against a binary
you already have on your own box. It is the library most CTF players reach for first
when a challenge hands them an ELF and a nc host port line.
You will see pwntools everywhere in the pwn category: a local practice binary you are reverse engineering, a CTF challenge file shipped with its libc, a homelab target VM you control, and the scratch scripts people keep around for quick byte packing. It matters because it lets you focus on the logic of an exploit instead of the plumbing.
What you will learn#
- How to install, update, and verify pwntools
- When to use it, and when a different approach fits better
- How to drive a local process and a remote service with the same script
- How to pack, unpack, and build cyclic patterns without manual math
- Realistic examples for I/O, packing, ELF parsing, ROP, and shellcode assembly
- The mistakes that waste the most time, and how to avoid them
- A commented cheat sheet you can paste into your notes
Legal and scope reminder#
Only run exploits against binaries and services you own or are explicitly authorized to test. CTF platforms, your own homelab VMs, and practice binaries you downloaded for learning are all fair game. Anything else needs written permission and an agreed scope. Keep a short note of the challenge name, the source, and the date next to your solve script. This section is the boring part that keeps the fun part legal.
Prerequisites#
- A Linux host is easiest (Kali, Parrot, or any Ubuntu/Debian box). pwntools also works under WSL and macOS, but Linux matches the binaries you will face.
- Python 3.8 or newer. pwntools is a Python library, so you write scripts, not just one-off commands.
- Basic comfort with the terminal, Python, and reading hex and addresses.
- A rough idea of how the stack, registers, and calling conventions work. You do not need to be an expert, but pwntools assumes you know what you are trying to do.
- A practice binary you are allowed to work on. The examples below use
./chall, private lab IPs, and placeholder values only.
Lab setup#
Keep every challenge in its own folder so binaries, scripts, and notes never mix.
# One folder per challenge, with room for the binary, libc, and your solve script
mkdir -p ~/labs/pwn-demo/{bin,scripts,notes}
cd ~/labs/pwn-demo
# Drop the provided files into bin/, keep your solve.py in scripts/
ls bin/ # chall, libc.so.6, ld-linux.so.2, etc.
Use private ranges if a challenge also exposes a remote service
(10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16). Throughout this guide,
./chall means a local practice binary you are authorized to work on,
TARGET_IP means a lab host running the challenge service, and TARGET_PORT
means the port it listens on.
Installation#
Pick the method that matches your environment.
# Debian / Ubuntu / Kali (system Python, simplest on most boxes)
sudo apt update && sudo apt install -y python3-pip
pip3 install --upgrade pwntools
# Isolated install with pipx (keeps the CLI tools tidy, no system clutter)
sudo apt install -y pipx
pipx install pwntools
# Inside a virtualenv (preferred for reproducible solve scripts)
python3 -m venv ~/labs/pwn-demo/venv
source ~/labs/pwn-demo/venv/bin/activate
pip install --upgrade pwntools
pwntools also installs helper command-line tools (pwn, checksec, cyclic,
asm, disasm, and more). Use the virtualenv route whenever you want a clean,
reproducible setup per challenge; use the system pip route for a quick global
install on a throwaway VM.
Update process#
# pip / pipx upgrade (most common)
pip3 install --upgrade pwntools
# Inside an active virtualenv
pip install --upgrade pwntools
# pipx-managed install
pipx upgrade pwntools
# Bleeding-edge from the dev branch (only if you need an unreleased fix)
pip3 install --upgrade git+https://github.com/Gallopsled/pwntools.git@dev
New releases add architectures, ROP improvements, and bug fixes, so keeping pwntools current avoids chasing problems that were already patched upstream.
Check if installed and available#
command -v pwn # prints the path if the CLI is on your PATH
pwn --version # confirm the installed version
python3 -c "import pwn; print(pwn.__version__)" # confirm the library imports
pwn --help # list the bundled CLI subcommands
If command -v pwn prints nothing but the import works, the library installed but
the CLI scripts are not on your PATH. Activate your virtualenv or check
~/.local/bin. If the import itself fails, the install did not complete.
First safe test#
Pack and unpack a value. It proves the library works and touches nothing external.
# Pack the 64-bit integer 0xdeadbeef into little-endian bytes, then unpack it back
python3 -c "from pwn import *; print(p64(0xdeadbeef)); print(hex(u64(p64(0xdeadbeef))))"
Expected: a byte string like b'\xef\xbe\xad\xde\x00\x00\x00\x00' followed by
0xdeadbeef. Success looks like the round trip producing the same value you started
with. Failure looks like ModuleNotFoundError: No module named 'pwn' (install
problem) or a Python version error (upgrade Python or your virtualenv).
Core concepts#
- Tubes are the unified I/O abstraction.
process()runs a local binary,remote()connects to a service, and both expose the same send/receive methods. - context is a global settings object. Set
context.binaryorcontext.arch,context.os, andcontext.bitsonce and the rest of the library adapts (packing width, assembler target, and more). - Packing converts integers to bytes and back:
p32/p64to pack,u32/u64to unpack, in the byte ordercontextimplies. - ELF parsing reads symbols, the GOT, the PLT, and section addresses from a binary so you do not hardcode offsets.
- ROP helpers find gadgets and assemble chains from an ELF or its libc.
- cyclic patterns generate De Bruijn sequences so you can find an exact offset to a crash instead of guessing.
- GDB integration (
gdb.attach,gdb.debug) drops you into a debugger at the right moment with breakpoints already set.
When this tool is useful#
- CTF pwn challenges: the standard way to script interaction and exploitation.
- Local reversing and testing: drive a binary you are analyzing on your own box.
- Homelab targets: exploit a service running on a VM you control end to end.
- Quick byte work: pack addresses, build patterns, or assemble shellcode fast.
- Reproducibility: turn a manual finding into a clean, rerunnable solve script.
- Learning: experiment with stack layout and gadgets without manual hex juggling.
When not to use this tool#
- For pure static reversing, where Ghidra, IDA, or radare2 do the heavy lifting and pwntools only helps later when you script the interaction.
- When a one-line
nc TARGET_IP TARGET_PORTand a pasted payload already work and scripting adds nothing. - Against anything you are not authorized to touch. pwntools makes remote I/O trivial, which is exactly why scope discipline matters.
- As a vulnerability scanner. pwntools executes your logic; it does not find bugs for you.
- On Windows-native targets without setup, since the assembler and many helpers assume a Linux/ELF world by default.
Command anatomy#
Most pwntools work happens in a Python script rather than a single shell command. A typical solve script has this shape.
from pwn import * # import the toolkit
context.binary = './chall' # set arch/os/bits from the binary
io = process('./chall') # [tube] local process, or remote(TARGET_IP, TARGET_PORT)
payload = b'A' * 64 # [build] padding to reach the target
payload += p64(0x401234) # [build] packed address, little-endian
io.sendlineafter(b'> ', payload) # [I/O] send after a prompt
print(io.recvall()) # [I/O] read the response
- import:
from pwn import *pulls in the helpers used below. - context: sets architecture and word size so packing and assembly are correct.
- tube:
process()for local,remote()for a service; same methods on both. - build: assemble the payload with packing helpers and cyclic patterns.
- I/O: send and receive bytes, then read and interpret the response.
Common flags and options#
These are the helpers and CLI options you reach for most. The CLI tools come bundled with the library.
| Option | Meaning | When to use it | Example |
|---|---|---|---|
context.binary | Set arch/os/bits from a file | Start of every script | context.binary = './chall' |
context.log_level | Control verbosity | Quiet or debug a script | context.log_level = 'debug' |
process() | Run a local binary | Local testing | io = process('./chall') |
remote() | Connect to a service | Solve a remote challenge | io = remote(TARGET_IP, TARGET_PORT) |
p64() / u64() | Pack / unpack 64-bit | Build or read addresses | p64(0x401234) |
cyclic() | De Bruijn pattern | Find a crash offset | cyclic(200) |
cyclic_find() | Locate offset in pattern | Turn a leak into an offset | cyclic_find(0x6161616c) |
ELF() | Parse a binary | Read symbols, GOT, PLT | e = ELF('./chall') |
ROP() | Build a ROP chain | Chain gadgets | rop = ROP(e) |
checksec | Show binary mitigations | Triage a new binary | checksec ./chall |
cyclic (CLI) | Pattern from the shell | Quick offset work | cyclic 200 |
asm (CLI) | Assemble from the shell | Quick shellcode bytes | asm 'nop' |
Practical examples#
Each example below explains what it does and what to expect. All targets are local practice binaries or lab hosts you are authorized to work on.
# Triage a binary: print its mitigations before writing any exploit
from pwn import *
context.binary = e = ELF('./chall') # also sets context arch/bits
print(e.checksec()) # shows NX, PIE, canary, RELRO
# Output: a summary of which protections are on. This decides your approach.
# Local process I/O: send input and read the reply
from pwn import *
io = process('./chall') # run the binary locally
io.recvuntil(b'name: ') # read up to the prompt
io.sendline(b'lab-user') # send a line of input
print(io.recvline()) # print the next line of output
io.close() # clean up the process
# Same script, but pointed at a remote lab service instead of a local process
from pwn import *
io = remote('TARGET_IP', 31337) # connect to the challenge host:port
io.sendlineafter(b'> ', b'lab-user') # send after the prompt appears
print(io.recvall(timeout=2)) # read everything until it closes
# Find a crash offset with a cyclic pattern (local binary you control)
from pwn import *
context.binary = './chall'
io = process('./chall')
io.sendline(cyclic(200)) # send a 200-byte De Bruijn pattern
io.wait() # let it crash
core = io.corefile # read the core dump it left
print("offset:", cyclic_find(core.read(core.rsp, 4))) # locate the offset
# Output: an integer offset, for example 72. That is your padding length.
# Build a simple padded payload using the offset you found
from pwn import *
context.binary = './chall'
offset = 72 # from the cyclic step above
payload = b'A' * offset # padding to reach the saved return slot
payload += p64(0x401136) # placeholder address to redirect to
io = process('./chall')
io.sendline(payload)
print(io.recvall(timeout=2))
# Read symbol, GOT, and PLT addresses from an ELF instead of hardcoding them
from pwn import *
e = ELF('./chall')
print(hex(e.symbols['main'])) # address of a known symbol
print(hex(e.got['puts'])) # GOT entry for puts
print(hex(e.plt['puts'])) # PLT stub for puts
# Output: addresses parsed from the binary. Use these in your payload.
# Assemble a tiny ROP chain from gadgets in the binary (lab challenge)
from pwn import *
context.binary = e = ELF('./chall')
rop = ROP(e)
rop.call('puts', [e.got['puts']]) # call puts(puts@got) to leak an address
print(rop.dump()) # human-readable view of the chain
payload = b'A' * 72 + rop.chain() # padding + the assembled chain
# Assemble shellcode for the binary's architecture (no manual opcodes)
from pwn import *
context.arch = 'amd64' # match the target architecture
sc = asm(shellcraft.sh()) # generate a standard interactive-shell stub
print(disasm(sc)) # confirm what it assembled to
# Output: assembled bytes plus a readable disassembly to sanity-check it.
# CLI helpers for quick work without writing a script
cyclic 100 # print a 100-byte pattern to paste into a test
cyclic -l 0x6161616b # look up the offset of those four bytes in the pattern
checksec ./chall # show the binary's mitigations
asm 'mov rax, 1' # assemble one instruction to bytes
# Attach GDB at the right moment for interactive debugging (local only)
from pwn import *
context.terminal = ['tmux', 'splitw', '-h'] # open GDB in a tmux split
io = gdb.debug('./chall', gdbscript='break main\ncontinue')
io.interactive() # hand control to you for manual stepping
Example workflow#
A realistic beginning-to-end pass on one authorized practice binary.
# 1. Prepare and confirm scope
mkdir -p ~/labs/chall01/{bin,scripts,notes} && cd ~/labs/chall01
echo "scope: ./chall + TARGET_IP:31337, authorized $(date -I)" > notes/scope.txt
# 2. Triage the binary's protections from the shell
checksec bin/chall | tee notes/checksec.txt
# 3. Develop locally first: find the offset (scripts/solve.py)
from pwn import *
context.binary = './bin/chall'
io = process('./bin/chall')
io.sendline(cyclic(200))
io.wait()
print("offset:", cyclic_find(io.corefile.read(io.corefile.rsp, 4)))
# 4. Build the payload locally and confirm it behaves
from pwn import *
context.binary = e = ELF('./bin/chall')
payload = b'A' * 72 + p64(e.symbols['main']) # placeholder redirect for testing
io = process('./bin/chall')
io.sendline(payload)
print(io.recvall(timeout=2)) # read and interpret the result
# 5. Flip to remote once it works locally: change the tube, keep the logic
io = remote('TARGET_IP', 31337) # the only line that changes
io.sendline(payload)
print(io.recvall(timeout=2))
The local-first habit is the point: develop against ./chall where you can attach a
debugger and inspect crashes, then switch the one process() line to remote() when
the script reliably works.
Reading and interpreting output#
- checksec output tells you the rules: NX off means stack execution may be possible; PIE on means addresses are randomized and you need a leak; a canary means a straight overflow will be caught.
- A clean recv that matches the program’s normal prompt means your I/O sync is correct.
- A hang on recv usually means the program is waiting for input you have not sent, or you are reading past what it produced; add a timeout and check your prompts.
- EOFError means the connection or process closed. Often that is a crash you caused, which can be progress, or a payload that the program rejected.
- A core dump appearing locally is useful: it lets
cyclic_findandcorefilerecover the crash state. - Garbage bytes in a leak are normal; unpack them with
u64()and sanity-check the address looks plausible for the architecture before trusting it. - Empty output can mean wrong host or port, a closed service, or that the program buffered its output and you closed too early.
Common mistakes#
- Sending text instead of bytes. pwntools wants
b'...'; passing astrraises an error or sends the wrong thing. - Forgetting to set
context.binaryorcontext.arch, sop64,asm, and ROP use the wrong word size or architecture. - Using
sendlinewhen the program reads a fixed number of bytes, so the trailing newline shifts everything. - Reading with
recvand racing the program, instead ofrecvuntil/sendlineafter, which sync on a known prompt. - Hardcoding addresses from one run when PIE is on, then wondering why the next run fails.
- Mixing up the local libc and the challenge’s provided libc, so offsets are wrong.
- Testing only remotely. Develop locally where you can debug, then switch the tube.
- Leaving
context.log_level = 'debug'on in a final script, drowning real output.
Troubleshooting#
| Problem | Likely cause | Fix |
|---|---|---|
No module named 'pwn' | Not installed / wrong venv | Reinstall; activate the right virtualenv |
command not found: pwn | CLI not on PATH | Add ~/.local/bin; activate the venv |
Script hangs on recv | Waiting for input or over-reading | Use recvuntil and add timeout= |
EOFError | Process/connection closed | Expected on a crash; check the payload otherwise |
| Wrong packing width | context not set | Set context.binary or context.bits |
| Addresses change each run | PIE enabled | Leak an address at runtime, do not hardcode |
| Exploit works local, not remote | Different libc/version | Use the provided libc; rebuild offsets |
asm fails or wrong bytes | Architecture not set | Set context.arch before asm |
| Results differ from a writeup | Version/flag changes | Check pwn --version and current docs |
Tools that pair well#
- binwalk: carve and inspect embedded data in a challenge file before you start scripting against it.
- netcat: a quick manual connection to a service to see prompts before committing them to a pwntools script.
- socat: host a local copy of a challenge service so you can develop against it without touching the remote.
- searchsploit: look up known issues in a library version a binary links against.
- GDB with the gef or pwndbg extension: the debugger pwntools attaches to for stepping through a crash and confirming offsets.
- Ghidra or radare2: static reverse engineering to find the bug before you script it.
Automation and note taking#
# Run the solve script, timestamp it, and save the output alongside your notes
python3 scripts/solve.py 2>&1 | tee "notes/solve-$(date +%Y%m%d-%H%M).txt"
Keep one folder per challenge with the binary, any provided libc, your solve.py,
and a one-line scope note. Save checksec output and the cyclic offset in the notes
so a rerun does not start from scratch. Screenshot anything you will reference later,
and keep the final script clean so it runs unattended.
Blue-team visibility#
A pwntools script that hammers a remote service can be very visible to defenders. Repeated connections from one source, malformed input, crash-and-reconnect loops, and bursts of identical payloads all stand out. On the service side, expect process crash logs, core dumps, watchdog restarts, and connection records in any front-end proxy or firewall. Knowing this helps on both sides: as a defender, crash loops and repeated odd input are exactly the patterns to alert on and rate-limit; as a learner, it explains why a noisy brute-force-style script against a lab service is easy to spot and why developing locally first is both faster and quieter.
Safer or lower-noise usage#
- Develop and test against a local
process()copy before ever touching a remote. - When you must test remotely, send deliberate single attempts, not tight loops.
- Add timeouts so a stuck script does not pile up connections.
- Keep the target set to the one challenge host in scope, never a range.
- Avoid brute-force-style retries unless the challenge clearly expects them and you are authorized.
- Confirm scope and the exact host and port before the first connection.
Alternatives#
- pwntools (Python): the all-rounder for ELF challenges, with the richest I/O, packing, ELF, and ROP helpers. Best default for Linux pwn.
- pwntools-equivalent libraries in other languages: thin wrappers exist for scripting I/O, but they lack the ELF and ROP tooling. Use only if you must stay in another language.
- Plain
socketplusstructin Python: fine for trivial I/O and packing, but you rebuild everything pwntools already gives you. Use for tiny one-offs. - ROPgadget / ropper: dedicated gadget finders. pwntools’
ROPcovers most CTF needs, but these can be handier for searching unusual gadgets, then you feed the addresses back into a pwntools script. - pwntools wins on integration and speed of development; standalone tools win when you want one focused capability without the full library.
Deprecated and legacy notes#
- pwntools 4.x dropped Python 2 support; old tutorials that use
printstatements or Python 2 idioms will not run on a current install. - Some older guides import with
from pwn import *and then rely on implicit string handling. Modern pwntools is strict about bytes versus str, so paste-old-code carefully. - The
pwnlibpackage is the internal module name; you still import the public API aspwn. A few very old writeups reference moved or renamed helpers, so check the current docs if an import fails.
Quick reference cheat sheet#
# --- Setup ---
from pwn import * # import everything
context.binary = './chall' # set arch/os/bits from the binary
context.log_level = 'info' # 'debug' to see raw I/O, 'error' to quiet
# --- Tubes (local vs remote) ---
io = process('./chall') # run a local binary
io = remote('TARGET_IP', TARGET_PORT) # connect to a lab service
# --- I/O (sync on prompts, not luck) ---
io.recvuntil(b'> ') # read up to a known prompt
io.sendline(b'lab-user') # send a line (adds newline)
io.sendlineafter(b'> ', PAYLOAD) # wait for prompt, then send
data = io.recvall(timeout=2) # read everything until close
io.interactive() # hand control to you (e.g. a shell)
# --- Packing / unpacking ---
p64(0x401234) # pack 64-bit, little-endian
u64(b'\x34\x12\x40\x00\x00\x00\x00\x00') # unpack back to an int
p32(0xdeadbeef); u32(b'....') # 32-bit variants
# --- Cyclic patterns (find a crash offset) ---
pattern = cyclic(200) # 200-byte De Bruijn sequence
offset = cyclic_find(0x6161616c) # offset of those bytes in the pattern
# --- ELF parsing (no hardcoded offsets) ---
e = ELF('./chall') # parse the binary
hex(e.symbols['main']) # symbol address
hex(e.got['puts']); hex(e.plt['puts']) # GOT and PLT entries
# --- ROP chain ---
rop = ROP(e) # build from an ELF
rop.call('puts', [e.got['puts']]) # call puts(puts@got)
PAYLOAD = b'A' * offset + rop.chain() # padding + chain
# --- Shellcode (lab/practice only) ---
context.arch = 'amd64' # match the target arch
sc = asm(shellcraft.sh()) # standard interactive-shell stub
print(disasm(sc)) # confirm what assembled
# --- CLI helpers (no script needed) ---
checksec ./chall # show NX, PIE, canary, RELRO
cyclic 100 # print a 100-byte pattern
cyclic -l 0x6161616b # look up an offset in the pattern
asm 'mov rax, 1' # assemble one instruction
disasm '4831c0' # disassemble raw hex bytes
pwn --version # confirm the installed version
Mini decision tree#
- New binary, unknown protections: run
checksec ./challfirst. - Need the crash offset: send
cyclic(N), thencyclic_findon the crash value. - PIE is on: leak an address at runtime, never hardcode.
- Local works but remote fails: use the challenge’s provided libc, not yours.
- Script hangs: switch
recvtorecvuntiland add atimeout=. - Need a quick byte or offset without a script: use the
cyclic,asm, orchecksecCLI tools.
Final checklist#
- Scope and authorization noted next to the solve script
- Binary triaged with
checksecbefore writing the exploit - Developed and tested locally before pointing at a remote
-
context.binaryorcontext.archset so packing and assembly match - All payloads sent as bytes, synced with
recvuntil/sendlineafter - Addresses parsed from the ELF, not hardcoded under PIE
- No real third-party targets used
Final summary#
pwntools is best when you treat it as glue: triage the binary, develop the exploit
against a local process() where you can debug, then switch one line to remote()
once it works. The fastest way to start is checksec ./chall followed by a short
script that sends cyclic(200) to find your offset. The most expensive mistake is
testing only remotely, where you cannot attach a debugger or read a core dump. Learn
a static reversing tool like Ghidra or radare2 next, since finding the bug is the half
of pwn that pwntools cannot do for you.