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.
- Platform: picoGym
- Category: Cryptography
- Points: 200 pts
- Difficulty: Intermediate
- Technique: XOR, 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.
- XOR with a short repeating key is vulnerable as soon as part of the plaintext is known — the fixed format of CTF flags (
picoCTF{) is often that known fragment - Never reuse a short XOR key across multiple blocks or messages: it's the same structural weakness as the Vigenère cipher
- For real confidentiality needs, prefer a modern authenticated cipher like AES-GCM or ChaCha20-Poly1305, never repeated XOR
Originally published on CTFdojo — join the CTFdojo Discord to discuss writeups and get notified about new ones.
Top comments (0)