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 |
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.
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.
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.
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.
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.
The flag is deliberately hidden — follow the method, you've earned it. 💪
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.
picoCTF{) is often that known fragmentBreaking an RSA key with a too-small modulus by factoring p and q.
Discuss this writeup with the community on the CTFdojo Discord.
Join the Discord →