A Lock Drawn on a Door
XOR obfuscation: why something that looks encrypted, and something that is, are not the same thing.
There’s a difference between a lock and a picture of a lock. A picture of a lock hangs on the door, brass-colored and convincing, and it will turn away anyone who glances at the door and moves on. It stops nobody who reaches out and pushes. XOR obfuscation, the kind you meet in CTFs, in malware config blobs, in the operational tradecraft of people who like the aesthetics of secrecy more than the math of it, is a picture of a lock. It looks exactly like encryption. It is not encryption. The whole skill is learning to tell the drawing from the hardware at a glance.
Encoding is not encryption
Start with the thing people confuse first, because everything downstream depends on getting it right.
When Cade hits the first Dark Cypher relay, the challenge is a short hex string. He runs it through a converter and out falls a passphrase:
$ echo "5f71 6b33 7235 5f74 306c 645f 796f 755f 736f 6d33 7468 316e 675f" | xxd -r -p
_qk3r5_t0ld_you_som3th1ng_
Nothing was decrypted there. Hex, like Base64, is an encoding: a reversible mapping between representations of the same bytes. It takes no key, hides nothing, and anyone who recognizes the format can reverse it. xxd -r -p reverses a plain hex dump; a browser tab of CyberChef does the same with “From Hex.” Encoding exists to make bytes survive a transport that only likes printable text. That is its entire job. If a system’s only defense is that the data is Base64’d or hex-encoded, the data is not protected. It is wearing a disguise that comes off the instant you look at it.
Encryption is the other thing: a keyed, ideally-irreversible-without-the-key transformation. The dividing line is the key. No key, no encryption. Just a costume change.
XOR, and the one property that matters
XOR (exclusive or) compares two bits and returns 1 when they differ. Its cryptographic charm is a single algebraic fact:
A ^ B ^ B = A
XOR is its own inverse. Encrypt by XORing plaintext with a key; decrypt with the identical operation: XOR the ciphertext with the same key and the key cancels itself out ((P ^ K) ^ K = P ^ 0 = P). One operation, one key, both directions. That symmetry is why XOR is genuinely everywhere in real cryptography: it is the mixing step inside stream ciphers, and it is AES’s AddRoundKey. As a primitive, XOR is load-bearing and respectable.
The trouble is never XOR. The trouble is the key.
Single-byte XOR
The simplest misuse is a single-byte key: XOR every byte of the message against one constant. When Cade’s relay hands back a key and tells him the next hop is “encrypted,” this is the shape of it: elegant operational obfuscation, not cryptography.
def xor(data, key):
return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
ct = bytes.fromhex("141f020e60283f363b23776a68")
print(xor(ct, b"\x5a").decode()) # -> NEXT:relay-02
A single-byte key has 256 possible values. You do not need the key. You loop through all 256, XOR the whole ciphertext against each, and score each result for how much it looks like English: letter frequencies, the dominance of the space character, printable-ASCII ratio. The candidate that scores highest is almost always the plaintext. That is a brute-force space you exhaust in microseconds. Calling it encryption is generous.
Repeating-key XOR is Vigenere in a hex costume
The obvious “fix” is a longer key, repeated across the message: cycle the key bytes and wrap around. This is repeating-key XOR, and it is exactly the classical Vigenere cipher moved from letters to bytes. The site’s own ARG does this: 0a0a17054e0b1b011e XOR the key novacoin decodes to dead-drop.
ct = bytes.fromhex("0a040a1f4e111f185800010d13124e4942460111")
key = b"darkcypher"
print(xor(ct, key).decode()) # -> next-hop=relay-02.dc
It looks much stronger. A ten-byte key has 2^80 values; you cannot brute-force that. But you never brute-force the whole key, and that is the point people miss. This is Cryptopals Set 1, Challenge 6, and the attack is a small, reliable pipeline that recovers the key without guessing it:
-
Find the key length. For a candidate KEYSIZE, take chunks of that many bytes and measure the normalized Hamming distance between them, the count of differing bits, divided by KEYSIZE. Here’s why it works: two chunks of English XORed with the same keystream cancel that keystream when compared, so at the true key length the ciphertext chunks resemble each other (low bit-difference) far more than at a wrong length. Average a few chunk-pairs per candidate; the KEYSIZE with the smallest normalized distance is your key length. (The canonical sanity check: the Hamming distance between
this is a testandwokka wokka!!!is exactly 37.) -
Transpose. Break the ciphertext into KEYSIZE-length blocks and regroup by position: all the bytes encrypted by key byte 0 into one column, all the bytes by key byte 1 into the next, and so on.
-
Solve each column as single-byte XOR. Every column was encrypted with one repeating key byte, so each column is a single-byte XOR problem. Run the 256-key frequency attack on each column independently. Concatenate the winning bytes and you have the whole key.
A long repeating key does not multiply the work. It just gives you N independent single-byte problems, each of which you already know how to crush. Longer key, same afternoon.
So is XOR ever OK?
Yes, with precision about which XOR you mean.
- As a primitive inside a real cipher: essential. The security there comes from the surrounding structure (key schedules, S-boxes, many rounds of diffusion), not from the XOR itself.
- As a one-time pad: XOR is not merely secure but information-theoretically secure, provably unbreakable by any amount of computing power, under three unforgiving conditions. The key must be truly random, at least as long as the message, and never reused. Break any one and it collapses. Reuse a pad and you have a “two-time pad”: XOR the two ciphertexts together and the key vanishes (
C1 ^ C2 = P1 ^ P2), handing an attacker a direct relationship between the plaintexts. A short key repeated across a long message is precisely key reuse, which is why repeating-key XOR is the failure mode of the one construction XOR is perfect for. - As a short repeating key protecting real secrets: never. It is Vigenere. It has been broken for 150 years.
The honest way to describe relay-grade XOR is what the chapter calls it: obfuscation. It raises the effort from “read it” to “notice it’s XOR and write four lines of Python.” That is a real, if tiny, cost, enough to filter the incurious. It is not confidentiality, and treating it as such is how config blobs full of C2 addresses get “protected” right up until someone runs bytes.fromhex.
The point
Cade clears the relay in two minutes not because he’s clever but because he can read what the system is actually doing rather than what it’s dressed as. A hex string that looks like ciphertext is an encoding. A next hop “encrypted with the relay key” is a picture of a lock. The competition uses XOR the way a good operator uses it, to slow down anyone who mistakes the drawing of a lock for the lock, and the whole first tier is a filter for the difference. Learning to see it is most of the job. Something that looks encrypted and something that is are separated by exactly one question: where is the key, and how long is it?
Read next
- The scene: Chapter 0.3 — The Contest — the relay chain into the underground contest.
- More field notes: The Signature Was Always Theater — a token that lies because you let it · Padlocks are Suggestions — the difference between what a system claims and what it does.
- Codex: Dark Cypher · Capture the Flag · NovaCoin
Sources & further reading
- Cryptopals — Set 1, Challenge 6: break repeating-key XOR, step by step
- XOR cipher and the one-time pad — the one place XOR is provably unbreakable, and why