Biterra Labs preview / Intro to Cryptography

XOR

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.

What the input can look like

A challenge might give you hexadecimal text:

Text
c8c3dec9deccd1d1d2c5d8d7d7

Why suspect hex?

Text
c8 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:

Text
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:

Text
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.

How XOR works

XOR compares two bits:

Bit ABit BA XOR B
000
011
101
110

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.

flowchart LR A["Plaintext bytes"] -->|"XOR with key"| B["Ciphertext bytes"] B -->|"XOR with the same key"| C["Plaintext bytes again"]

"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.

Single-byte XOR

The key is one byte (0255). Every message byte is XOR'd with that same value.

There are only 256 keys, so a short loop is clearer than guessing:

Python
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"))
JavaScript
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.

Repeating-key XOR

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.

Text
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:

Python
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"))
JavaScript
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.

Choose the right first test

Once you know the difference between a one-byte key and a repeating key, use the challenge clue to choose a test:

Challenge clueWhat it meansFirst test
"The key is one byte"One value was used on every ciphertext byteTry all values from 0 to 255
"The key is KEY"The known key bytes repeat across the messageRepeat KEY with cycle(...) and XOR once
"The key is three bytes long"Three unknown key bytes repeatUse the known length with an XOR analysis tool; do not run the one-byte loop
No XOR or key hintThe data may use a different techniqueIdentify 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.

Worked example

A challenge gives you this hex string and says it was XOR'd with one byte:

Text
c8c3dec9deccd1d1d2c5d8d7d7

Work through it in order:

  1. Convert the hex into bytes with bytes.fromhex(...).
  2. Try keys 0 through 255 with the single-byte loop above.
  3. Run the test against this ciphertext:
Python
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())
JavaScript
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:

Text
bitctf{{xor}}
  1. Read the whole result. The expected prefix and the readable body agree, so this is a strong candidate.

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.

Common mistake

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.

Tools and resources

CyberChef

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.

Python bytes documentation

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.

Self-check

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.

Next

Mission: Scrambled Protocol

Before you start

Keep XOR in bytes.

  • what representation are the bytes shown in?
  • is the key small enough to try by hand or in a short loop?
  • what readable text or flag pattern will identify the right candidate?

Download Mission: Scrambled Protocol