XOR Encoding
Understand XOR's role in CTF crypto — single-byte key recovery, repeating-key XOR, and the crib-drag attack.
// prerequisite reading
XOR in one line
A XOR B flips each bit where B is 1. The crucial property: (A XOR B) XOR B = A.
XOR is its own inverse — applying the same key twice returns the plaintext.
plaintext 0110 0001 'a'
key 0101 0101 'U'
ciphertext 0011 0100 '4'
Single-byte XOR
The ciphertext is XOR’d with one repeated byte (0x00–0xFF). Brute-force 256 possibilities and score each result by English letter frequency:
ct = bytes.fromhex("1b37373331363f78151b7f2b783431333d78397828372d363c78373e783a393b3736")
def score(s):
freq = 'etaoin shrdlu'
return sum(c in freq for c in s.lower().decode('latin-1', errors='ignore'))
best = max(range(256), key=lambda k: score(bytes(b ^ k for b in ct)))
print(bytes(b ^ best for b in ct))
Repeating-key XOR
The key is longer than one byte but shorter than the plaintext, so it repeats.
Attack:
- Find the key length — try lengths 2–40; compute the normalised Hamming distance between consecutive chunks. Minimum distance reveals the key length.
- Split into slices — collect bytes at positions 0, keylen, 2*keylen … (every byte XOR’d with key[0]), and so on.
- Solve each slice as single-byte XOR.
This is the Matasano/Cryptopals Set 1 Challenge 6 — do it once and you’ll always know how.
Crib-dragging (known-plaintext)
If you know or guess a fragment of the plaintext (a crib like flag{ or
the ), XOR the crib against the ciphertext at every offset. When the result
reads as printable text, you’ve found a hit — the key bytes at that position
fall out.
def crib_drag(ct: bytes, crib: bytes):
for i in range(len(ct) - len(crib)):
candidate = bytes(a ^ b for a, b in zip(ct[i:], crib))
if all(0x20 <= c < 0x7f for c in candidate[:len(crib)]):
print(f"offset {i}: {candidate[:len(crib)]}")
CyberChef shortcut
XOR operation → key type Hex, value 41 (your guessed key byte) → toggle
key bytes until the output is readable. Pair with XOR Brute Force for
single-byte keys.