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
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Identify the key lengthObservationI noticed the challenge involves a one-time pad over a network server, which suggested the keystream must have a fixed, finite size; knowing when it cycles is the prerequisite for every key-reuse attack.Read the provided source - the key length is almost always a hardcoded constant (here, 50000). If only the binary is available, check any key file size, or send a long known plaintext and watch where the ciphertext starts repeating.bashgrep -n -i 'key.*=' easy-peasy.py 2>/dev/nullbashwc -c key 2>/dev/nullWhat didn't work first
Tried: Guess the key length is 256 or 512 based on common AES block sizes.
AES block sizes are irrelevant here - this is a raw XOR stream cipher, not AES. Using 256 or 512 means you send the wrong number of padding bytes, the offset never wraps back to 0, and the server encrypts the second plaintext with a different key region than the flag. The actual period is 50000, which is stated as a constant in the source code and can be confirmed with wc -c on the key file.
Tried: Skip reading the source and infer key length empirically by sending repeated known-plaintext blocks and XORing consecutive ciphertext chunks.
This approach works in theory but requires encrypting tens of thousands of bytes in multiple short requests to find where the ciphertext repeats, which is extremely slow over a netcat session with round-trip latency. The source is provided, so reading the hardcoded constant is always faster and more reliable than statistical inference.
Learn 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 2
Get the encrypted flagObservationI noticed the server immediately outputs a hex string upon connection, which suggested this is the flag XORed with the beginning of the keystream and must be captured before any other interaction shifts the key offset.Connect 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 3
Exhaust the keyObservationI noticed the server maintains a global offset that increments with each encryption request, which suggested sending exactly 49968 bytes after the 32-byte flag would advance the counter to 50000 and wrap it back to offset 0.The key is exactly 50000 bytes and the server tracks a usage offset. The flag is 32 bytes so the offset is at 32 after receiving the encrypted flag. Send 49968 bytes of known plaintext to advance the offset to 50000, which wraps back to 0.Learn more
The server maintains a global offset into its 50000-byte key and increments it with every encryption request. After it encrypts the flag (32 bytes), the offset sits at 32. Sending 49968 more bytes advances it to 50000, which wraps back to 0 - returning to the same key bytes that encrypted the 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 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 4
Retrieve the key at offset 0ObservationI noticed that XORing all-zero bytes with the keystream yields the raw key bytes directly, and since the offset was just reset to 0, sending zeros here reveals exactly the same key material that encrypted the original flag.Now ask the server to encrypt another 32-byte block of known plaintext (e.g., all zeros). Since the offset is back at 0, the server XORs your zeros with the first 32 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..31]- which is simply the first 32 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 5
Recover the flagObservationI noticed we now hold both the encrypted flag and the key bytes that produced it, which meant a simple byte-by-byte XOR using pwntools would reverse the encryption and reveal the plaintext flag.XOR 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.pythonpython3 << '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 remaining key bytes with known zeros # Key is 50000 bytes; flag was 32 bytes, so send 49968 to wrap offset back to 0 chunk = b'\x00' * 49968 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() EOFWhat didn't work first
Tried: Send 50000 zero bytes in step 2 instead of 49968 to exhaust the key.
The flag is 32 bytes, so the server's offset is already at 32 after the encrypted flag is sent. Sending 50000 more bytes advances the offset to 50032, which wraps to 32 - not 0. The next encryption then uses key bytes starting at offset 32, which were never used on the flag. XORing those bytes with the encrypted flag produces garbage, not plaintext.
Tried: Decode the encrypted flag directly without a server round-trip by guessing that the key starts with null bytes or a fixed pattern.
There is no known pattern to the key - it is read from an opaque key file on the server. Without the actual key bytes, any assumed key just produces random-looking output when XORed with the ciphertext. The exploit depends on making the server encrypt known zeros at the same offset the flag occupied, which hands the raw key bytes back to you.
Learn 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
Reveal flag
picoCTF{...}
The key is 50000 bytes and cycles - send 49968 bytes after receiving the encrypted flag to wrap the offset back to 0, then exploit the known-plaintext.