March 25, 2026

Python for CTF: Essential Scripting Techniques

A practical guide to using Python in CTF competitions: automating tasks, handling binary data, decoding encodings, socket connections, and writing exploit scripts.

Introduction

If you do only one thing to improve your CTF performance, learn Python scripting. Nearly every CTF category -- crypto, forensics, pwn, web, reverse engineering -- eventually requires you to automate something: decode a stream of bytes, talk to a server, try 256 keys in a loop, or reassemble a scrambled file. Doing those things by hand is slow and error-prone. A ten-line Python script runs in milliseconds and never makes a typo.

Python is the lingua franca of CTF solving for good reason: its standard library covers binary I/O, encoding, sockets, and cryptographic primitives right out of the box, and the third-party pwntools library adds purpose-built helpers for talking to challenge servers. You do not need to be a software engineer to use it -- clear, imperative scripts are enough.

This guide walks through the most-used patterns in order: reading and writing binary files, encoding and decoding data, opening raw sockets, upgrading to pwntools, automating brute force loops, and a quick-reference card of one-liners you can copy directly into your terminal.

Prerequisites: Python 3.8 or later. Install it with sudo apt install python3 python3-pip on Debian/Ubuntu. All code here is Python 3 only -- do not use Python 2.

Reading and writing binary files

Many CTF files are not plain text. Images, compiled binaries, encrypted blobs, and network captures are all raw bytes. Python distinguishes between text mode ('r' / 'w') and binary mode ('rb' / 'wb'). Always open non-text files in binary mode or you will get encoding errors and corrupted data.

Reading a binary file

# Read the whole file into a bytes object
with open('challenge.bin', 'rb') as f:
data = f.read()
# Print the first 32 bytes as hex, two digits each
print(' '.join(f'{b:02x}' for b in data[:32]))
# Check the file's magic bytes (first 4 bytes)
print(data[:4]) # e.g. b'\x89PNG' for a PNG file

Unpacking structured binary data with struct

The struct module converts raw bytes into Python integers and floats using format strings. The prefix < means little-endian (x86 byte order), > means big-endian (network byte order).

import struct
with open('challenge.bin', 'rb') as f:
data = f.read()
# Unpack the first 4 bytes as a little-endian unsigned 32-bit integer
(value,) = struct.unpack('<I', data[:4])
print(f'First dword: {value} (0x{value:08x})')
# Unpack a header with multiple fields
magic, version, length = struct.unpack('<4sHI', data[:10])
print(magic, version, length)

Writing bytes back to a file

# Patch one byte and write the result out
patched = bytearray(data) # mutable copy
patched[0] = 0x89 # change first byte
with open('patched.bin', 'wb') as f:
f.write(patched)
Tip: Use bytearray when you need to mutate bytes in place. bytes objects are immutable -- you cannot assign to individual positions.

Encoding and decoding

Encoding and decoding is one of the most common tasks in CTF crypto and forensics challenges. Python's base64 module, hex literals, and XOR operations are all you need for the majority of cases.

Base64

import base64
# Decode a base64 string
encoded = 'cGljb0NURntweXRob25fcm9ja3N9'
decoded = base64.b64decode(encoded)
print(decoded) # b'picoCTF{...}'
print(decoded.decode()) # picoCTF{...}
# Encode bytes to base64
raw = b'hello CTF'
print(base64.b64encode(raw).decode()) # aGVsbG8gQ1RG
# URL-safe variant (replaces + and / with - and _)
print(base64.urlsafe_b64encode(raw).decode())

Hex encoding

# bytes -> hex string
data = b'\x70\x69\x63\x6f'
print(data.hex()) # 7069636f
print(data.hex(' ')) # 70 69 63 6f (space-separated)
# hex string -> bytes
raw = bytes.fromhex('7069636f')
print(raw) # b'pico'

XOR decryption

XOR is the single most common "encryption" in entry-level CTF challenges. A single-byte XOR key applied to every byte of ciphertext is trivially brute-forced -- only 256 possible keys.

ciphertext = bytes.fromhex('1b0f0d07171c1e5a1c0a5a071d1e5a0d1e04')
key = 0x7f # known or guessed key
# Decrypt by XORing every byte with the key
plaintext = bytes(b ^ key for b in ciphertext)
print(plaintext.decode(errors='replace'))

ROT / Caesar cipher in Python

Python's str.maketrans and str.translate make ROT-N trivial. The challenge picoCTF 2023 / rotation is a classic ROT cipher -- here is the script that solves the general case:

def rot(text: str, n: int) -> str:
uppers = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
lowers = 'abcdefghijklmnopqrstuvwxyz'
table = str.maketrans(uppers + lowers,
uppers[n:] + uppers[:n] +
lowers[n:] + lowers[:n])
return text.translate(table)
ciphertext = 'Vhfrpbzr gb gur ebgngvba punyyratr!'
# Try all 26 rotations
for n in range(26):
candidate = rot(ciphertext, n)
if 'picoCTF' in candidate or 'flag' in candidate.lower():
print(f'ROT-{n}: {candidate}')

Sockets: talking to challenge servers

Many picoCTF challenges give you a host and port -- nc challenge.picoctf.org 12345 -- and expect you to send crafted input and parse the response. Python's built-in socket module lets you do this programmatically, which is essential when the server sends a math problem you have to answer in under one second, or when you need to automate hundreds of requests.

Basic socket template

import socket
HOST = 'challenge.picoctf.org'
PORT = 12345
with socket.create_connection((HOST, PORT)) as s:
# Receive initial banner / prompt
banner = s.recv(4096)
print(banner.decode())
# Send a response (must end with newline)
s.sendall(b'hello\n')
# Receive the next message
response = s.recv(4096)
print(response.decode())

Receiving until a delimiter

Servers rarely send exactly one packet. Use a helper to accumulate data until you see a known delimiter like a prompt character or newline.

def recv_until(s: socket.socket, marker: bytes) -> bytes:
buf = b''
while marker not in buf:
chunk = s.recv(1024)
if not chunk:
break
buf += chunk
return buf
with socket.create_connection((HOST, PORT)) as s:
data = recv_until(s, b': ') # wait for a ': ' prompt
print(data.decode())
s.sendall(b'42\n')
result = recv_until(s, b'\n')
print(result.decode())

Challenges that use sockets

pwntools basics

Raw sockets work, but pwntools is dramatically nicer. It is a purpose-built CTF library that wraps sockets, processes, and binary packing into a clean API. Once you start using it you will rarely go back to raw sockets.

Install

pip install pwntools
# or inside a virtual environment:
python3 -m venv ctf-env && source ctf-env/bin/activate
pip install pwntools

Connecting to a remote service

from pwn import *
# Connect to a TCP service
r = remote('challenge.picoctf.org', 12345)
# Receive until a specific string appears
r.recvuntil(b'Enter your answer: ')
# Send a line (automatically appends \n)
r.sendline(b'42')
# Receive one line
flag_line = r.recvline()
print(flag_line)
r.close()

Binary packing and unpacking

For binary exploitation (pwn) challenges, you need to convert between integers and their little-endian byte representation. pwntools makes this one call.

from pwn import *
# Pack a 32-bit integer (little-endian by default)
payload = p32(0xdeadbeef) # b'\xef\xbe\xad\xde'
payload = p64(0xdeadbeef) # 64-bit version
# Unpack bytes back to an integer
val = u32(b'\xef\xbe\xad\xde') # 3735928559 == 0xdeadbeef
val = u64(b'\xef\xbe\xad\xde' + b'\x00' * 4)
# Set target architecture context (affects p32/p64 defaults)
context.arch = 'amd64'
context.log_level = 'debug' # print all send/recv traffic

Full exploit template

from pwn import *
r = remote('challenge.picoctf.org', 12345)
# Read the challenge prompt
prompt = r.recvuntil(b'> ')
log.info(prompt.decode())
# Build and send payload
payload = b'A' * 64 + p64(0x401337)
r.sendline(payload)
# Drop into an interactive shell if the server spawns one
r.interactive()
Why pwntools over raw sockets: recvuntil, recvline, and interactive() remove dozens of lines of buffer-management boilerplate. The debug logging (context.log_level = 'debug') prints every byte sent and received, which makes debugging far easier.

Automating repeated operations

The moment a challenge requires you to try more than a handful of values, write a loop. A classic example is single-byte XOR: the key is one of 256 possible byte values, and you can check all of them in milliseconds by testing whether the decrypted output looks like English text.

Brute-forcing a single-byte XOR key

ciphertext = bytes.fromhex(
'1b580b1e0f590b1c0f59131c0459151e1d590b1e0f'
)
def score(text: bytes) -> int:
"""Count printable ASCII characters as a quality heuristic."""
return sum(0x20 <= b < 0x7f for b in text)
best_key, best_plain, best_score = 0, b'', 0
for key in range(256): # try all 256 possible keys
candidate = bytes(b ^ key for b in ciphertext)
s = score(candidate)
if s > best_score:
best_score = s
best_key = key
best_plain = candidate
print(f'Key: 0x{best_key:02x}')
print(f'Plaintext: {best_plain}')

Automating a server interaction loop

Some challenges present 100 math problems and require all correct answers within a time limit. Here is the pattern:

from pwn import *
r = remote('challenge.picoctf.org', 12345)
for _ in range(100):
line = r.recvline().decode().strip()
# e.g. line = 'What is 347 + 891?'
nums = [int(x) for x in line.split() if x.lstrip('-').isdigit()]
answer = nums[0] + nums[1]
r.sendline(str(answer).encode())
print(r.recvall().decode())
Note: Always add a small amount of structure to your scripts: print what you receive before you parse it. This makes debugging much faster when the server sends something unexpected.

Quick reference

Copy-paste one-liners for the operations you reach for most often in a CTF session.

base64.b64decode(s)Easy

Decode a base64 string to bytes

bytes.fromhex('deadbeef')Easy

Convert a hex string to raw bytes

data.hex()Easy

Convert bytes to a hex string

bytes(b ^ key for b in data)Easy

XOR-decrypt every byte with a fixed key

struct.unpack('<I', data[:4])Medium

Unpack 4 bytes as little-endian uint32

p32(0xdeadbeef)Medium

Pack integer to 4-byte little-endian (pwntools)

r.recvuntil(b': ')Medium

Receive data until a delimiter (pwntools)

r.sendline(b'payload')Easy

Send bytes + newline to a remote (pwntools)

for key in range(256):Easy

Brute-force all single-byte XOR keys

Recommended workflow for a new Python CTF challenge

  1. Read the problem statement and identify the primitive: encoding, XOR, socket service, binary exploitation, or file manipulation.
  2. For encoding challenges, paste the ciphertext into a script and try base64.b64decode, bytes.fromhex, and ROT brute-force first.
  3. For socket services, start with a pwntools remote(), print every line you receive, then build the response logic.
  4. For binary files, open in 'rb' mode, print the first 16 bytes as hex to identify the format, then use struct to parse the header.
  5. Enable context.log_level = 'debug' in pwntools whenever you are confused about what the server is sending.