Step 90. Project — XOR File Encryption/Decryption Tool
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
A cipher that stretches the key to the length of the data and XORs byte by byte is called a stream cipher. Today’s tool is the most primitive stream cipher, reusing a single key byte. Real-world stream ciphers (e.g., ChaCha20) share the same skeleton — generate a key stream that looks truly random, then XOR. The only difference is "how unpredictable the key stream is."
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.
import sys
def xor_bytes(data, key):
return bytes([b ^ key for b in data])
def main():
if len(sys.argv) != 4:
print("Usage: python xor_tool.py enc|dec input_file key(0-255)")
return
mode, path, key = sys.argv[1], sys.argv[2], int(sys.argv[3])
out = path + ('.enc' if mode == 'enc' else '.dec')
data = open(path, 'rb').read()
open(out, 'wb').write(xor_bytes(data, key))
print(f"{mode} complete: {out}")
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).
How to read it: sys.argv is the list of words typed on the command line (Step 64). Whether the mode is enc or dec, the work is the same — because XOR is symmetric. The completion condition of a cipher tool is "does it come back," and the round trip has been proven.
Interesting discovery (measured 2026-09-09): if you peer into the ciphertext
test.txt.encwithod -c, you get characters that happen to be readable likeB 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'
The closest answer is (b) — restoration failed, and there’s no error either. But the measurement shows an interesting detail: if the key is off by just 1 (43 instead of 42), each byte is off by exactly 1 — like "hello" becoming "idmmn." That’s because 42 XOR 43 is 1. If the key is off by more, the result becomes complete garbage. XOR has no "close enough" — a wrong key quietly gives a wrong answer.
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.
Why it matters: this is why "a key, only once, for only one message" is the iron rule of stream ciphers. This principle is also the condition of the OTP (one-time pad) coming up next.
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: the leading bytes changed to 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
- Prove that
xor_tool.pyworks in both enc and dec modes, and that the round-trip result matches the original, withcmp(orfc /b) - Succeed in round-tripping a non-text file (image, archive, etc.)
- Break your own ciphertext with
crack.pyand find the key - Write
XOR-cipher.mdin your Step 89 wiki — one-line summary / usage commands / stuck points / a 3-line "why is it weak" explanation - 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.
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 original_file 42
python xor_tool.py dec original_file.enc 42
cmp original_file original_file.enc.dec # no output = round-trip success
python crack.py # should print key candidate: 42
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. A one-byte key has only 256 candidates, so you try 0–255 all the way through and pick the "readable" one — done (measured in 3-6: under a second). AES-128 has 2-to-the-128th candidates, so even trying trillions per second takes longer than the age of the universe. A cipher’s strength comes from the size of its key space.
Answer 4. The key cancels out and the XOR of the two plaintexts (M1 ⊕ M2) is exposed as-is (measured in 3-7: True). The difference pattern of the two messages leaks, and if you know even part of one side’s content, the other side and the key are recovered — which is why key reuse is absolutely forbidden in stream ciphers.
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
cmpthat 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'
Cause: a non-numeric string went into int(sys.argv[3]).
Fix: enter a number (0–255) as the usage says. Once you’re comfortable, wrapping it in try/except to show a friendly message is also good practice.
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
Symptom: decryption "succeeded" but the content is strange.
Cause: XOR gives a wrong answer without an error even for a wrong key (measured in 3-5: a key off by 1 gives results like "idmmn," each letter off by 1).
Fix: the tool can’t verify the result. Build the habit of visually confirming the content after restoring — if you have the original, comparing with cmp is the standard.
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.
And this one-line operation has a great descendant — the OTP (one-time pad), the only cipher mathematically proven unbreakable, when the key is as long as the data, completely random, and used exactly once. Today’s toy is that cipher’s ancestor. A large share of CTF crypto problems are variations on "something XORed," so keep crack.py well stored in your wiki — the day will come when you pull it out as-is at the competition venue.
Once every box is checked, Step 90 is complete. Click the checkbox in the sidebar to save your progress.