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 1Identify the key length
ObservationThis is a one-time pad served over a network, so the keystream has a fixed, finite size. Knowing when it cycles is the prerequisite for any 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: this is a raw XOR stream cipher. Use 256 or 512 and you send the wrong number of padding bytes, the offset never wraps to 0, and the server encrypts your second plaintext with a different key region than the flag. The period is 50000, stated as a constant in the source and confirmable 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 works in theory but means encrypting tens of thousands of bytes across many short requests to find where the ciphertext repeats, which is painfully slow over a netcat session with round-trip latency. The source is provided, so reading the constant beats inferring it.
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 2Get the encrypted flag
ObservationThe server prints a hex string the moment you connect. That is the flag XORed with the start of the keystream, and it has to 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 3Exhaust the key
ObservationThe server keeps a global offset that advances with every encryption request. After the 32-byte flag, sending exactly 49968 more bytes pushes the counter to 50000, which wraps it back to 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 4Retrieve the key at offset 0
ObservationXORing all-zero bytes against the keystream returns the raw key bytes. With the offset just reset to 0, sending zeros now hands back exactly the key material that encrypted the 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 5Recover the flag
ObservationWe now hold both the encrypted flag and the key bytes that produced it. A byte-by-byte XOR reverses the encryption and gives the plaintext.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 offset already sits at 32 once the encrypted flag has been sent. Send 50000 more and the offset reaches 50032, which wraps to 32 rather than 0. The next encryption then uses key bytes from offset 32, which never touched the flag, so XORing them against it gives garbage.
Tried: Decode the encrypted flag directly without a server round-trip by guessing that the key starts with null bytes or a fixed pattern.
The key has no known pattern; it is read from an opaque file on the server. Without the real bytes, any assumed key XORs the ciphertext into noise. The exploit works by making the server encrypt known zeros at the same offset the flag occupied, which hands the raw key bytes straight back.
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.