What would you like to learn?

Try PowerShell, networks, XSS, or Step 138

Browse the full curriculum →

Developer tools

Step 90. Project — XOR File Encryption/Decryption Tool

Step 90Estimated practice · 3 hours

Level 1 — Programming and the Inside of a Computer | Difficulty ★★★☆☆ | Estimated time: 3 hours

Prerequisites: Python basics from Steps 41–46 (lists, file I/O), bit operations from Step 54, and command-line arguments from Step 64.

  • What you need: Python, a text editor, and any one file (for the encryption experiment — absolutely not an important file).
  • Caution: the cipher you’re making today is educational. Proving to yourself that it can’t be used in the real world is today’s core objective. Every “breaking” experiment today applies only to files you encrypted yourself.

When people hear “cipher,” they picture the black box from a spy movie, but at its heart sits an astonishingly simple operation. Today’s star is XOR (exclusive OR, symbol ⊕) — one single line of rule: “same gives 0, different gives 1.” This operation has a magical property: if you XOR any value A with a key K and then XOR with K again, you get back the original A. This one property creates “a transformation only someone who knows the key can undo.” Today’s goals are twofold — build a tool that encrypts and decrypts files with XOR yourself, and personally smash why this cipher is weak. Making it, breaking it, and explaining the reason — that triple beat is the most typical rhythm of security study.


1. Learning Objectives

By the end of this chapter, you will be able to:

  • Demonstrate XOR’s restoration property (A ⊕ K ⊕ K = A) with Python’s ^ operator
  • Read and write files in "rb"/"wb" binary mode
  • Complete an enc/dec CLI tool that takes command-line arguments
  • Break a one-byte-key cipher with 256 exhaustive attempts (brute force)
  • Prove by experiment why key reuse is dangerous, and explain this cipher’s limits

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment Python 3 (script + one-liner execution)
Today’s tools ^ (XOR operator), bytes(), open(..., "rb"/"wb"), sys.argv, range(256)
Concepts needed XOR truth table, symmetric key, binary files, key space, known-plaintext attack
Today’s artifacts xor_tool.py (encryption/decryption tool), crack.py (exhaustive-search cracker)

2-1. XOR’s Magic Property — Flipping a Switch Twice

Here’s XOR’s truth table.

A B A ⊕ B
0 0 0
0 1 1
1 0 1
1 1 0

You only need to memorize one property here. A ⊕ K ⊕ K = A. It’s like flipping a light switch the same way twice — you’re back to the original state. In Python, XOR is the ^ symbol.

2-2. Symmetric Key — A Cipher Locked and Opened with the Same Key

A cipher that uses the same key for encryption and decryption, like today’s method, is called a symmetric-key cipher. It’s like a front-door key — you unlock with the same key you locked with. The opposite concept is public-key cryptography (the locking key and the unlocking key differ), which you’ll meet in a later level.

2-3. Binary Files — Bytes, Not Characters

What you’ve read so far with open("file") was text. But photos, executables, and archives contain “bytes that can’t be interpreted as characters.” To handle such files, you append b to the mode — "rb" (binary read), "wb" (binary write).

When you read in binary, Python gives you the file as a sequence of bytes (a bytes object). Each byte is a number from 0 to 255. And XOR is an operation between numbers — every file is a sequence of numbers, and numbers can be XORed. That’s the substance of the claim “any file can be encrypted.”

2-4. The Roots of Stream Ciphers and Key Space

This repeated single-byte XOR is a reversible encoding exercise, not secure encryption. Modern stream ciphers generate a keystream from a secret key and construction-specific nonce/counter. Correct nonce handling and authentication also matter; use a reviewed authenticated-encryption library for real data.

And today’s other star is the key space (the number of possible keys). A one-byte key has a key space of 256. The real-world cipher AES-128 has 2 to the 128th power. In section 3-6, this difference decides “broken in one second, or longer than the age of the universe.”


3. Follow Along

3-1. Confirming the XOR Property — First Sighting of the Magic

Input:

python -c "print(65 ^ 42); print((65 ^ 42) ^ 42)"

Output (measured 2026-09-09):

107
65

How to read it: flipping 65 (the character ‘A’) with 42 gives 107 (‘k’), and flipping it again with the same 42 brought it back to 65. From the intermediate value 107 alone, you can’t tell what the original was without the key 42.

Why: everything you build today stands on these two lines. Only a property you’ve confirmed with your own eyes becomes a tool you can trust.

3-2. Reading a File in Binary

Input: make an experiment file and read it.

echo "hello secret" > test.txt
python -c "data = open('test.txt','rb').read(); print(data); print(list(data)[:5])"

Output (measured 2026-09-09):

b'hello secret\n'
[104, 101, 108, 108, 111]

How to read it: b'...' marks a sequence of bytes. The list after it is each byte’s numeric value — h is 104, e is 101. The claim that a file is numbers has been proven. Encryption will be “flipping all these numbers with XOR.”

3-3. The Encryption Function — One Line of Core

Input: start making xor_tool.py.

def xor_bytes(data, key):
    return bytes([b ^ key for b in data])

How to read it: XOR each byte b of the list with key to make a new byte sequence. bytes([...]) wraps the number list into bytes. This one line is both encryption and decryption — since XOR is symmetric, one function is enough. The heart of a seemingly complex “encryption program” is really this one line, and the rest of the code is all wrapping paper — file reading/writing and argument handling.

3-4. Completing the CLI and Round-Tripping

Input: complete all of xor_tool.py.

"""Reversible single-byte XOR toy. Never use for confidential data."""
import argparse
from pathlib import Path


def xor_bytes(data, key):
    if not 0 <= key <= 255:
        raise ValueError("key must be 0..255")
    return bytes(b ^ key for b in data)


def byte_key(value):
    key = int(value)
    if not 0 <= key <= 255:
        raise argparse.ArgumentTypeError("key must be 0..255")
    return key


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("mode", choices=("enc", "dec"))
    parser.add_argument("input", type=Path)
    parser.add_argument("key", type=byte_key)
    args = parser.parse_args(argv)
    target = Path(str(args.input) + "." + args.mode)
    try:
        # Exclusive creation refuses to overwrite an existing output.
        with args.input.open("rb") as source, target.open("xb") as out:
            while chunk := source.read(65536):
                out.write(xor_bytes(chunk, args.key))
    except OSError as exc:
        parser.exit(1, f"File operation failed: {exc}\n")
    print(f"{args.mode} complete: {target}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Input (execution):

python xor_tool.py enc test.txt 42
python xor_tool.py dec test.txt.enc 42
cat test.txt.enc.dec
cmp test.txt test.txt.enc.dec

Output (measured 2026-09-09):

enc complete: test.txt.enc
dec complete: test.txt.enc.dec
hello secret

cmp finished with no output — meaning the two files are completely identical at the byte level (Windows has fc /b).

argparse validates the mode, paths, and byte key before file processing. XOR uses the same operation in either direction. A byte-for-byte round trip verifies reversibility for that input; it does not establish confidentiality or detect tampering.

Interesting discovery (measured 2026-09-09): if you peer into the ciphertext test.txt.enc with od -c, you get characters that happen to be readable like B O F F E ... — because ‘h’ (104) XOR key 42 is ‘B’. A ciphertext doesn’t have to be “strange symbols” — a ciphertext is just “bytes whose meaning you can’t know without the key.”

3-5. Make a Prediction — What If the Key Is Wrong?

You encrypted with key 42 and decrypt with key 43. What’s the result?

  • (a) It restores roughly similarly
  • (b) Completely wrong bytes come out
  • (c) An error occurs

Check for yourself:

python -c "data=open('test.txt.enc','rb').read(); print(bytes([b ^ 43 for b in data]))"

Output (measured 2026-09-09):

b'idmmn!rdbsdu\x0b'

A wrong XOR key silently produces different bytes; this toy has no authentication. Here 42 XOR 43 equals 1, so the low bit of each byte flips. Numerical closeness of keys does not predict text similarity: reason about the XOR bit mask, not subtraction.

3-6. Breaking It — 256 Exhaustive Attempts

Let’s attack my own tool. Assume you don’t know the key.

Input: crack.py:

data = open('test.txt.enc', 'rb').read()
for key in range(256):
    plain = bytes([b ^ key for b in data])
    if b'hello' in plain:
        print(f"key candidate: {key} → {plain[:20]}")

Output (measured 2026-09-09):

key candidate: 42 → b'hello secret\n'

How to read it: we ran all 256 key candidates and looked for “a readable result.” It takes less than a second. This is the proof of why today’s tool is “educational” — a cipher with a key space of 256 is defenseless in front of a computer.

Why: you’ve just confirmed with your hands the reason behind the textbook sentence “keys must be long.” No further explanation should be needed for why AES-128’s key space (2 to the 128th power) is necessary.

3-7. The Danger of Key Reuse — Encrypting Twice with the Same Key

XOR ciphers have a subtler weakness beyond exhaustive search. If you encrypt two messages with the same key, the moment you XOR the two ciphertexts, the key cancels out: C1 ⊕ C2 = (M1 ⊕ K) ⊕ (M2 ⊕ K) = M1 ⊕ M2.

Input:

key = 77
m1 = b'attack at dawn'
m2 = b'retreat at noon'
c1 = bytes([b ^ key for b in m1])
c2 = bytes([b ^ key for b in m2])
print('c1 XOR c2 == m1 XOR m2:', bytes(a ^ b for a, b in zip(c1, c2)) == bytes(a ^ b for a, b in zip(m1, m2)))
print('recovered key:', c1[0] ^ ord('a'))   # if you know the first letter is 'a'

Output (measured 2026-09-09):

c1 XOR c2 == m1 XOR m2: True
recovered key: 77

How to read it: even knowing nothing about the key, the XOR of the two ciphertexts exactly matched the XOR of the two plaintexts — the key was erased. Worse, if you know even one character of the plaintext (e.g., a guess that the message starts with “attack”), the key is recovered outright. This is called a known-plaintext attack.

Never reuse a keystream for different messages. With modern stream-cipher constructions, one key may encrypt many messages when the required nonce/counter rules are followed. A one-time-pad key is different: its secret random pad must never be reused.

3-8. Encryption You Can See — Round-Tripping a Binary File

Experimenting with a binary file instead of text makes the generality visible. Let’s make a test image (PPM format) directly with Python.

Input:

python -c "
w,h=64,64
with open('photo.ppm','wb') as f:
    f.write(b'P6\n64 64\n255\n')
    for y in range(h):
        for x in range(w):
            f.write(bytes([x*4 % 256, y*4 % 256, 128]))
print('photo.ppm created')"
python xor_tool.py enc photo.ppm 200
python xor_tool.py dec photo.ppm.enc 200
cmp photo.ppm photo.ppm.enc.dec

Output (measured 2026-09-09):

photo.ppm created
enc complete: photo.ppm.enc
dec complete: photo.ppm.enc.dec

cmp went silent again — the image file also round-tripped perfectly at the byte level. The encrypted photo.ppm.enc has even its header (P6) flipped (measured: in od -c output the leading bytes changed to octal 230 376 302 ...), so no image viewer can open it.

How to read it: whether text or picture, a file is a sequence of bytes, and bytes get XORed. This is an experiment in feeling firsthand what the state “once encrypted, even programs can’t recognize it” looks like.


4. Missions & Exercises

Mission — A Finished Tool and Self-Analysis

  1. Prove that xor_tool.py works in both enc and dec modes, and that the round-trip result matches the original, with cmp (or fc /b)
  2. Succeed in round-tripping a non-text file (image, archive, etc.)
  3. Break your own ciphertext with crack.py and find the key
  4. Write XOR-cipher.md in your Step 89 wiki — one-line summary / usage commands / stuck points / a 3-line “why is it weak” explanation
  5. Bonus challenge (optional): modify it to take a multi-character key instead of one byte and XOR cycling through the key bytes, then organize why it’s still weak

Exercises

Exercise 1. Explain which rows of the truth table let you read why A ⊕ K ⊕ K = A holds.

Exercise 2. Explain, together with the word “symmetric key,” why one single function is enough for both encryption and decryption.

Exercise 3. Compare, via “key space,” why a one-byte-key XOR cipher breaks in one second while AES-128 doesn’t break by exhaustive search.

Exercise 4. Explain what happens when you XOR two ciphertexts encrypted with the same key, and why it’s dangerous.


Answers & completion criteria · expand/collapse

5. Model Answers & Completion Criteria

Mission Model Answer

The code from 3-4 is the finished tool. Collecting just the mission’s verification commands:

python xor_tool.py enc test.txt 42
python xor_tool.py dec test.txt.enc 42
cmp test.txt test.txt.enc.dec
# crack.py reads test.txt.enc and searches for the known dummy word hello.
python crack.py
# Use a fresh folder or new output names when repeating; existing outputs are preserved.

Skeleton of the optional task (multi-character key):

def xor_bytes(data, key):
    return bytes([b ^ key[i % len(key)] for i, b in enumerate(data)])

i % len(key) cycles through the key’s bytes. Why it’s still weak: the key space does grow, but as long as the key repeats, the same key byte gets reused periodically (the key-reuse problem from 3-7 repeats every cycle), and an attack that estimates the key length, splits, and brute-forces each position is possible.

How to verify: ① is cmp silent? ② does crack.py find the correct key? ③ is “why is it weak” written in the wiki document? All “yes” means complete.

Exercise Answers

Answer 1. In the truth table, if K=0 then A ⊕ 0 = A (rows 1 and 3: unchanged); if K=1 then A ⊕ 1 = the inverse of A (rows 2 and 4). In other words, XOR is an operation that “flips only the positions where K=1,” so flipping twice with the same K flips the flipped positions back to the original.

Answer 2. Since XOR is a symmetric operation that returns to the original when applied twice with the same key, encryption and decryption are the same calculation. Because it’s a symmetric-key cipher that uses the same key to lock and unlock, one function covers both directions.

Answer 3: The toy has only 256 possible byte keys, making exhaustive search practical. AES-128 has a vastly larger key space, but key size alone does not establish security: the construction, implementation, key handling, and authentication also matter.

Answer 4: Reusing the same keystream exposes the XOR of the plaintexts. In this single-byte toy, one known plaintext byte reveals the repeated byte key. Modern stream ciphers can reuse a secret key under their required unique nonce/counter rules; they must not reuse a keystream.

Completion Criteria Checklist

  • [ ] I can demonstrate XOR’s restoration property with the ^ operator
  • [ ] I can read and write files in binary mode ("rb"/"wb")
  • [ ] I completed a CLI tool with working enc/dec
  • [ ] I proved with cmp that the round-trip result equals the original
  • [ ] I broke my own ciphertext with 256 exhaustive attempts
  • [ ] I can explain the key-reuse attack (M1 ⊕ M2 exposure)
  • [ ] I can state why this cipher is weak (key space, key repetition)

6. Common Pitfalls & Fixes

Wall 1. The restored file is corrupted / UnicodeDecodeError appears

Symptom (measured 2026-09-09, ciphertext read in text mode):

UnicodeDecodeError: 'cp949' codec can't decode byte 0xfe in position 6: illegal multibyte sequence

Cause: you dropped the b in open and read in text mode. Text mode dies or mangles things while the OS tries to interpret the encoding (cp949 on Korean Windows) against binary bytes.
Fix: check that both reading and writing use "rb"/"wb". This single b is this chapter’s most frequent accident.

Wall 2. “ValueError: invalid literal for int()” appears

Symptom (measured 2026-09-09, the word key entered in the key position; path fabricated):

  File "xor_tool.py", line 10, in main
    mode, path, key = sys.argv[1], sys.argv[2], int(sys.argv[3])
ValueError: invalid literal for int() with base 10: 'key'

The old traceback illustrates direct int conversion. The complete corrected CLI uses argparse and rejects a non-integer or a value outside 0–255 before creating an output file. Read the usage message and rerun with a valid byte key.

Wall 3. crack.py finds no candidates

Symptom: it ran 256 times and stayed quiet.
Cause: the word you’re looking for (hello) isn’t in the ciphertext, or that word wasn’t in the original.
Fix: search for a word actually present in the original. In the real world, you score “does it look like an English sentence” — since a short word can match by coincidence, visually confirming the printed candidates is part of cracking.

Wall 4. The key is wrong but no error appears, so you move on unaware

A wrong key still produces bytes because this toy has no authentication. For keys 42 and 43, the XOR difference is 1 and flips the low bit of each byte; numeric closeness is not a general measure of text similarity. Compare with the original for this exercise, and never treat apparent readability as authenticity.

Wall 5. Filenames snowball (.enc.enc.dec…)

Symptom: the extensions pile up the more you run it.
Cause: a limitation of simply appending to the output name.
Fix: in dec mode, try polishing the naming rule, like stripping the trailing .enc. This finishing touch is part of “the tool’s completeness.”


7. Summary

Today’s Concepts

Concept One-line explanation
XOR (⊕) Same gives 0, different gives 1. A ⊕ K ⊕ K = A — this property makes a cipher
Symmetric-key cipher A cipher that uses the same key to lock and unlock
Binary mode "rb"/"wb" — every file is a sequence of numbers from 0 to 255
Key space The number of possible keys. 256 means a 1-second exhaustive search; 2^128 means impossible
Brute force An attack that runs through all key candidates and picks the readable one
Known-plaintext attack An attack where knowing part of the plaintext recovers the key — fatal with key reuse
OTP The only cipher mathematically proven unbreakable, with a key as long as the data, truly random, used only once

Today’s Code

Code What it does
a ^ b XOR of two numbers
bytes([b ^ key for b in data]) Flip the entire byte sequence with the key (encryption = decryption)
open(p, "rb").read() Read a file as a byte sequence
open(p, "wb").write(b) Write a byte sequence to a file
sys.argv[1:] Pull out command-line arguments
for key in range(256) Exhaustive search of one-byte keys
cmp original restored Round-trip verification (no output = match; Windows uses fc /b)

An Instinct More Important Than Commands

Today you made it, reversed it, and broke it yourself. You confirmed with your own hands that a single line of XOR becomes a cipher, that a key space of 256 becomes that cipher’s grave, and that key reuse interlocks two ciphertexts and lets plaintext leak out. The difference between “using a weak cipher knowing it’s weak” and “using it without knowing” is the difference between a professional and an amateur.

A one-time pad achieves perfect secrecy under strict assumptions: a secret uniformly random pad at least as long as the message, used only once. It does not authenticate the message. This single-byte exercise meets none of the required long-random-pad conditions. Use the cracking exercise only on the provided toy data or authorized CTF material.


Once every box is checked, Step 90 is complete.

ONE STEP FURTHER

Finished this lesson?

Check the completion criteria, then mark your progress.

Something wrong with this page or a link? Let us know.
Next