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.