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.