Biterra Labs preview / Intro to Cryptography
XOR (exclusive OR) is a reversible bit operation. XOR the data with a key, then XOR the result with the same key, and you get the original back.
In CTFs, XOR usually arrives as hex or raw bytes. It only makes sense once you stop treating it as printable text.
A challenge might give you hexadecimal text:
c8c3dec9deccd1d1d2c5d8d7d7
Why suspect hex?
0-9 or a-fc8 c3 de c9 de cc d1 d1 d2 c5 d8 d7 d7
Those clues make hex a sensible first interpretation; they do not prove that the bytes use XOR. Convert the string with bytes.fromhex(...) before applying XOR. If you need to inspect unfamiliar values, use a hex-to-ASCII converter or add From Hex in CyberChef.
A file may contain the bytes directly. Viewed with xxd, the same data starts like this:
00000000: c8c3 dec9 decc d1d1 d2c5 d8d7 d7
Opening those raw bytes as a text file would show mostly unreadable characters, not a sentence such as:
meet at nine
This appearance does not prove that the data uses XOR. It only tells you that you have bytes to classify. Look for an XOR or key hint in the challenge before choosing the technique.
XOR compares two bits:
| Bit A | Bit B | A XOR B |
|---|---|---|
0 | 0 | 0 |
0 | 1 | 1 |
1 | 0 | 1 |
1 | 1 | 0 |
The result is 1 when the two input bits are different and 0 when they are the same.
The value combined with the data is called the key. For now, picture it as one byte applied to every byte of the message. The next sections show that case first, then a key made from several repeating bytes.
So A XOR B XOR B = A. The same key encrypts and decrypts.
"XOR with a key" means: each message byte is XOR'd with the corresponding key byte. If the key is shorter than the message, repeat the key.
The key is one byte (0–255). Every message byte is XOR'd with that same value.
There are only 256 keys, so a short loop is clearer than guessing:
ciphertext = bytes.fromhex("c8c3dec9deccd1d1d2c5d8d7d7")
for key in range(256):
plaintext = bytes(byte ^ key for byte in ciphertext)
if b"bitctf{{" in plaintext:
print(key, plaintext.decode("utf-8", errors="replace"))
const hex = "c8c3dec9deccd1d1d2c5d8d7d7";
const ciphertext = Uint8Array.from(
hex.match(/../g).map(byte => Number.parseInt(byte, 16)),
);
const decoder = new TextDecoder();
for (let key = 0; key < 256; key += 1) {
const plaintext = Uint8Array.from(ciphertext, byte => byte ^ key);
const text = decoder.decode(plaintext);
if (text.includes("bitctf{{")) {
console.log(key, text);
}
}
That is not "breaking modern crypto." It is testing a 256-value key space and using a strong expected clue — here the course flag prefix — to recognise the right output.
If you do not know the prefix, print candidates that look like mostly printable ASCII and read them. One lucky readable character is not enough; the whole plaintext should make sense.
The key is a few bytes, for example KEY. Byte 1 of the message XOR's with K, byte 2 with E, byte 3 with Y, byte 4 with K again.
Message position: 1 2 3 4 5 6 7 8
Repeated key: K E Y K E Y K E
If the key is known, repeat it with itertools.cycle:
from itertools import cycle
ciphertext = bytes.fromhex("030015070a79130a0b")
key = b"KEY"
plaintext = bytes(byte ^ key_byte for byte, key_byte in zip(ciphertext, cycle(key)))
print(plaintext.decode("utf-8", errors="replace"))
const hex = "030015070a79130a0b";
const ciphertext = Uint8Array.from(
hex.match(/../g).map(byte => Number.parseInt(byte, 16)),
);
const key = new TextEncoder().encode("KEY");
const plaintext = Uint8Array.from(
ciphertext,
(byte, index) => byte ^ key[index % key.length],
);
console.log(new TextDecoder().decode(plaintext));
If only the key length is hinted, use that clue when choosing an analysis tool. Do not treat a three-byte repeating key as if it were one byte.
Once you know the difference between a one-byte key and a repeating key, use the challenge clue to choose a test:
| Challenge clue | What it means | First test |
|---|---|---|
| "The key is one byte" | One value was used on every ciphertext byte | Try all values from 0 to 255 |
"The key is KEY" | The known key bytes repeat across the message | Repeat KEY with cycle(...) and XOR once |
| "The key is three bytes long" | Three unknown key bytes repeat | Use the known length with an XOR analysis tool; do not run the one-byte loop |
| No XOR or key hint | The data may use a different technique | Identify the input format and revisit the challenge clues before assuming XOR |
Challenges may phrase the same clues less directly:
Treat these as hypotheses to test, not proof. Other techniques can produce similar patterns.
A successful result should be coherent: readable text, a valid file header, or a complete flag. One accidental readable character is not enough.
A challenge gives you this hex string and says it was XOR'd with one byte:
c8c3dec9deccd1d1d2c5d8d7d7
Work through it in order:
bytes.fromhex(...).0 through 255 with the single-byte loop above.ciphertext = bytes.fromhex("c8c3dec9deccd1d1d2c5d8d7d7")
for key in range(256):
plaintext = bytes(byte ^ key for byte in ciphertext)
if b"bitctf{{" in plaintext:
print(key, plaintext.decode())
const hex = "c8c3dec9deccd1d1d2c5d8d7d7";
const ciphertext = Uint8Array.from(
hex.match(/../g).map(byte => Number.parseInt(byte, 16)),
);
for (let key = 0; key < 256; key += 1) {
const plaintext = Uint8Array.from(ciphertext, byte => byte ^ key);
const text = new TextDecoder().decode(plaintext);
if (text.includes("bitctf{{")) {
console.log(key, text);
}
}
Key 170 (0xaa) produces:
bitctf{{xor}}
If the same challenge had said the key was three characters long, you would still start by getting the bytes right. Then you would test the hinted length, not invent a single-byte loop out of habit.
Trying to read XOR ciphertext as if it were a Caesar puzzle, or running the 256-key loop on a string that is still hex characters rather than decoded bytes.
2b0c1f as text is three ASCII characters. bytes.fromhex("2b0c1f") is three bytes. Those are different inputs.
Add From Hex first when the challenge gives you hex, then add XOR and enter the known key. Use XOR Brute Force when the key is one unknown byte.
Use Python when you want to inspect every candidate or control how results are filtered. You do not need a dedicated "XOR cracker" for a 256-value single-byte key space.
xxd -r -p on a challenge file prints a mess of high-bit garbage. Someone says "so it is not XOR, XOR would look like text." What is wrong with that?
Answer: XOR ciphertext is supposed to look like garbage until you apply the key. The hex decode gave you bytes. That is the input to XOR, not evidence against it.
Mission: Scrambled Protocol
Keep XOR in bytes.