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

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.binary or context.arch, context.os, and context.bits once and the rest of the library adapts (packing width, assembler target, and more).
  • Packing converts integers to bytes and back: p32/p64 to pack, u32/u64 to unpack, in the byte order context implies.
  • 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_PORT and 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.

OptionMeaningWhen to use itExample
context.binarySet arch/os/bits from a fileStart of every scriptcontext.binary = './chall'
context.log_levelControl verbosityQuiet or debug a scriptcontext.log_level = 'debug'
process()Run a local binaryLocal testingio = process('./chall')
remote()Connect to a serviceSolve a remote challengeio = remote(TARGET_IP, TARGET_PORT)
p64() / u64()Pack / unpack 64-bitBuild or read addressesp64(0x401234)
cyclic()De Bruijn patternFind a crash offsetcyclic(200)
cyclic_find()Locate offset in patternTurn a leak into an offsetcyclic_find(0x6161616c)
ELF()Parse a binaryRead symbols, GOT, PLTe = ELF('./chall')
ROP()Build a ROP chainChain gadgetsrop = ROP(e)
checksecShow binary mitigationsTriage a new binarychecksec ./chall
cyclic (CLI)Pattern from the shellQuick offset workcyclic 200
asm (CLI)Assemble from the shellQuick shellcode bytesasm '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_find and corefile recover 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 a str raises an error or sends the wrong thing.
  • Forgetting to set context.binary or context.arch, so p64, asm, and ROP use the wrong word size or architecture.
  • Using sendline when the program reads a fixed number of bytes, so the trailing newline shifts everything.
  • Reading with recv and racing the program, instead of recvuntil / 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#

ProblemLikely causeFix
No module named 'pwn'Not installed / wrong venvReinstall; activate the right virtualenv
command not found: pwnCLI not on PATHAdd ~/.local/bin; activate the venv
Script hangs on recvWaiting for input or over-readingUse recvuntil and add timeout=
EOFErrorProcess/connection closedExpected on a crash; check the payload otherwise
Wrong packing widthcontext not setSet context.binary or context.bits
Addresses change each runPIE enabledLeak an address at runtime, do not hardcode
Exploit works local, not remoteDifferent libc/versionUse the provided libc; rebuild offsets
asm fails or wrong bytesArchitecture not setSet context.arch before asm
Results differ from a writeupVersion/flag changesCheck 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 socket plus struct in 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’ ROP covers 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 print statements 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 pwnlib package is the internal module name; you still import the public API as pwn. 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 ./chall first.
  • Need the crash offset: send cyclic(N), then cyclic_find on 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 recv to recvuntil and add a timeout=.
  • Need a quick byte or offset without a script: use the cyclic, asm, or checksec CLI tools.

Final checklist#

  • Scope and authorization noted next to the solve script
  • Binary triaged with checksec before writing the exploit
  • Developed and tested locally before pointing at a remote
  • context.binary or context.arch set 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.

Find us elsewhere

Merch, stickers, and moreSupport the work at the Solvere Labs shop