Pwntools for Beginners: First Exploit for CTF

Depov

Moderator
Staff member
MODERATOR
ULTIMATE
SUPREME
PREMIUM
MEMBER
Joined
Feb 18, 2025
Messages
464
Reaction score
738
Deposit
0$
Why pwntools if there is pure Python and netcat

You can solve pwn-tasks without pwntools. Opening the terminal, connecting through nc, you drive the data, you watch the crash. Problems start when the task requires a little more than entering text from the keyboard.

It is impossible to transmit non-print bytes - zero symbols, addresses from the upper half of the address space - through the usual input. It is necessary to town structures like echo -e '\x41\x41...\xef\xbe\xad\xde' | ./vuln. Uncomfortable, and every payload edit is a typosed lottery.

Finding an exact shift to a return address manually is a dumb way: 10 characters 'A', then 20, 30 until the program drops. Then binary search you specify a specific byte. Through the cyclic patterns of pwntools it is one team.

Switching between local testing and a remote CTF server without pwntools is script rewriting. In pwntools — replacement of one line: process on remote.

Packing addresses in a little-endian manually is a routine that is easy to make a mistake. p64 and p32 do it automatically.

Connecting GDB to the process directly from the exploit script – without pwntools, it’s a separate quest with gdbserver and manual adjustment.

Pwntools was created by the team Gallopsled (finalists of DEF CON CTF) and de facto became the standard for the development of exploits in Python in the pwn category. Virtually every writeup from the PicoCTF, HackTheBox CTF or DEF CON Quals competition uses this library. As the analysis of PicoCTF 2021 (heartburn.dev) tasks shows, even format string exploits and ret2libc chains are built through pwntools - from connection to the server to parsing leaks from memory. The library removes the routine, and time goes to analyze the vulnerability, not to convert hex values in the terminal.
Installation of pwntools and preparation of the environment

Pwntools works on Linux. The main platform is the 64-bit Ubuntu LTS (22.04 and 24.04). On Windows – WSL2, on macOS – with limitations (part of the functions associated with ELF and GDB, simply does not work). According to the official documentation, version 5.0.0 requires Python 3.10 or newer. Python 2 support is completely removed starting with v5.

Installation in two steps: first sudo apt-get update && sudo apt-get install python3 python3-pip python3-dev git libssl-dev libffi-dev build-essential -y, then python3 -m pip install --upgrade pwntools. Verification – pwn version in the terminal must return the version number. If the command is not found, add ~/.local/bin in $PATH (pwntools itself warns about it when installing without sudo).

Debugger: sudo apt-get install gdb. Naked GDB is suitable for basic work, but the pwndbg plugin (GEF) adds a visualization of the stack, registers and disassembler at each stop. For GDB, debugging binary in the context of CTF is critical – without a visual state of the stack, it is more difficult to understand the behavior of buffer overflow. I started with a naked GDB and wasted a lot of time on x/40wx $rsp by hand. With pwndbg, the stack is drawn automatically at each si.

The first line after import is to configure the context: context(arch='amd64', os='linux'). It sets the target architecture and influences the behavior of all functions: p64 will pack in 8 bytes, cyclic – generate patterns with 8-byte substrings, shellcraft – create code under x86-64. Without explicitly specifying the context, pwntools will try to guess the architecture from the loaded ELF, but relying on automation is an idea.

Another useful team: pwn template ./binary > exploit.py. Generates a script template with custom context, local/remote switching, and a workpiece for payload. Saves a couple of minutes on each task and learns to a uniform structure.
Disassembling pwn-task: buffer overflow with ret2win

Classic binary operation scenario: the program accepts custom input through a vulnerable function (gets or scanf without length limitation), and in the code hidden function win, which takes the flag. The task is to overwrite the return address on the stack so that instead of a normal completion, the program jumps into win. This pattern is called ret2win and is found in every second task for beginners on PicoCTF, pwnable.kr, and similar platforms. The ideal starting point to understand, How to solve pwn-tasks in practice.
Binary exploration – checksec and analysis

Before you write an exploit for a CTF task, you need to understand what you are dealing with. Two teams give a basic picture.

file ./vuln will show the architecture (ELF 64-bit LSB executable, x86-64), the type of linking (dynamically linked) and the presence of symbols (not stripped - function names are available, which simplifies life).

checksec ./vuln – utility from the pwntools kit – displays the protective mechanisms of the binary. What to look at:

NX (No eXecute) - if enabled, the stack is not executable. Shellcode on the stack will not work, you need ret2win or ROP chains.

Stack Canary – if found, between the buffer and return address lies “canary”. Overwriting of the return address without prior leakage of the channel value will lead to an emergency completion through __stack_chk_fail.

PIE (Position Independent Executable) – if the base address of the binary is randomized at each start, the addresses from the character table will be relative.

RELRO is the level of protection of the GOT table: Partial or Full.

For the typical “first buffer overflow” level task, the configuration is: NX enabled, No canary found, No PIE, Partial RELRO. Transfer: shellcode on the stack will not go, but the addresses of the functions are fixed and the return address can be overwritten directly. Almost a gift.

In pwntools script elf = ELF('./vuln') automatically output checksec and spart the symbol table. Target function address — elf.symbols['win']. No hand-picking through objdump.
Search for offset through cyclic patterns

Key question when writing overflow exploit: How many bytes do you need to write to get to the return address? The manual approach is the same: 10 characters 'A', 20, 30, 50 - until the program falls with a secopholt. Then binary search specify the exact byte. Slowly, inaccurately, infuriating.

Pwntools uses a de-Brein sequence — a special pattern in which each substring of 4 (or 8 for amd64) bytes is unique. cyclic(200) generates 200 bytes of this pattern. You send it to the program - it falls, and in the RSP register there is a fragment of the pattern. cyclic_find(value) on this fragment instantly calculates the exact displacement.

from pwn import *
context(arch='amd64')
p = process('./vuln')
p.sendline(cyclic(200))
p.wait()
offset = cyclic_find(0x6161616c)
log.success(f': {offset}')

cyclic(200) Creates a 200-byte pattern. After the collapse, the RSP value is viewed through GDB with pwndbg (command cyclic -l $rsp does the same thing that cyclic_find in Python). If the RSP contains 0x6161616c – challenge cyclic_find(0x6161616c) He'll return, let's say 40. This means: 40 bytes fill in the buffer and the saved RBP, and starting from the 41st byte there is an overwriting of the return address. The manual selection would take ten to fifteen iterations. The Cyclic pattern gives an accurate response on the first attempt.
Collecting the exploit on pwntools — line-by-line disassembly

Displacement found (40 bytes), target function address is available via elf.symbols['win']. We collect working buffer overflow exploit in Python:

from pwn import *
context(arch='amd64', os='linux')
elf = ELF('./vuln')
p = process('./vuln')
payload = b'A' * 40
payload += p64(elf.symbols['win'])
p.sendline(payload)
p.interactive()

Lay down.

from pwn import * – import of the entire pwntools library. For exploits, this is standard practice, although this is not done in the production code.

context(arch='amd64', os='linux') Target architecture. Affects p64 (will pack in 8 bytes) and other functions.

elf = ELF('./vuln') – loading the binary. Parsit ELF headers, sections, symbols table. Automatically outputs checksec.

process('./vuln') – starting the local process. For a remote server, it changes to remote('host', port) – one line, the rest of the script unchanged.

b'A' * 40 – padding to return address. The specific symbol of the role does not play, the length is important.

p64(elf.symbols['win']) – function address packaging win in 8 bytes little-endian. Address 0x00401186 will turn into \x86\x11\x40\x00\x00\x00\x00\x00. Without pwntools, you would have to manually turn over each byte – and make a mistake on the third.

sendline(payload) – sending a payload with the symbol of the new line.

interactive() – switching to manual mode. If the exploit worked and win brought out the flag - it will appear on the screen. If win opens shell - you can enter commands.

Eight lines. From "I don't know the shift" to "the flag is received" - three minutes.
Writing Python Exploit: Key Pwntools Features
Input-output — process, remote and send/recv options

Pwntools abstracts the interaction with the target through a single interface (tube object). Two main entry points: process('./binary') – local start for debugging; remote('host', port) – connection to the CTF server. Both support the same methods, so when moving from local testing to a remote attack, one line changes.

In practice, switching is made out through an argument check: launched with REMOTE – connect to the server, otherwise we work locally. Template pwn template generates this code automatically.

Methods of sending: send(data) raw bytes without adding anything. sendline(data) – adds \n, analogue Enter. sendafter(delim, data) – waits for the process to output the line delim, and only then sends. It is indispensable when the program outputs the prompt before waiting for input.

Methods of reception: recv(n) Reading to n Byte. recvline() – to the symbol of the new line. recvuntil(delim) – to the specified divider (used most often). recvall() – until the connection is closed.

A typical template from PicoCTF's writeup (heartburn.dev): p.recvuntil(b'Enter name: ') waiting for the prompt, then p.sendline(payload) sends the exploit. This pattern works in the vast majority of pwn tasks.

For debugging data exchange: context.log_level = 'debug'. Pwntools will withdraw each submitted and accepted byte. When the script “hangs” and it is unclear why – the debug log usually shows that the program is waiting for input, and we are waiting for the output. Classic mutual blocking.
Address packing — p64, p32 and little-endian

Architectures x86 and x86-64 store data in little-endian: Junior byte at junior address. Number 0xDEADBEEF in memory looks like \xef\xbe\xad\xde. Manual byte conversion is a source of errors, especially under the pressure of the timer on the CTF.

p32(value) – 4 bytes of little-endian for 32-bit binary. p64(value) – 8 bytes for amd64, the main function in modern tasks. Reverse Operations: u32(data) and u64(data) – unpacking the byte string into a number. Useful when reading the leaked addresses from the output of the program (leak libc base address for ret2libc).

Choice between p32 and p64 determined by the architecture of the binary. The mistake here is one of the most frequent in beginners: p32 on the 64-bit binary generates a 4-byte address instead of the 8-byte, payload is shorter than the desired and the displacement “float”. I myself stepped on it - spent half an hour on debugging, until I noticed that the context was worth i386 on the amd64-binary.

To work with the ELF symbol table: elf.symbols['main'] – address main, elf.got['puts'] – entry in GOT, elf.plt['puts'] – PLT-stub address. These addresses are critical for advanced techniques. Class ROP automatically finds gadgets (ret, pop rdi; ret and others) in binary - the basis for building ROP chains, when a simple ret2win is not enough.
GDB debugging binary through pwntools

When the exploit does not work – and this happens more often than you would like – you need debugging. Instead of process('./vuln') use gdb.debug('./vuln'). The GDB window connected to the running process will open. Put breakpoint before a vulnerable function, step by step follow the instructions, see the status of the stack and registers at the time of overwriting.

For automatic breakpoints when starting: gdb.debug('./vuln', 'break main\ncontinue'). Saves time when it is iterative debugging, when you need to stop at one point and check payload. On the third iteration without it you start to go crazy.

Useful technique - tracing system calls without a complete reverse. As welchbj notes in the CTF directory on GitHub, you can run the process through strace: io = process(["strace", "-o", "trace.txt", "-f", "./vuln"]). File trace.txt will show each syscall - what files the program opens, what functions libc causes, where exactly falls.

The pwndbg plugin adds teams to work with cyclic patterns right in the debugger: cyclic 200 generates a pattern, cyclic -l $rsp finds a shift in the value of the register. The result is identical cyclic() and cyclic_find() in Python, two paths to one goal.

When debugging remote tasks, GDB cannot be connected directly. The scheme is two-step: first, debug the exploit locally (through process and gdb.debug), then switch to remote. The pwninit utility (mentioned in The Pwner's Roadmap, izzy.sh) solves the problem of libc mismatch between the local machine and the server - the binary to work with the server version of the library.
Typical errors in solving pwn-tasks

Incorrect architecture in context. Binarnik 32-bit, and context.arch Worth it amd64 - p64 generates 8-byte addresses instead of 4-byte addresses. Payload is longer than expected, everything breaks. Rule: Always check architecture through file ./binary before writing the script.

Confusion between send and sendline. sendline adds \n – one extra byte. If payload is designed to byte (which is typical for overflow overflow exploits in Python), this extra symbol shifts the data. For precise control — send, eh sendline only when the program is waiting for the Enter to be completed.

Discrepancy of libc versions. The exploit with ret2libc works locally (glibc 2.39) but falls on the server (glibc 2.31). Addresses system, lines /bin/sh different between versions. As The Pwner's Roadmap (izzy.sh) emphasizes, "this is the biggest headache of beginners when moving to remote operation." The solution is the pwninit utility: downloads the desired linker and patches the binary to work with the target libc.

Unlevelled stack on amd64. In 64-bit systems, many libc functions (including system) require the RSP to be aligned by 16 bytes. Disrupted alignment – the process drops from SIGSEGV to instructions movaps, although the return address is overwritten correctly. GDB shows the correct RIP, but the function is painted on the first SIMD instruction. This is a trap that everyone is coming. Solution: add a gadget ret before the target function address is one ret shifts the RSP to 8 bytes and restores the alignment.

EOFERror at recvuntil. The process closed before the pwntools received the expected line. Reasons: payload incorrectly completed the process, the input format does not coincide with the expected, the timeout worked. Include context.log_level = 'debug' - usually the cause becomes obvious in a minute.

Null-byte in the middle of payload. If the target address contains 0x00 inside (not at the end), functions like gets and strcpy cut the input on the null-byte. For simple ret2win, this is not usually a problem (null-bytes go to senior bytes, that is, at the end of payload), but for ROP chains with multiple addresses can become a blocker. Then you need partial overwrite or gadgets without null-byte.
 
Top Bottom