PicoCTF Easy1 Writeup — Recover an XOR Key with Crib Dragging

Crypto 2026-09-21 · picoGym · By CTFdojo · ⏱ ... · 👁 ... views
𝕏 Share
TL;DR

The flag is XOR-encrypted with a short repeating key. Since we know every picoCTF flag starts with picoCTF{, we XOR that known text ("crib") with the beginning of the ciphertext to recover the first bytes of the key, then complete the key by finding the repeating pattern.

PlatformpicoGym
CategoryCryptography
Points200 pts
DifficultyIntermediate
TechniqueXOR, crib dragging

Challenge description

The challenge provides a hex-encoded ciphertext file, along with the Python script used to encrypt it:

KEY = ???  # unknown, short key

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

with open("flag.txt", "rb") as f:
    flag = f.read()

ciphertext = encrypt(flag, KEY)
print(ciphertext.hex())
$ cat ciphertext.txt
3d262c3c061909340c1a1210100c1a1210100c1a1210100c1a1210102e

The KEY is unknown, but the encryption scheme is crystal clear: repeated byte-by-byte XOR, cycling through the key (i % len(key)). It's a classic construction — and classically vulnerable as soon as you know a fragment of the plaintext.

Step 1 — Understand XOR's self-inverse property

The XOR operator has a mathematical property that's very handy for the attacker: it's its own inverse. If ciphertext = plaintext XOR key, then:

plaintext XOR ciphertext = plaintext XOR (plaintext XOR key) = (plaintext XOR plaintext) XOR key = 0 XOR key = key

In other words: as soon as you know both a chunk of plaintext and its corresponding ciphertext, you can recover the key directly, without having to guess it.

Step 2 — Crib dragging with the known prefix

Every picoCTF flag follows the same format: it starts with picoCTF{. That's our "crib" (known plaintext). We XOR this crib with the first bytes of the ciphertext to deduce the first bytes of the key:

ciphertext_hex = "3d262c3c061909340c1a1210100c1a1210100c1a1210100c1a1210102e"
ciphertext = bytes.fromhex(ciphertext_hex)

crib = b"picoCTF{"

key_fragment = bytes(c ^ k for c, k in zip(ciphertext, crib))
print(key_fragment)
b'MOOSEMOO'

We get a key fragment. A pattern jumps out right away: the first 5 bytes MOOSE repeat at the start of the next 3 bytes (MOO) — a clear sign that the real key is 5 bytes long and we simply went past one period with an 8-character crib.

Step 3 — Determine the key length

Looking at key_fragment = MOOSEMOO, the most natural hypothesis is a 5-byte key: MOOSE, which indeed starts over at the 6th position (the following M, since the 8-character crib overruns one period of the 5-byte key).

key = b"MOOSE"
print(len(key))  # 5

This is consistent: a short 5-byte key cycling over the whole message, exactly as expected from the encryption script given in the challenge.

Step 4 — Decrypt the full message

With the complete key in hand, we decrypt the whole file:

from itertools import cycle

ciphertext = bytes.fromhex(ciphertext_hex)
key = b"MOOSE"

plaintext = bytes(c ^ k for c, k in zip(ciphertext, cycle(key)))
print(plaintext.decode())
picoCTF{____________________}

The picoCTF{ prefix confirms the recovered key is correct, and the rest of the message decrypts cleanly through to the closing brace.

🚩 picoCTF{flag intentionally hidden}

The flag is deliberately hidden — follow the method, you've earned it. 💪

Key takeaways

This challenge illustrates a known-plaintext attack on short repeating-key XOR: knowing just a fragment of the plaintext is enough to recover the entire key, and therefore the entire message.

Resources

Related reading

Crypto 2026-11-30

PicoCTF Mind Your Ps and Qs Writeup — Factor a Weak RSA Modulus

Breaking an RSA key with a too-small modulus by factoring p and q.

Got a question or a different approach?

Discuss this writeup with the community on the CTFdojo Discord.

Join the Discord →