Athena Wiki

XOR Cryptography

cryptobeginner

Exploit XOR cipher weaknesses: single-byte key, multi-byte repeating key, and known plaintext attacks.

cryptoxorstream-cipherkey-recoveryknown-plaintext

XOR Cryptography

XOR is the foundation of many CTF crypto challenges - from trivial single-byte keys to more complex multi-byte repeating key ciphers (essentially a Vigenère cipher in binary). Understanding XOR properties is essential for any CTF player.

XOR Properties

AA=0(self-inverse)A0=A(identity)AB=BA(AB)C=A(BC)\begin{aligned} A \oplus A &= 0 \quad \text{(self-inverse)} \\ A \oplus 0 &= A \quad \text{(identity)} \\ A \oplus B &= B \oplus A \\ (A \oplus B) \oplus C &= A \oplus (B \oplus C) \end{aligned}

If C=PKC = P \oplus K, then P=CKP = C \oplus K and K=CPK = C \oplus P (known plaintext reveals the key).

Attack: Single-Byte XOR Brute Force

The key is a single byte (256 possibilities). Try all 256 and score by English letter frequency:

from collections import Counter

def score(text):
    freq = "etaoinshrdlu "
    return sum(c in freq.lower() for c in text.lower().decode(errors='ignore'))

def single_byte_xor_crack(ciphertext):
    best_score = 0
    best_key   = 0
    best_plain = b""

    for key in range(256):
        plain = bytes([b ^ key for b in ciphertext])
        s = score(plain)
        if s > best_score:
            best_score = s
            best_key   = key
            best_plain = plain

    return best_key, best_plain

ct = bytes.fromhex("1b37373331363f78151b7f2b783431333d78397828372d363c78373e783a393b3736")
key, plain = single_byte_xor_crack(ct)
print(f"Key: {hex(key)}{plain}")

Attack: Multi-Byte Repeating Key (Vigenère in Binary)

Step 1: Find Key Length (Kasiski / Index of Coincidence)

def hamming_distance(a, b):
    return bin(int.from_bytes(a, 'big') ^ int.from_bytes(b, 'big')).count('1')

def find_key_length(ciphertext, max_keylen=40):
    distances = {}
    for keylen in range(2, max_keylen + 1):
        # Average normalized Hamming distance between consecutive blocks
        chunks = [ciphertext[i:i+keylen] for i in range(0, len(ciphertext) - keylen, keylen)]
        dists = [hamming_distance(chunks[i], chunks[i+1]) / keylen
                 for i in range(min(4, len(chunks) - 1))]
        distances[keylen] = sum(dists) / len(dists)

    # Smallest distance = most likely key length
    return sorted(distances, key=distances.get)[:5]

keylens = find_key_length(ciphertext)
print("Likely key lengths:", keylens)

Step 2: Crack Each Byte of the Key

def crack_repeating_xor(ciphertext, keylen):
    key = b""
    for i in range(keylen):
        # Every keylen-th byte was encrypted with the same key byte
        block = bytes(ciphertext[j] for j in range(i, len(ciphertext), keylen))
        k, _ = single_byte_xor_crack(block)
        key += bytes([k])
    return key

key = crack_repeating_xor(ciphertext, keylens[0])
plaintext = bytes([c ^ key[i % len(key)] for i, c in enumerate(ciphertext)])
print(f"Key: {key}\nPlaintext: {plaintext}")

Attack: Known Plaintext

If you know (or can guess) any portion of the plaintext:

known_plain = b"flag{"
ciphertext  = bytes.fromhex("...")

# Recover the corresponding key bytes
key_fragment = bytes([p ^ c for p, c in zip(known_plain, ciphertext)])
print(f"Key starts with: {key_fragment}")

# If key is repeating, infer full key from fragment

In CTFs, the flag format itself (flag{) is 5 known bytes - use it!

Two-Time Pad: Reused Key

If the same XOR key is used to encrypt two messages:

C1=P1K,C2=P2KC1C2=P1P2\begin{aligned} C_1 &= P_1 \oplus K, \quad C_2 = P_2 \oplus K \\ C_1 \oplus C_2 &= P_1 \oplus P_2 \end{aligned}

Knowing either plaintext recovers the other:

c1 = bytes.fromhex("...")
c2 = bytes.fromhex("...")

xored = bytes([a ^ b for a, b in zip(c1, c2)])  # P1 xor P2 (bitwise)

# If P1 has known characters (spaces, lowercase letters)
# You can recover P2 statistically by crib-dragging

Crib Dragging

def crib_drag(ct1, ct2, crib):
    xored = bytes([a ^ b for a, b in zip(ct1, ct2)])
    for i in range(len(xored) - len(crib)):
        result = bytes([xored[i+j] ^ crib[j] for j in range(len(crib))])
        print(f"Offset {i:3d}: {result}")

# Try common cribs
crib_drag(c1, c2, b"the ")
crib_drag(c1, c2, b"flag{")
crib_drag(c1, c2, b" and ")

CyberChef XOR

For quick XOR in the browser:

  • CyberChef → XOR operation
  • Input: your ciphertext (hex)
  • Key: your key (hex or UTF-8)
  • Scheme: Standard (or cycling for repeating key)

Checklist

  • Single byte? → Brute force all 256 keys, score by English frequency
  • Multi-byte? → Find key length via Hamming distance, then break each byte
  • Know part of plaintext? → Known-plaintext recovery: K=CPK = C \oplus P
  • Two ciphertexts, same key? → XOR them together, crib-drag
  • Key starts repeating visually? → Determine period and attack each position

Last updated on

On this page