Syskron Security CTF: EPES2
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
- 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).
- 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.
- The sender sends the encrypted message and the epoch timestamp for validation.
- 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}