~/blog/xor-is-not-encryption.md

XOR Is Not Encryption (and Why That Matters)

· #security #cryptography

I once got a bug report that said, verbatim, “the file is encrypted, please add decryption support.” The file was 40KB of what looked like noise. Three minutes with a hex editor and a guess later, it decoded cleanly against a five-byte repeating key. It wasn’t encrypted. It was data[i] ^ key[i % len(key)], and someone upstream had called that encryption in a design doc, and the word stuck.

This happens constantly in reverse-engineering and scraping work. XOR shows up everywhere because it’s trivial to implement, symmetric for free, and it genuinely destroys the visual structure of data. But “looks random” and “is cryptographically secure” are different claims, and conflating them is how people end up shipping obfuscation with a security label on it.

Why XOR looks like encryption

XOR against a keystream is, structurally, exactly what a real stream cipher does. ChaCha20 generates a keystream from a key and nonce, then XORs it against the plaintext. The operation is identical to a toy XOR cipher. The difference is entirely in how the keystream gets made.

# this is the entire "cipher"
def xor_cipher(data: bytes, key: bytes) -> bytes:
    return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))

Run that on a paragraph of English text with a short key and eyeball the output — it looks like garbage. Compute the entropy and it reads close to 8 bits/byte for anything longer than a few key-lengths. By a naive “does it look random” test, it passes. That’s exactly why it keeps fooling people, including engineers who should know better.

Where it falls apart

A real cipher’s security rests on the keystream being computationally indistinguishable from random without the key. A repeating XOR key fails that in one specific, brutal way: the keystream repeats, and repetition is detectable without ever guessing the key.

The classic break is Kasiski examination, older than computers: find repeated substrings in the ciphertext, measure the distance between repeats, and the key length falls out of the GCD of those distances. Once you have the length, each byte position becomes an independent single-byte XOR — and single-byte XOR against English text is a solved problem, because English has a fixed letter-frequency fingerprint.

def guess_single_byte_key(chunk: bytes) -> int:
    # score each candidate key by how "English" the decoded chunk looks
    best_key, best_score = 0, -1
    common = set(b"etaoin shrdlu ETAOIN")
    for key in range(256):
        plain = bytes(b ^ key for b in chunk)
        score = sum(c in common for c in plain)
        if score > best_score:
            best_key, best_score = key, score
    return best_key

Ten minutes of this against a file with a repeating key, and you’ve recovered the key without touching the “encryption” algorithm at all. Contrast that with AES: there is no length to find, no letter-frequency leak, no known-plaintext shortcut that scales — breaking it means brute-forcing a 128-bit or 256-bit space, which isn’t happening on anyone’s laptop.

The known-plaintext shortcut

Even against a non-repeating keystream — say, one generated by a PRNG seeded per file, a step up from a fixed key — XOR has a second weak point: if you know any plaintext, you get that slice of keystream for free.

keystream = ciphertext XOR known_plaintext

File formats leak plaintext constantly. An MP4 starts with a predictable ftyp box. A JSON response starts with {". A BMP starts with BM. Feed the algorithm a file of a known type, XOR the header against what you expect it to be, and you’ve recovered enough keystream to start reversing whatever generates it. This is exactly how you’d start pulling apart a per-file PRNG-seeded scheme — not by attacking the cipher, but by exploiting the fact that XOR leaks keystream to anyone holding a plaintext guess.

What “not encryption” actually means for you

None of this means XOR is useless. It’s a fine, fast building block — real stream ciphers use it as their final step. The mistake is treating XOR by itself, with a static or short key, as a security boundary. That is not confidentiality. It is obfuscation: it stops a casual look, and it stops nothing else.

The practical test I use: if changing one byte of the “key” would make the scheme trivially breakable by a script I could write in twenty minutes, it was never a cipher. It was formatting. Call it that, and budget your threat model accordingly — because whoever’s on the other end of your API, reading the same file you are, will call it that too, whether the docs say “encrypted” or not.

cd .. cd ~ (back to terminal)