Crypto Notes – Meet-in-the-Middle on RSA–AES Hybrid

Date: 2025-12-07
Box: HTB — ReMeeting the Wheel (Crypto Study Note)

Breaking a Hybrid RSA–AES Scheme with Meet-in-the-Middle

This challenge looks like a normal hybrid cryptosystem: RSA protects an AES key, and AES protects the flag. But the way the AES key is generated introduces a structure that completely breaks the security. The attack is a neat combination of RSA’s multiplicative property and a meet-in-the-middle strategy.

1. Challenge Setup

1.1. RSA key generation

class RSAGen:
    def __init__(self, bits):
        self.bits = bits
    
    def keygen(self):
        p = getPrime(self.bits//2)
        q = getPrime(self.bits//2)
        self.n = p*q
        self.e = 0x10001
        phi = (p-1)*(q-1)
        self.d = pow(self.e, -1, phi)
        return (self.n, self.e), (p, q, phi, self.d)

    def encrypt(self, m):
        return pow(m, self.e, self.n)
    
    def decrypt(self, c):
        return pow(c, self.d, self.n)

This is a totally standard 1024-bit RSA implementation:

We are only given the public key (n, e), not p, q, d, and we will not factor n at all.

1.2. AES key generation

class AESGen:
    def __init__(self, size):
        key1 = randint(1 << size, 1 << (size+1))
        key2 = randint(1 << size, 1 << (size+1))
        self.k = key1 * key2
        assert 40 <= self.k.bit_length() <= 42
        self.KEY = sha256(str(self.k).encode()).digest()

    def encrypt(self, m):
        cipher = AES.new(self.KEY, AES.MODE_ECB)
        enc_secret = cipher.encrypt(pad(m, 16))
        return enc_secret

Here, the AES key is derived in an unusual way:

So the true security of the AES key is bounded by ~40–42 bits of entropy in k, not by 256 bits.

1.3. Overall protocol

def main():
    rsa = RSAGen(1024)
    aes = AESGen(20)
    pubkey, _ = rsa.keygen()
    enc_aes_key = l2b(rsa.encrypt(aes.k))
    enc_secret = aes.encrypt(FLAG)

    with open('output.txt', 'w') as f:
        f.write("Bob :: Hi Alice, here is my public key:\n")
        f.write(f"({pubkey[0]}, {pubkey[1]})\n")
        f.write("Alice :: Hi Bob, here is my encrypted AES key, don't forget to sha256-hash it!\n")
        f.write(f"Encrypted AES Key = {enc_aes_key.hex()}\n")
        f.write("Bob :: Got it, here is the encrypted secret you requested:\n")
        f.write(f"Encrypted Secret = {enc_secret.hex()}")

The challenge gives us:

We want to recover FLAG without factoring n.

2. RSA’s Multiplicative Property

RSA is multiplicatively homomorphic:

Enc(m₁) · Enc(m₂) ≡ (m₁^e mod n) (m₂^e mod n) ≡ (m₁ m₂)^e mod n ≡ Enc(m₁ m₂) (mod n)

In our case:

k = key1 · key2

c = Enc(k) = k^e mod n = (key1 · key2)^e mod n = key1^e · key2^e mod n

Let:

Then the observed ciphertext c satisfies:

c ≡ A · B (mod n).

Rearranging:

B ≡ c · A^{-1} (mod n).

If we know A for some candidate key1, we can compute the corresponding B. If this B equals key2^e mod n for some valid key2, we have found the pair (key1, key2).

3. Naive Brute Force vs Structured Key Space

3.1. Brute forcing k directly

k is a 40–42 bit integer, so the naive search space is about:

2^{40} to 2^{42} ≈ 10^{12} possibilities.

For each candidate k, we would check whether k^e mod n = c. That’s too large for a typical CTF.

3.2. Exploiting the structure k = key1 · key2

Instead, we know:

That suggests a meet-in-the-middle approach:

  1. Precompute all possible key2^e mod n and store in a table.
  2. Iterate over all possible key1, compute A = key1^e mod n, derive B_target = c · A^{-1} mod n, and check if it appears in the table.

Time complexity becomes roughly:

O(2^{20}) + O(2^{20}) ≈ O(2^{21})

Instead of O(2^{40}). This is easily feasible.

4. Meet-in-the-Middle Attack

4.1. High-level algorithm

Step 1: Precompute table for key2

  1. For every k₂ ∈ [2^{20}, 2^{21}]:
    • Compute v = k₂^e mod n.
    • Store table[v] = k2.

Step 2: Loop over key1 and match

  1. For every k₁ ∈ [2^{20}, 2^{21}]:
    • Compute A = k₁^e mod n.
    • Compute the modular inverse A^{-1} mod n.
    • Compute B_target = c · A^{-1} mod n.
    • If B_target is in table, retrieve k₂ = table[B_target].

At this point, we have found a pair (key1, key2) such that:

key1^e · key2^e ≡ c (mod n) ⇒ (key1·key2)^e ≡ c (mod n).

So k = key1 · key2 is the correct AES pre-key.

4.2. Recovering the AES key and the flag

  1. Compute k = key1 · key2.
  2. Derive AES key:
    KEY = sha256(str(k).encode()).digest()
  3. Decrypt the given ciphertext using AES-ECB and unpad to get FLAG.
Note: We never need the RSA private key d, nor do we factor n. The attack purely uses the public key, the ciphertext c = RSA(k), and the fact that k lies in a small, structured subset of integers.

5. Reference Exploit Code

Below is a self-contained Python script that:

from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from hashlib import sha256

# ====== Given RSA public key and ciphertexts ======
n = 72741423208033405403492275698762968936514657314823442485453559870105200118330405900858299998747406127848670334407387228444029070060538220448699667905891284937839901536444798774307744865631349770845980738192442639071823803272283670943732769371979418893953521892212745135191661138195973757608889180051128601323
e = 65537

enc_aes_key_hex = "4da0230d2b8b46e3a7065f32c46df019739cc002a208cc37767a82c3e94edfc3440fa4b24a32274e35d5ddceaa33505c4f2a57281c3a5d6d4db3a0dbdbb30dbf373241319ce4a7fdd1780b6bafc86e37d283c9f9787c567434e2fc29c988fb05fd411fe4453ea40eb45fc41a423839b485e238dd2530fba284e9b07a0bba6546"
enc_secret_hex  = "ce8f36aa844ab00319bcd4f86460a10d77492c060b2c2a91615f4cd1f2d0702e76b68f1ec0f11d15704ba52c5dacc60018d5ed87368464acd030ce6230efdbff7b18cba72ccaa9455a6fe6021b908dd1"

c = int(enc_aes_key_hex, 16)

# ====== key1, key2 range ======
LOW  = 1 << 20
HIGH = 1 << 21   # randint upper bound is inclusive in original code

print(f"[+] Searching key1, key2 in [{LOW}, {HIGH}]")

# ====== Step 1: Build table for key2^e mod n ======
print("[+] Building table for key2^e mod n ...")
val_to_key2 = {}

for k2 in range(LOW, HIGH + 1):
    v = pow(k2, e, n)
    val_to_key2[v] = k2

print("[+] Table built. Entries:", len(val_to_key2))

# ====== Step 2: Loop over key1 and match ======
print("[+] Searching for matching key1, key2 ...")
found = False
key1 = key2 = None

for k1 in range(LOW, HIGH + 1):
    A = pow(k1, e, n)
    invA = pow(A, -1, n)          # modular inverse of A mod n
    target = (c * invA) % n       # should equal key2^e mod n

    if target in val_to_key2:
        key1 = k1
        key2 = val_to_key2[target]
        found = True
        print("[+] Found candidate!")
        print("    key1 =", key1)
        print("    key2 =", key2)
        break

if not found:
    print("[-] No key pair found in range?!")
    exit(1)

k = key1 * key2
print("[+] k =", k)
print("[+] bit_length(k) =", k.bit_length())

# ====== Step 3: Derive AES key and decrypt flag ======
KEY = sha256(str(k).encode()).digest()
cipher = AES.new(KEY, AES.MODE_ECB)

plaintext = unpad(cipher.decrypt(bytes.fromhex(enc_secret_hex)), 16)
print("[+] FLAG:", plaintext)

6. Complexity and Practicality

This is completely feasible in a typical CTF environment, even in pure Python.

7. Takeaways

In a real system, k should be a uniformly random value with at least 128 bits of entropy, and the RSA layer should be used in a well-studied KEM–DEM construction, not with ad-hoc key generation.