SunshineCTF 2025 Writeup - Plutonian Crypto

The core problem is that the same secret message (P) is being encrypted repeatedly with a predictable, but incrementing, counter. Because the message is longer than the known plaintext, we must retrieve two consecutive ciphertexts (C0 and C1) to piece together the full encryption keystream (K0).

SunshineCTF 2025 Writeup - Plutonian Crypto

Challenge:

Step 1: The Vulnerability (Known-Plaintext + Repeating Message)

In CTR mode, the encryption process is simple:

Ciphertext=Plaintext⊕Keystream

C=P⊕K

The keystream (K) is generated by encrypting a unique Nonce + Counter value with the secret key (KEY).

The server code confirms two critical facts:

  1. The Plaintext (P) is constant (it's the secret MESSAGE).
  2. The Keystream (K) changes because the counter (C) is incremented for each transmission.
Transmission Counter Value Keystream Ciphertext
0 (First) C=0 K0 C0=P⊕K0
1 (Second) C=1 K1 C1=P⊕K1

The First Breakthrough (Initial Keystream)

We are given that the message starts with "Greetings, Earthlings." We use the first 16 bytes of this to recover the first 16 bytes of the keystream (K0).

K0[0:16]=C0[0:16]⊕"Greetings, Earthli"

The Second Breakthrough (Keystream Identity)

Because the counter is 64 bits and the nonce is 64 bits, the AES blocks are generated by encrypting the 16-byte value (Nonce∥Counter).

The counter for K0 starts at 0 and increments: 0,1,2,3,… The counter for K1 starts at 1 and increments: 1,2,3,4,…

This means that the K1 keystream is simply the K0 keystream shifted by one 16-byte block!

K1[0:16]=K0[16:32]

K1[16:32]=K0[32:48]

Step 2: Retrieving Ciphertexts and Deriving the XOR Difference

We use the pwntools library to connect to the network service and retrieve the first two ciphertexts (C0 and C1). Then, we calculate the XOR difference, D=C0⊕C1.

D=C0⊕C1=(P⊕K0)⊕(P⊕K1)=K0⊕K1

This difference (D) allows us to link the blocks of K0 together.

Code Snippet: Retrieve and Calculate D

# 1. Retrieve C0 and C1
conn = remote('chal.sunshinectf.games', 25403)
conn.recvuntil(b"== BEGINNING TRANSMISSION ==\\n\\n")
hex_c0 = conn.recvline().strip()
hex_c1 = conn.recvline().strip()
conn.close()

C0 = binascii.unhexlify(hex_c0)
C1 = binascii.unhexlify(hex_c1)

# 2. Calculate D = C0 XOR C1 = K0 XOR K1
D = bytes([c0 ^ c1 for c0, c1 in zip(C0, C1)])
L = len(C0)
BLOCK_SIZE = 16

Step 3: Chain-Recovering the Full Keystream (K0)

We can now use the identity K0⊕K1=D and the shift property K1[i]=K0[i+1] to recover the entire K0 stream, 16 bytes at a time.

For any block i:

K0[i]⊕K1[i]=D[i]

Substituting the shift property:

K0[i]⊕K0[i+1]=D[i]

We can rearrange this to solve for the next block K0[i+1]:

K0[i+1]=K0[i]⊕D[i]

The Recovery Process

  1. Block 0 (i=0): Use known plaintext.K0[0]=C0[0]⊕Pknown[0]
  2. Block 1 (i=1): Use the formula with Block 0's results.K0[1]=K0[0]⊕D[0]
  3. Block 2 (i=2):K0[2]=K0[1]⊕D[1]
  4. ...and so on, until the full 417-byte keystream is recovered.

Code Snippet: Iterative Keystream Recovery

KNOWN_PLAINTEXT_16 = b"Greetings, Earthli" # 16 bytes

# Initialize the recovered keystream K0
K0 = bytearray(L)

# 1. Recover K0 Block 0 (the anchor) using known plaintext
K0_block0 = bytes([c0 ^ p for c0, p in zip(C0[:BLOCK_SIZE], KNOWN_PLAINTEXT_16)])
K0[:BLOCK_SIZE] = K0_block0

# 2. Iteratively recover subsequent blocks
for i in range(1, L // BLOCK_SIZE + 1): # L // BLOCK_SIZE is the number of 16-byte blocks
    
    # Define the byte ranges for the previous block and the XOR difference (D)
    prev_start = (i - 1) * BLOCK_SIZE
    start = i * BLOCK_SIZE
    end = min(start + BLOCK_SIZE, L) # Stop at the total length L
    
    # K0[current block] = K0[prev block] XOR D[prev block]
    K0_next_block = bytes([k_prev ^ d_prev for k_prev, d_prev in zip(K0[prev_start:start], D[prev_start:start])])
    
    # Store the recovered bytes in the K0 array
    K0[start:end] = K0_next_block[:(end-start)] # Handle partial final block

Step 4: Final Decryption

With the full keystream K0 recovered, we can now decrypt the full message (P) using the very first ciphertext (C0):

P=C0⊕K0

Code Snippet: Decrypt and Print

# Decrypt the full message P = C0 XOR K0
full_plaintext_bytes = bytes([c ^ k for c, k in zip(C0, K0)])

# Decode and print the result
try:
    flag = full_plaintext_bytes.decode('ascii')
    print("\\n--- DECRYPTED MESSAGE ---")
    print(f"Decrypted Message: {flag}")
    print("-------------------------")
except UnicodeDecodeError:
    print("Decryption successful, but contains non-ASCII characters.")

This final step yields the full secret message, including the flag.

Attack successful!!!