We’re given the ZIP file epes2.zip containing several files.

Archive:  epes2.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
     3861  2020-09-23 12:08   CONFIDENTIAL-epes2.rst
      117  2020-09-10 09:53   libreplc-config-plc1.json
      117  2020-09-10 09:54   libreplc-config-plc2.json
      357  2020-09-10 12:21   libreplc-config-shared-keys-plc1.json
      179  2020-09-10 12:22   libreplc-config-shared-keys-plc2.json
      537  2020-09-10 11:23   m1.out
      156  2020-09-10 11:23   m2.out
      150  2020-09-10 11:19   m3.out
      150  2020-09-10 11:19   m4.out
      150  2020-09-10 11:19   m5.out
      150  2020-09-10 11:20   m6.out
      150  2020-09-10 11:20   m7.out
      150  2020-09-10 11:20   m8.out
---------                     -------
     6224                     13 files

The file CONFIDENTIAL-epes2.rst describes the “Enhanced PLC Encryption Standard 2 (EPES2),” a fictitious encryption protocol. The file contains quite some irrelevant text for the challenge, so let’s first look at the other files.

The files libreplc-config-plc?.json and libreplc-config-shared-keys-plc?.json are JSON files containing information about the fictional PLCs, but all the sensitive cryptographic material is redacted and reads “read protected [use keygen].” The m?.out files are more interesting: they just contain a hex string. For example, the contents of m3.out is: 82 a9 74 69 6d 65 73 74 61 6d 70 ce 5f 7a b6 02 b0 65 6e 63 72 79 70 74 65 64 4d 65 73 73 61 67 65 b0 30 78 62 32 62 35 39 31 65 61 39 34 63 37 30 34. With a bit of fiddling around, we found out that these raw bytes can be decoded using MessagePack.

#!/usr/bin/env python3
from pathlib import Path

import msgpack


path = Path('.')
for f in sorted(path.glob('*.out')):
    contents = f.read_text().strip()
    hexstring = contents.replace(' ', '')
    msg = msgpack.unpackb(bytes.fromhex(hexstring))
    print(msg)

Running the script gives us the following output.

{'protocol': 'epes', 'protocolVersion': 2, 'protocolMessage': 'SYN', 'scryptSalt': 'dGhlTW9zdFNlY3VyZUNyeXB0b1Byb3RvY29s', 'hmacSha': 256, 'aesKeySize': 192, 'aesIv': '0x86ce20cec9f4dbd8f5a9df51dd63b5a0', 'aesMode': 'cfb'}
{'protocol': 'epes', 'protocolVersion': 2, 'protocolMessage': 'ACK'}
{'timestamp': 1601877506, 'encryptedMessage': '0xb2b591ea94c704'}
{'timestamp': 1601877534, 'encryptedMessage': '0xb77c347a2619d3'}
{'timestamp': 1601877557, 'encryptedMessage': '0x6c69dfad4f3ff9'}
{'timestamp': 1601877599, 'encryptedMessage': '0x24ed9df539ab08'}
{'timestamp': 1601877681, 'encryptedMessage': '0x5450c732c60508'}
{'timestamp': 1601877704, 'encryptedMessage': '0xf00d34f319fa5d'}

So we have six AES-192 ciphertexts, encrypted using Cipher Feedback (CFB) mode and IV 86ce20cec9f4dbd8f5a9df51dd63b5a0.

Let’s return to the CONFIDENTIAL-epes2.rst file. For the challenge, only the following excerpt is relevant.

C. Encrypted message exchange

  1. For each message, the sender uses RFC 6238 to generate an ephemeral key. The input is the shared secret (see B). The output is an 8-digit key. The HMAC size for RFC 6238 is defined in the handshake (see A).
  2. The sender uses the ephemeral key (UTF-8) and the AES parameters (see A) to encrypt the message. For AES-128, the ephemeral key is stretched by duplication, resulting in a 16-digit key. For AES-192, the key is stretched to 24 digits.
  3. The sender sends the encrypted message and the epoch timestamp for validation.
  4. The receiver checks the epoch timestamp. If it deviates more than 5 seconds from the receiver’s current time, the communication is aborted. If the deviation is less than 5 seconds, the receiver conducts the same steps to decrypt the message.

What this basically tells us, is that each ciphertext is encrypted using an AES key consisting of eight digits repeated three times. This is quite a small key space as it can be enumerated in just 100.000.000 steps.

for key in range(0, 100_000_000):
    key = str(key).zfill(8) * 3

The problem is that cannot easily determine whether a candidate keys is the correct decryption key, or whether it just decrypted to some random value. However, we do know that the plaintext is likely the flag and that the flag format is syskronCTF{...} where ... is likely matching the pattern [a-zA-Z0-9_-].

Let’s decrypt the ciphertext using our candidate keys and write the resulting plaintext to a file for later examination. Running six instances of the following script (one for each ciphertext) took a bit over an hour on my laptop.

#!/usr/bin/env python3
from pathlib import Path
import sys

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
import msgpack
import tqdm


iv = 0x86ce20cec9f4dbd8f5a9df51dd63b5a0
iv = iv.to_bytes(16, 'big')

f = Path(sys.argv[1])
contents = f.read_text().strip()
hexstring = contents.replace(' ', '')
msg = msgpack.unpackb(bytes.fromhex(hexstring))
ct = int(msg['encryptedMessage'], 16).to_bytes(7, 'big')

with open(f'{sys.argv[1]}-pt.txt', 'w') as fh:
    for key in tqdm.trange(0, 100_000_000):
        key = str(key).zfill(8) * 3
        cipher = Cipher(algorithms.AES(key.encode()), modes.CFB(iv))
        decryptor = cipher.decryptor()
        pt = decryptor.update(ct) + decryptor.finalize()
        try:
            pt = pt.decode('ascii')
        except UnicodeError:
            continue
        if not pt.isprintable():
            continue
        fh.write(f"{key}: {pt}\n")

We know that the flag begins with syskron, so by seeing key 212212902122129021221290 decrypting the first ciphertext to syskron, we knew that we were on the right track. The second ciphertext should then start with CTF{ and so we found 980375419803754198037541 decrypting to CTF{get. The next ciphertext were a bit more challenging. We could refine the file written by our script doing some grepping over the output, e.g., by assuming the flag is lower case and spaces are separated by a - or _, we find using grep -E '[a-z_-]{7}$' m5.out-pt.txt the next part of the flag, ting-th. Continuing for the next ciphertexts in a similar fashion, we found the entire flag.

212212902122129021221290: syskron
980375419803754198037541: CTF{get
023686830236868302368683: ting-th
609260836092608360926083: e-flag-
276201662762016627620166: was-a-P
438198394381983943819839: ITA} (:

syskronCTF{getting-the-flag-was-a-PITA}