Step 247. Encrypted-Artifact Recovery Techniques — Three Keys That Open Locked Evidence

Step 247. Encrypted-Artifact Recovery Techniques — Three Keys That Open Locked Evidence

Level 3 — Real-World CTF & Advanced Offensive Skills | Difficulty ★★★★☆ | Estimated time: 3–4 hours

Prerequisites: Step 90 (XOR) and Step 246 complete. We proceed in Python 3, and every locked file is one we make ourselves.

⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.

  • What you need: Python 3 (measured: 3.12.14). No internet connection needed.
  • Caution: every file and hash we crack today is generated by us for practice. Password-cracking techniques apply only to evidence you have recovery authority over or legal CTF problems — opening someone else’s locked file is a crime in itself.

At forensic scenes, "the important file is locked with a password" is a common wall. Archives, office documents, encrypted memos — the investigation doesn’t stop there, because every lock has a weakness: simple XOR falls to a few hundred brute-force tries, legacy ZIP encryption (ZipCrypto) opens if you know even part of the contents, and weak passwords don’t survive a dictionary. Today you build and break these three keys yourself — brute force, the known-plaintext attack, and the dictionary attack.


1. Learning Objectives

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

  • Recover a single-byte XOR-encrypted file with brute force (256 tries)
  • Explain the principle of the known-plaintext attack and recover an XOR keystream from a file signature
  • Parse the structure of a ZIP local file header (signature, flags, compression method)
  • Implement the procedure for recovering a hash-stored password with a dictionary attack
  • Know which locks don’t open (AES, etc.) and name the alternatives then

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment Python 3 — bytes, hashlib, zipfile, struct
Today’s code bytes(b ^ k for b in data) (XOR), 256-try brute force + printable-character scoring, hashlib.sha256() comparison, struct.unpack("<IHHHHHIIIHH", ...) (ZIP header)
Concepts needed XOR’s self-inverse property, known-plaintext attacks, file signatures, hashes and dictionary attacks, ZipCrypto vs AES

2-1. XOR’s Fatal Virtue — The Same Operation Locks and Unlocks

Let’s review XOR’s property from Step 90: A ^ K ^ K = A. XOR twice with the same key and you return to the original. That makes XOR the world’s simplest "encryption" — and simultaneously the most vulnerable.

If the key is 1 byte, only 256 keys are possible. Try all 256 and pick "the result a human can read" — done. That’s today’s first key, brute force.

2-2. The Known-Plaintext Attack — Know Part of the Contents and the Key Falls Out

Even when the key grows (say, a repeating 3-byte key), there’s a way. XOR has this property too:

ciphertext ^ plaintext = key

In other words, if you know part of an encrypted file’s original contents, the key for that stretch computes directly. This is the known-plaintext attack.

"How would you know the contents in advance?" — the file format answers. A PNG file always starts with the same 8 bytes (89 50 4E 47 0D 0A 1A 0A, i.e. x89PNG...), and a ZIP starts with PKx03x04 (Step 239’s signatures). A file’s opening bytes are not a secret. So an "encrypted PNG" hands you the first 8 bytes of plaintext for free, and the keystream leaks out from there.

2-3. The ZIP Header and ZipCrypto’s Fate

A ZIP file prefixes every entry with a local file header. The signature (4 bytes), flags (2 bytes — if bit 0 is 1, it’s encrypted), compression method, CRC32, sizes, and filename sit at fixed positions. The header structure being standard means even when encrypted, the skeleton is readable.

Legacy ZIP encryption, ZipCrypto, is weakly designed: knowing just 12 bytes of plaintext from one file in the archive lets you recover the internal keys and decrypt everything without the password (the practitioner’s tool: bkcrack). A single "uncompressed text file inside the archive" becomes the keyhole. Modern AES-method ZIPs and 7z, on the other hand, are immune to this attack — then you go to the fourth key, password guessing.

2-4. Hashes and Dictionary Attacks — Human Passwords Are Predictable

The passwords of office documents and ZIPs are usually stored converted into hashes (tools like zip2john and office2john extract these hashes, and John the Ripper or hashcat attacks them). A hash can’t be computed backwards, but "picking candidates and computing forwards to compare" works.

Human passwords are predictable — company name + year, season + year, common words. So the dictionary attack — running through a candidate list (a wordlist) and comparing hashes — works well in reality. Today you’ll implement it yourself with an 8-word wordlist.


3. Follow Along

3-1. Staging the Scene — Three Pieces of Locked Evidence

First, make the "investigation targets." make_evidence.py:

from pathlib import Path
import hashlib

lab = Path("lab247"); lab.mkdir(exist_ok=True)

# Evidence 1: a memo locked with single-byte XOR
secret = b"The meeting is at pier 9. Flag{kn0wn_pla1ntext_w1ns}n"
(lab / "note.txt.xor").write_bytes(bytes(b ^ 0x5A for b in secret))

# Evidence 2: a "PNG" locked with a repeating 3-byte XOR key
magic = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
payload = b"IHDR........hidden coords: 35.1795,129.0753 Flag{x0r_m4g1c}"
key = b"K3Y"
plain = magic + payload
(lab / "evidence.png.xor").write_bytes(
    bytes(b ^ key[i % 3] for i, b in enumerate(plain)))

# Evidence 3: only the password's hash remains (recreating the office/ZIP hash-extraction situation)
(lab / "hash.txt").write_text(
    hashlib.sha256("spring2026".encode()).hexdigest() + "n", encoding="utf-8")
(lab / "wordlist.txt").write_text(
    "passwordn123456nqwertynletmeinnspring2025nspring2026nsummer2026nadmin123n",
    encoding="utf-8")
print("3 pieces of evidence created")

Run it and you get note.txt.xor (53 bytes), evidence.png.xor (67 bytes), and hash.txt (measured 2026-09-09).

3-2. The First Key: Brute Force — 256 Tries Are Enough

For note.txt.xor, we don’t know the key. But if the key is 1 byte, there are only 256, and decrypted with the right key, the result will be full of printable characters. Use that score to pick the answer:

from pathlib import Path

data = Path("lab247/note.txt.xor").read_bytes()

def score(bs):  # the number of characters a human can read
    return sum(1 for b in bs if 32 <= b < 127 or b in (10, 13))

results = []
for k in range(256):
    dec = bytes(b ^ k for b in data)
    results.append((score(dec), k, dec))
results.sort(reverse=True)

top = results[0]
print(f"best key: 0x{top[1]:02X} (score {top[0]}/{len(data)})")
print("recovered:", top[2].decode())
print("2nd best:", f"0x{results[1][1]:02X} score {results[1][0]}")
best key: 0x5A (score 53/53)
recovered: The meeting is at pier 9. Flag{kn0wn_pla1ntext_w1ns}

2nd best: 0x5F score 52

(Measured 2026-09-09.)

How to read the output: with key 0x5A, all 53 bytes came out printable (a perfect score), one point ahead of second place (0x5F, 52). The score gap between the perfect answer and the near miss — this is why brute force works. A wrong key produces broken bytes somewhere; the right key makes everything readable. 256 tries is a blink to a computer.

3-3. The Second Key: The Known-Plaintext Attack — The Signature Sells the Key

evidence.png.xor has a 3-byte key (K3Y), making 16 million candidates. Brute force is out of reach, but if the filename says png, we know the first 8 bytes of plaintext. ciphertext ^ plaintext = keystream:

from pathlib import Path

enc = Path("lab247/evidence.png.xor").read_bytes()
magic = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])

recovered = bytes(e ^ m for e, m in zip(enc[:8], magic))
print("key stream from magic:", recovered)

key_stream = recovered[:3]  # repeating unit of 3 bytes
dec = bytes(b ^ key_stream[i % 3] for i, b in enumerate(enc))
print("decrypted:", dec.decode(errors="replace"))
key stream from magic: b'K3YK3YK3'
decrypted:PNG

IHDR........hidden coords: 35.1795,129.0753 Flag{x0r_m4g1c}

(Measured 2026-09-09.)

How to read the output: the keystream computed from the first 8 bytes is K3YK3YK3the repeating 3-byte key lies fully exposed. Decrypting everything with that key restored the PNG magic (x89PNG), the hidden coordinates, and the flag. Zero attempts; all we needed was the common knowledge that "a PNG always starts with the same bytes." That’s the power of the known-plaintext attack, and the structure by which a file format’s standard header becomes the cipher’s weakness.

3-4. Reading the ZIP Skeleton — What’s Visible Even When Encrypted

To understand the ZipCrypto attack (bkcrack), you need to see the ZIP skeleton. Make an ordinary ZIP and parse its header directly:

from zipfile import ZipFile, ZIP_DEFLATED
import struct
from pathlib import Path

lab = Path("lab247")
with ZipFile(lab / "evidence.zip", "w", ZIP_DEFLATED) as z:
    z.writestr("memo.txt", "transfer schedule: tue 02:00n")

raw = (lab / "evidence.zip").read_bytes()
sig, ver, flag, method, mt, md, crc, csize, usize, nlen, xlen = 
    struct.unpack("<IHHHHHIIIHH", raw[:30])
print(f"signature : {sig:08X}   (PK\x03\x04 = 04034B50)")
print(f"flag bits : {flag:016b}  (bit 0 = encrypted or not)")
print(f"method    : {method}   (0=stored, 8=deflate)")
print(f"crc32     : {crc:08X}")
print(f"comp size : {csize}  orig size : {usize}")
print(f"name      : {raw[30:30+nlen].decode()}")
signature : 04034B50   (PKx03x04 = 04034B50)
flag bits : 0000000000000000  (bit 0 = encrypted or not)
method    : 8   (0=stored, 8=deflate)
crc32     : 2B71F3DD
comp size : 31  orig size : 29
name      : memo.txt

(Measured 2026-09-09. With such a small file, the compressed 31 bytes actually exceed the original 29 — deflate’s fixed vanity overhead.)

How to read the output: the signature 04034B50 matches the ZIP standard. Bit 0 of the flags is the encryption marker — here it’s 0 (not locked). In a real investigation, this bit is 1, and you determine here whether the method is ZipCrypto or AES, then branch: bkcrack for ZipCrypto (needs 12 bytes of plaintext), dictionary attack for AES. The header being standard — that’s why even a locked file tells you "what it’s locked with."

3-5. The Third Key: The Dictionary Attack — Hash Comparison

The last piece of evidence, hash.txt, holds only the SHA-256 hash of a password. You can’t go backwards, so compute candidates forwards and compare:

from pathlib import Path
import hashlib

target = Path("lab247/hash.txt").read_text().strip()
for w in Path("lab247/wordlist.txt").read_text().splitlines():
    h = hashlib.sha256(w.encode()).hexdigest()
    mark = "  <-- MATCH" if h == target else ""
    print(f"{w:>12}  {h[:12]}...{mark}")
    password  5e884898da28...
      123456  8d969eef6eca...
      qwerty  65e84be33532...
     letmein  1c8bfe8f801d...
     spring2025  076c32dcd131...
     spring2026  5c7acb9bafe6...  <-- MATCH
     summer2026  6a0436eecdad...
     admin123  240be518fabd...

(Measured 2026-09-09.)

How to read the output: the hash of the 6th candidate, spring2026, matches the target — the password is recovered. Even an 8-word toy wordlist caught the "season+year" pattern. Real-world wordlists (rockyou.txt, etc.) hold tens of millions, and a custom wordlist mixed from the investigation target’s clues (filenames, dates, organization-name variants) raises the success rate.

Why does this work: hashes are safe, but if the input is predictable, the hash is powerless. The claim "storing as a hash is safe" carries the premise "when the input is sufficiently random" — human passwords usually aren’t.


4. Missions & Exercises

Mission — Independently Recovering Two Kinds of Locked Evidence

  1. Make a new single-byte XOR file yourself (you choose the key and contents; assume a friend or your future self doesn’t know them)
  2. Recover the key and contents with section 3-2’s brute-force code — but change the scoring function to "ratio of spaces and alphanumerics"
  3. Knowing the ZIP signature (50 4B 03 04), make a secret.zip.xor locked with a repeating-key XOR and open it with the known-plaintext attack (hint: PKx03x04 is 4 bytes)
  4. Organize the recovery process in recovery-note.md — one line each on which key you used and why that key worked

Exercises

Problem 1. Derive why ciphertext ^ plaintext = key holds in XOR, starting from A ^ K ^ K = A.

Problem 2. In section 3-2, the runner-up candidate (0x5F) also scored high at 52. What additional check should you do to avoid trusting the score and picking the wrong key?

Problem 3. A locked ZIP’s local-header flag bit 0 is 1, and the compression method is marked "AES." Can you attempt the known-plaintext attack (bkcrack)? What’s the alternative then?

Problem 4. A dictionary attack failed (no candidate matched the hash). Name two things to try next, drawn from today’s material.


5. Model Answers & Completion Criteria

Mission Model Answer

from pathlib import Path

# (1) Creation: key 0x37, contents up to you
msg = b"vault code: 4815. Flag{d1ct_4tt4ck_d0ne}n"
Path("lab247/mine.xor").write_bytes(bytes(b ^ 0x37 for b in msg))

# (2) Brute force (space/alphanumeric scoring version)
data = Path("lab247/mine.xor").read_bytes()
def score(bs):
    return sum(1 for b in bs if chr(b).isalnum() or b in (32, 10))
best = max(range(256), key=lambda k: score(bytes(b ^ k for b in data)))
print(f"key=0x{best:02X}", bytes(b ^ best for b in data).decode())

# (3) Known-plaintext attack via the ZIP signature
key = b"Z1P!"
body = b"PKx03x04" + b"x14x00x00x00...secret content Flag{z1p_m4g1c}"
enc = bytes(b ^ key[i % 4] for i, b in enumerate(body))
Path("lab247/secret.zip.xor").write_bytes(enc)
known = b"PKx03x04"
stream = bytes(e ^ k for e, k in zip(enc[:4], known))
print("recovered key:", stream)  # b'Z1P!'
dec = bytes(b ^ stream[i % 4] for i, b in enumerate(enc))
print(dec.decode(errors="replace"))

(Verified by measurement on 2026-09-09 with the same code pattern — the same procedure as sections 3-2 and 3-3.)

How to verify: it succeeds if the recovered key matches the key used at creation and the restored text is human-readable. recovery-note.md must state why each key worked, like "brute force — because the key space is only 256," "known-plaintext — because the ZIP magic is a public constant."

Exercise Answers

Answer 1. XOR both sides of the ciphertext C = P ^ K with P: C ^ P = P ^ K ^ P = K ^ (P ^ P) = K ^ 0 = K. XOR is commutative and self-cancels to 0, so knowing the plaintext computes the key directly.

Answer 2. Read the restored text with your own eyes and check that it makes sense. The score is only a tool for narrowing candidates; the final verdict is "can a human read it / does the file signature restore?" If the file format is one you know (ZIP, PNG), whether the restored leading bytes match the magic is the decisive check.

Answer 3. No. The known-plaintext attack targets the design weakness of legacy ZipCrypto, so it doesn’t work on AES-method ZIPs. The alternative is the dictionary attack — guessing the password with a custom wordlist built from filenames, dates, and organization-name variants.

Answer 4. (1) Expand the wordlist — bigger public wordlists, custom candidates based on case clues (organization name + year, filename variants). (2) Switch attack modes — if it was a ZipCrypto file, bypass the password entirely with the known-plaintext attack (bkcrack), which targets the internal keys directly. Re-checking "is this a lock that opens without the password" comes first.

Completion Criteria Checklist

  • [ ] I can recover a single-byte XOR file with 256-try brute force
  • [ ] I can explain the principle of picking the right key with a printable-character score
  • [ ] I can recover a keystream from a signature using ciphertext ^ plaintext = key
  • [ ] I can read a ZIP local header’s signature, flag bit 0, and compression method
  • [ ] I know the difference between ZipCrypto (plaintext attack possible) and AES ZIP (not possible)
  • [ ] I can implement a dictionary attack against a SHA-256 hash myself
  • [ ] I can explain the premise behind "hashes are safe" (input randomness)
  • [ ] Mission: completed recovery-note.md

6. Common Pitfalls & Fixes

Wall 1. The restored result is full of broken characters

Symptom: decode() throws an error, or “ fills the screen.
Cause: you decrypted with the wrong key, or you decoded a binary file (an image, etc.) as text.
Fix: for a text file, re-check the top-scoring key; for binary, view only a portion with decode(errors="replace") or check only the restored magic bytes (x89PNG, PK). Verifying a binary’s correct answer isn’t "is it readable" but "does the signature match."

Wall 2. It’s a known-plaintext attack but the keystream looks wrong

Symptom: the keystream computed from the signature doesn’t repeat (e.g., random-looking instead of K3YK3YK3).
Cause: even if the key’s repetition period (3 bytes here) and the signature length (8 bytes) don’t align, matching just the first 3 bytes is enough. If no repetition shows, your period estimate is wrong, or it wasn’t a repeating-key XOR to begin with.
Fix: first decrypt the whole thing with the front of the recovered keystream, and if the tail breaks, retry with different period candidates (2, 3, 4, 5…). If it still fails, leave open the possibility it’s some scheme other than XOR.

Wall 3. struct.unpack error

Symptom: struct.error: unpack requires a buffer of 30 bytes.
Cause: the file is smaller than 30 bytes, or you opened a file that isn’t a ZIP.
Fix: check the file size and signature first — whether raw[:4] == b"PKx03x04" is the start of ZIP identification (Step 239 review).

Wall 4. The dictionary attack never matches

Symptom: you ran the whole wordlist with no MATCH.
Cause: (1) the real password isn’t in the list — a normal failure. (2) the hash algorithm differs — the target may be MD5 or a salted hash rather than SHA-256.
Fix: estimate the algorithm from the hash length (32 hex chars = MD5, 40 = SHA-1, 64 = SHA-256 — tools like hashid automate this). If a salt is present, you must hash the candidate together with the salt.

Wall 5. Despair — "an AES-locked piece of evidence can never be opened"

Symptom: you hit an AES ZIP or a strong password and nearly gave up.
Cause: you only thought about technical breakthroughs.
Fix: in forensics, unlocking has many routes beyond technique — other files reusing the same password, a memo with the password written down, browser-saved passwords, legal procedures (the owner’s cooperation). And custom wordlists (birthdays, phone numbers, organization-name variants) succeed more often than you’d think. When technique is blocked, go through context — that’s investigation too.


7. Summary

Today’s Concepts

Concept One-line explanation
Brute force Try every possible key — a 1-byte XOR is done in 256
Known-plaintext attack ciphertext ^ plaintext = key — a file signature is free plaintext
ZIP local header PKx03x04 + flags (bit 0 = encryption) + method — the skeleton reads even when locked
ZipCrypto vs AES The legacy scheme’s keys are recoverable with 12 bytes of plaintext (bkcrack); AES allows only the dictionary attack
Dictionary attack Compute candidate passwords forwards and compare against the target hash
Custom wordlist A candidate list built from case clues (dates, organization names, filenames) — the core of success rate

Today’s Commands & Code

Code What it does
bytes(b ^ k for b in data) Apply/remove one layer of XOR with key k
for k in range(256) + scoring function Brute-force a single-byte key
enc[:8] ^ magic Compute the keystream from a signature
struct.unpack("<IHHHHHIIIHH", raw[:30]) Parse a ZIP local header
hashlib.sha256(w.encode()).hexdigest() Compare dictionary-candidate hashes

An Instinct More Important Than Commands

Today’s three keys are really one principle — the weakness is never in the encryption algorithm but in its surroundings. The key space is small (brute force), the format standard gives away plaintext (known-plaintext), or a human picked a predictable password (dictionary attack). Said the other way, the way to build a strong lock is to block those same three: long keys, standard methods (AES), random passwords.

And one last instinct: in practice, tools like zip2john + john, hashcat, and bkcrack do today’s Python code for you. But judging "which attack works on this file" is not the tool’s job — it’s yours: reading the header, identifying the method, picking the key. Today you laid the foundation of that judgment with your own hands.


Once every box is checked, Step 247 is complete.