XOR Cryptography

intermediate30 minWriteup

Breaking XOR-based encryption

Learning Objectives

  • Understand XOR properties
  • Recover single-byte keys
  • Break repeating key XOR
  • Find patterns in ciphertext

XOR encryption is simple but extremely common in CTFs. It's based on the XOR operation's special property: A ⊕ B ⊕ B = A. This means decryption is the same as encryption!

XOR is the foundation of many encryption schemes. Understanding its properties and weaknesses is essential for CTF crypto challenges.

XOR Basics

1606070;"># XOR Truth Table:
2606070;"># 00 = 0
3606070;"># 01 = 1
4606070;"># 10 = 1
5606070;"># 11 = 0
6 
7606070;"># Key Properties:
8606070;"># A ⊕ A = 0 (anything XOR itself = 0)
9606070;"># A ⊕ 0 = A (anything XOR 0 = itself)
10606070;"># A ⊕ B = B ⊕ A (commutative)
11606070;"># A ⊕ B ⊕ B = A (self-inverse: XOR twice = original)
12 
13606070;"># This means:
14606070;"># plaintext ⊕ key = ciphertext
15606070;"># ciphertext ⊕ key = plaintext
16 
17606070;"># Example:
18606070;"># "A" = 0x41 = 01000001
19606070;"># key = 0x20 = 00100000
20606070;"># XOR: 01100001 = 0x61 = "a"
21 
22606070;"># XOR "A" with 0x20, get "a"
23606070;"># XOR "a" with 0x20, get "A"

Single Byte Key Attack

python
1606070;"># If ciphertext is XORed with a single byte, brute force all 256 values
2 
3def single_byte_xor(ciphertext, key):
4 return bytes([b ^ key for b in ciphertext])
5 
6def brute_force_single_byte(ciphertext):
7 for key in range(256):
8 plaintext = single_byte_xor(ciphertext, key)
9 try:
10 decoded = plaintext.decode(606070;">#a5d6ff;">'ascii')
11 606070;"># Check if result looks like text
12 if decoded.isprintable():
13 print(f606070;">#a5d6ff;">"Key {key} (0x{key:02x}): {decoded}")
14 except:
15 pass
16 
17606070;"># Example usage:
18cipher = bytes.fromhex(606070;">#a5d6ff;">"1b37373331363f78151b7f2b783431333d")
19brute_force_single_byte(cipher)
20 
21606070;"># CyberChef: "XOR Brute Force" operation

Scoring Results

Add letter frequency scoring to automatically identify the most likely English plaintext. The most readable result is usually correct!

Known Plaintext Attack

python
1606070;"># If you know part of the plaintext, recover the key!
2606070;"># plaintext ⊕ key = ciphertext
3606070;"># Therefore: plaintext ⊕ ciphertext = key
4 
5def xor_bytes(a, b):
6 return bytes([x ^ y for x, y in zip(a, b)])
7 
8ciphertext = bytes.fromhex(606070;">#a5d6ff;">"...")
9known_plaintext = b606070;">#a5d6ff;">"flag{" # We know flags start with this!
10 
11606070;"># Recover key bytes for known portion
12key_fragment = xor_bytes(ciphertext[:5], known_plaintext)
13print(f606070;">#a5d6ff;">"Key fragment: {key_fragment}")
14 
15606070;"># If key repeats, use this fragment to find key length
16606070;"># and decrypt more of the message

Repeating Key XOR

python
1606070;"># Encryption with repeating key
2def repeating_key_xor(plaintext, key):
3 result = []
4 for i, byte in enumerate(plaintext):
5 result.append(byte ^ key[i % len(key)])
6 return bytes(result)
7 
8606070;"># Breaking repeating key XOR:
9606070;"># 1. Find key length
10606070;"># 2. Split cipher into groups by key position
11606070;"># 3. Each group is single-byte XOR - brute force each
12 
13606070;"># Finding key length with Hamming distance:
14def hamming_distance(a, b):
15 return sum(bin(x ^ y).count(606070;">#a5d6ff;">'1') for x, y in zip(a, b))
16 
17def find_key_length(cipher, max_len=40):
18 scores = []
19 for keylen in range(2, max_len):
20 chunks = [cipher[i:i+keylen] for i in range(0, len(cipher), keylen)]
21 if len(chunks) < 4:
22 continue
23 606070;"># Compare first chunks and average Hamming distance
24 distances = []
25 for i in range(min(4, len(chunks)-1)):
26 d = hamming_distance(chunks[i], chunks[i+1])
27 distances.append(d / keylen) 606070;"># Normalize
28 scores.append((keylen, sum(distances)/len(distances)))
29 
30 606070;"># Lower score = more likely key length
31 scores.sort(key=lambda x: x[1])
32 return scores[:5] 606070;"># Return top 5 candidates

Complete Repeating Key Attack

python
1606070;">#!/usr/bin/env python3
2606070;"># Complete attack on repeating-key XOR
3 
4from collections import Counter
5 
6def score_english(text):
7 606070;">#a5d6ff;">"""Score text based on English letter frequency"""
8 freq = 606070;">#a5d6ff;">'etaoinshrdlcumwfgypbvkjxqz'
9 score = 0
10 for c in text.lower():
11 if c in freq:
12 score += len(freq) - freq.index(c)
13 return score
14 
15def single_byte_xor_decrypt(cipher):
16 606070;">#a5d6ff;">"""Try all single-byte keys, return best result"""
17 best_score = 0
18 best_result = None
19 best_key = None
20 
21 for key in range(256):
22 plaintext = bytes([b ^ key for b in cipher])
23 try:
24 decoded = plaintext.decode(606070;">#a5d6ff;">'ascii')
25 score = score_english(decoded)
26 if score > best_score:
27 best_score = score
28 best_result = decoded
29 best_key = key
30 except:
31 pass
32 
33 return best_key, best_result
34 
35def break_repeating_xor(cipher, key_length):
36 606070;">#a5d6ff;">"""Break repeating XOR with known key length"""
37 606070;"># Split into columns
38 columns = [[] for _ in range(key_length)]
39 for i, byte in enumerate(cipher):
40 columns[i % key_length].append(byte)
41 
42 606070;"># Solve each column as single-byte XOR
43 key = []
44 for col in columns:
45 k, _ = single_byte_xor_decrypt(bytes(col))
46 key.append(k)
47 
48 606070;"># Decrypt with recovered key
49 key = bytes(key)
50 plaintext = bytes([c ^ key[i % len(key)] for i, c in enumerate(cipher)])
51 
52 return key, plaintext
53 
54606070;"># Usage:
55cipher = bytes.fromhex(606070;">#a5d6ff;">"...")
56key_lengths = find_key_length(cipher)
57for kl, _ in key_lengths:
58 key, plain = break_repeating_xor(cipher, kl)
59 print(f606070;">#a5d6ff;">"Key length {kl}: key={key}, plain={plain[:50]}...")

CTF XOR Tricks

python
1606070;"># Trick 1: XOR with itself reveals nothing
2606070;"># But XOR two ciphertexts encrypted with same key:
3606070;"># c1 ⊕ c2 = (p1 ⊕ key) ⊕ (p2 ⊕ key) = p1 ⊕ p2
4606070;"># Key cancels out! Now you have XOR of plaintexts.
5 
6606070;"># Trick 2: Flag format known
7606070;"># If flag is flag{...}, XOR cipher with "flag{" to get key start
8 
9606070;"># Trick 3: Null bytes in key
10606070;"># If plaintext has patterns (like spaces), key reveals itself
11606070;"># plaintext: "HELLO WORLD"
12606070;"># If encrypted and has obvious pattern, XOR with common char
13 
14606070;"># Trick 4: CyberChef shortcuts
15606070;"># - XOR: Specify key in hex, UTF8, or decimal
16606070;"># - XOR Brute Force: Try all single bytes
17606070;"># - Guess the key from context clues
python
1606070;"># Common attack: Two plaintexts, same key (many-time pad)
2606070;"># c1 = p1 ⊕ key
3606070;"># c2 = p2 ⊕ key
4606070;"># c1 ⊕ c2 = p1 ⊕ p2
5 
6606070;"># If we guess p1[i] = space (0x20):
7606070;"># c1[i] ⊕ 0x20 = key[i]
8606070;"># Then: c2[i] ⊕ key[i] = p2[i]
9 
10606070;"># Tool: mtp (many-time pad attack)
11606070;"># https://github.com/CameronLonsworthy/mtp

XOR Tools

bash
1606070;"># CyberChef Operations:
2606070;"># - XOR (with known key)
3606070;"># - XOR Brute Force (single byte)
4606070;"># - Guess the key
5 
6606070;"># xortool - Automated analysis
7pip install xortool
8 
9xortool cipher.bin
10606070;"># Suggests key lengths and attempts decryption
11 
12xortool -l 5 cipher.bin 606070;"># Force key length 5
13xortool -c 20 cipher.bin 606070;"># Assume most common char is space (0x20)
14 
15606070;"># Python one-liner XOR
16python3 -c 606070;">#a5d6ff;">"print(bytes([a^b for a,b in zip(open('cipher','rb').read(), b'KEY'*1000)])[:100])"
17 
18606070;"># xxd for hex manipulation
19xxd -p cipher.bin | tr -d 606070;">#a5d6ff;">'\n' # Hex dump

Knowledge Check

Quick Quiz
Question 1 of 2

Why is XOR used for encryption?

Key Takeaways

  • XOR is self-inverse: encrypt and decrypt are the same operation
  • Single-byte XOR: brute force all 256 keys
  • Known plaintext: XOR with cipher to recover key
  • Repeating key: find length, then solve each position separately
  • Two-time pad: XOR ciphertexts to eliminate key
  • xortool automates most XOR analysis