edit

TJCTF 2025: crypto/double-trouble

Twice the encryption, half the security.

Author: tmm

We’re given some ciphertext and the source code that genrated it.

out.txt
7125383e330c692c75e0ee0886ec7779
9ecba853742db726fb39e748a0c5cfd06b682c8f15be13bc8ba2b2304897eca2
enc.py
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import random

def gen():
    myrandom = random.Random(42)
    k1 = myrandom.randbytes(8)
    choices = list(myrandom.randbytes(6))
    k2 = b''
    for _ in range(8):
        k2 += bytes([ choices[random.randint(0, 3)] ])
    return k1, k2

def enc(data, k1, k2,  k3, k4):
    key1 = k1+k2
    cipher = AES.new(key1, mode=AES.MODE_ECB)
    ct1 = cipher.encrypt(pad(data, 16))

    key2 = k4+k3
    cipher = AES.new(key2, mode=AES.MODE_ECB)
    ct2 = cipher.encrypt(ct1)
    return ct2

k1, k2 = gen()
k3, k4 = gen()

pt = b"example"

with open('flag.txt') as f:
    flag = f.read().encode()

with open('out.txt', "w") as f:
    f.write(enc(pt, k1, k2, k3, k4).hex())
    f.write("\n")
    f.write(enc(flag, k1, k2, k3, k4).hex())

A gen function is used to generate two 8-byte key parts, and an enc function encrypts a plaintext twice (using AES ECB), using 4 of the key parts.

Crucially, we’re actually given two ciphertexts, one with the known plaintext b"example", the other being the flag.

Hence, we can employ a meet-in-the-middle attack. If the keys are actually random though, this isn’t practical (we’d be doing 21292^{129} operations), so let’s look at gen

First, gen uses a PRNG seeded with a constant to generate the first 8-byte key:

myrandom = random.Random(42)
k1 = myrandom.randbytes(8)

…Which means the k1 and k3 passed to enc are always the same, and we can trivially generate them. This cuts the bits in half, bringing us down to 2652^{65}, assuming the second part is ideal.

Which, well, it isn’t. The second key part is generated by concatenating 8 random… choices among 4 constant (generated by the seeded PRNG) bytes:

choices = list(myrandom.randbytes(6))
k2 = b''
for _ in range(8):
    k2 += bytes([ choices[random.randint(0, 3)] ])

We can go through all 84=4096=2128^4 = 4096 = 2^{12} of the “keys” that can possibly be generated this way with a quick and simple loop. There’s still two keys, but 2132^{13} operations is pretty much nothing.

Let’s do those things:

import random
import itertools

badrng = random.Random(42)
k_constant = badrng.randbytes(8)
k_choices = list(badrng.randbytes(6)[:4])

def biasedkeys():
	for p in itertools.product(k_choices, repeat=8):
		yield bytes(p)

Then comes the actual meet-in-the middle attack. First, encrypt the known plaintext with all possible keys.

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad

pt = pad(b"example", 16)

middles = dict()

for k2 in biasedkeys():
	ct = AES.new(k_constant + k2, mode=AES.MODE_ECB).encrypt(pt)
	middles[ct] = k2

And second, decrypt the ciphertext with all possible keys until we hit one which we’ve figured out as a middle:

key1 = b""
key2 = b""

ct2 = bytes.fromhex("7125383e330c692c75e0ee0886ec7779")
for k4 in biasedkeys():
	ct = AES.new(k4 + k_constant, mode=AES.MODE_ECB).decrypt(ct2)
	if (v := middles.get(ct)) is not None:
		key1 = k_constant + v
		key2 = k4 + k_constant

Now that we’ve got the keys, decrypt and win!

flagct = bytes.fromhex("9ecba853742db726fb39e748a0c5cfd06b682c8f15be13bc8ba2b2304897eca2")

aes2 = AES.new(key2, mode=AES.MODE_ECB)
aes1 = AES.new(key1, mode=AES.MODE_ECB)
flagpt = aes1.decrypt(aes2.decrypt(flagct))
print(flagpt)

Flag: tjctf{m33t_in_th3_middl3}


HomeAboutContact