Description
A one-time pad is truly unbreakable... as long as you never reuse the key. Connect to the server and break the OTP.
Setup
Connect via netcat.
nc mercury.picoctf.net <PORT>Solution
Walk me through it- Step 1Identify the key lengthRead the provided source - the key length is almost always a hardcoded constant (here, 40000). If only the binary is available, check any key file size, or send a long known plaintext and watch where the ciphertext starts repeating.bash
grep -n -i 'key.*=' easy-peasy.py 2>/dev/nullbashwc -c key 2>/dev/nullLearn more
Why key length matters. When the keystream cycles before your message ends, ciphertext at offset
iand offseti + Lshare the same key byte (whereLis the period). If you control either plaintext, XORing the two ciphertexts cancels the key entirely:c1 XOR c2 = p1 XOR p2. The whole exploit depends on knowing the period exactly. - Step 2Get the encrypted flagConnect to the server. It immediately sends the encrypted flag as a hex string - this is the flag XORed with the key starting at offset 0. Save this value.
Learn more
A one-time pad (OTP) is a theoretically unbreakable encryption scheme where the key is as long as the message and is used exactly once. Encryption is performed by XORing each plaintext byte with the corresponding key byte:
ciphertext = plaintext XOR key. Because XOR is its own inverse, decryption uses the identical operation:plaintext = ciphertext XOR key.The fundamental rule is that the key must never be reused. If the same key bytes encrypt two different plaintexts, an attacker who knows one plaintext can recover the other:
c1 XOR c2 = p1 XOR p2. This vulnerability is why "one-time" is in the name - the moment the key is reused, the security guarantee evaporates entirely.The server sends the flag encrypted at key offset 0. Your goal is to force the server to reveal those same key bytes so you can undo the encryption.
- Step 3Exhaust the keyThe key is exactly 40000 bytes and the server tracks a usage offset. Send exactly 40000 bytes of known plaintext (e.g., all zeros). This advances the offset all the way to the end, causing it to wrap back to 0.
Learn more
The server maintains a global offset into its 40000-byte key and increments it with every encryption request. By sending exactly 40000 bytes, you push the offset from its current position to the end of the key buffer, at which point it wraps back to 0 - returning to the same key bytes that encrypted the original flag.
Sending all-zero bytes is the ideal known plaintext:
0x00 XOR key_byte = key_byte, meaning the server's response is simply the raw key bytes. This is a classic known-plaintext attack - you choose the message, so you immediately know the relationship between input and output.The 40000-byte input needs to be sent as a hex string because the server reads hex-encoded input. The response (the "encrypted" zeros) is the key itself, but since you're just draining the buffer here, you discard it.
- Step 4Retrieve the key at offset 0Now ask the server to encrypt another 50-byte block of known plaintext (e.g., all zeros). Since the offset is back at 0, the server XORs your zeros with the first 50 bytes of the key - giving you the raw key bytes.
Learn more
With the offset reset to 0, encrypting all-zero bytes causes the server to output
0x00 XOR key[0..49]- which is simply the first 50 bytes of the key, in plain view. This is the same portion of the key that was used to encrypt the original flag, so you now have everything needed to reverse the encryption.This technique - deliberately controlling the server's state to expose key material - is a form of chosen-plaintext attack. You're not breaking the cipher mathematically; you're exploiting a stateful design flaw that allows key reuse.
- Step 5Recover the flagXOR the encrypted flag bytes with the recovered key bytes. Because XOR is its own inverse and the key is the same, you get the original plaintext.python
python3 << 'EOF' from pwn import * io = remote("mercury.picoctf.net", <PORT>) # Step 1: get encrypted flag enc_flag = bytes.fromhex(io.recvline().strip().decode()) # Step 2: exhaust the 40000-byte key with known zeros chunk = b'\x00' * 40000 io.sendline(chunk.hex().encode()) io.recvline() # discard server response # Step 3: encrypt zeros at offset 0 to learn the key io.sendline((b'\x00' * len(enc_flag)).hex().encode()) key_bytes = bytes.fromhex(io.recvline().strip().decode()) # Step 4: XOR to recover the flag flag = bytes([a ^ b for a, b in zip(enc_flag, key_bytes)]) print(flag.decode()) io.close() EOFLearn more
pwntools is the standard Python library for CTF exploitation. The
remote()function opens a TCP connection and providessendline()/recvline()methods to interact with the server programmatically. This makes it trivial to script multi-step protocol interactions like the key exhaustion attack here.The final XOR is performed byte-by-byte using a list comprehension with
zip(), which pairs each encrypted byte with the corresponding key byte. XOR is commutative and self-inverse:(plaintext XOR key) XOR key = plaintext. As long as the key bytes are identical between the two encryptions - which the wrap-around guarantees - the flag is recovered perfectly.Real-world lesson: Stateful key streams are dangerous whenever the state can be manipulated externally. Real OTP systems use hardware random number generators and destroy the key material after a single use. Stream ciphers like RC4 famously suffered related vulnerabilities when used in WEP WiFi encryption, where the same keystream was reused across packets.
Alternate Solution
After forcing the key reuse and obtaining the XOR-encrypted ciphertext, use the XOR Cipher tool on this site to perform the final decryption - paste the hex ciphertext, enter the known key bytes, and the plaintext flag appears instantly without writing any Python.
Flag
picoCTF{...}
The key is 40000 bytes and cycles - send exactly 40000 bytes to force a key reuse, then exploit the known-plaintext.