[Crypto] scriptCTF 2025 - Secure-Server-2 write-up

TL;DR

더블 AES Meet-in-the-Middle 문제

 

johndoe.py에선 메시지를 두 번 암호화한 결과를 출력하고 server.py에서는 그 결과를 받아 다시 두 번 암호화해서 준다.

그렇게 패킷 캡쳐가 된 파일(pcap)을 제공하는데, 4중 암호문을 복호화하면 된다.

 

 

Analysis

client: johndoe.py

from Crypto.Cipher import AES
k1 = b'AA' # Obviously not the actual key
k2 = b'AA' # Obviously not the actual key
message = b'scriptCTF{testtesttesttesttest!_' # Obviously not the actual flag
keys = [k1,k2]
final_keys = []
for key in keys:
    assert len(key) == 2 # 2 byte key into binary
    final_keys.append(bin(key[0])[2:].zfill(8)+bin(key[1])[2:].zfill(8))


cipher = AES.new(final_keys[0].encode(), mode=AES.MODE_ECB)
cipher2 = AES.new(final_keys[1].encode(), mode=AES.MODE_ECB)
enc2 = cipher2.encrypt(cipher.encrypt(message)).hex()

print(enc2)

to_dec = bytes.fromhex(input("Dec: ").strip())

secret = cipher.decrypt(cipher2.decrypt(to_dec))

print(secret.hex())

 

AES-ECB로 두 번 암호화: $C_2 = E_{K_2}(E_{K_1}(M))$

 

server: server.py

import os
from Crypto.Cipher import AES
print("With the Secure Server 2, sharing secrets is safer than ever! We now support double encryption with AES!")
enc = bytes.fromhex(input("Enter the secret, encrypted twice with your keys (in hex): ").strip())
# Our proprietary key generation method, used by the server and John Doe himself!
k3 = b'BB' # Obviously not the actual key
k4 = b'B}' # Obviously not the actual key
# flag = secret_message + k1 + k2 + k3 + k4 (where each key is 2 bytes)
# In this case: scriptCTF{testtesttesttesttest!_AAAABBB}
keys = [k3,k4]
final_keys = []
for key in keys:
    assert len(key) == 2 # 2 byte key into binary
    final_keys.append(bin(key[0])[2:].zfill(8)+bin(key[1])[2:].zfill(8))

cipher = AES.new(final_keys[0].encode(), mode=AES.MODE_ECB)
cipher2 = AES.new(final_keys[1].encode(), mode=AES.MODE_ECB)
enc2 = cipher2.encrypt(cipher.encrypt(enc)).hex()
print(f"Quadriple encrypted secret (in hex): {enc2}")
dec = bytes.fromhex(input("Decrypt the above with your keys again (in hex): ").strip())
secret = cipher.decrypt(cipher2.decrypt(dec))
print("Secret received!")

 

서버는 받은 $C_2$를 다시 두 번 암호화: $C_4 = E_{K_4}(E_{K_3}(C_2))$

 

클라이언트가 서버의 $C_4$를 받아 자신의 키로 역산한 값도 보낸다: $D=D_{K_1}(D_{K_2}(C_4))$

 

키는 모두 2바이트지만, 실제 키로 쓰일 땐

K_raw = [k[0], k[1]]
K_AES = (bin(k[0])[2:].zfill(8) + bin(k[1])[2:].zfill(8)).encode()
# 0/1로 구성된 16글자 ASCII == 16바이트 AES 키

AES-128-ECB가 된다.

 

pcap

1. 클라이언트가 서버에 제출한 2중 암호문 $C_2$

19574ac010cc9866e733adc616065e6c019d85dd0b46e5c2190c31209fc57727

 

2. 서버가 출력한 4중 암호문 $C_4$

0239bcea627d0ff4285a9e114b660ec0e97f65042a8ad209c35a091319541837

 

3. 클라이언트가 되돌려 보낸 자기 키로의 복호 결과 $D$

4b3d1613610143db984be05ef6f37b31790ad420d28e562ad105c7992882ff34

 

 

Exploitation

1. $(K_3, K_4)$ 복구: server-side 더블 AES

$$ C_4 = E_{K_4}(E_{K_3}(C_2)) \leftrightarrow E_{K_3}(C_2)=D_{K_4}(C_4) $$

1. 모든 $K_3 \in [0,2^{16})$에 대해 $X=E_{K_3(C_2)$를 테이블

2. 모든 $K_4$에 대해 $Y=D_{K_4}(C_4)$를 구하고 $X=Y$가 되는 지점 히트

 

2. $(K_1, K_2)$ 복구: client-side 더블 AES

$$ D = D_{K_1}(D_{K_2}(C_4)) \leftrightarrow D_{K_2}(C_4)=E_{K_1}(D) $$

1. 모든 $K_2$에 대해 $U=D_{K_2}(C_4)$ 테이블

2. 모든 $K_1$에 대해 $V=E_{K_1}(D)$ 계산 $\rightarrow U=V$ 매치

 

 

[1-2] 단계 거쳐서 $ O(2 \times 2^{16}) $ 밖에 안 거친다. (이해하기 편하게 빅오 표기법을 무시했습니다)

3. 평문 복구

평문은 $W = D_{K_1}(D_{K_2}(C_2))$으로 복구한다.

 

from pwn import *
from Crypto.Cipher import AES
from collections import defaultdict

context.log_level = 'debug'

enc2_hex   = "19574ac010cc9866e733adc616065e6c019d85dd0b46e5c2190c31209fc57727"
enc4_hex   = "0239bcea627d0ff4285a9e114b660ec0e97f65042a8ad209c35a091319541837"
dec_hex    = "4b3d1613610143db984be05ef6f37b31790ad420d28e562ad105c7992882ff34"

enc2 = bytes.fromhex(enc2_hex)
enc4 = bytes.fromhex(enc4_hex)
dec_resp = bytes.fromhex(dec_hex)

def u16_to_raw_bytes(u):
    return bytes([(u >> 8) & 0xFF, u & 0xFF])

def u16_to_aes_key(u):
    b0, b1 = (u >> 8) & 0xFF, u & 0xFF

    return (format(b0, '08b') + format(b1, '08b')).encode()

def enc(key, data): return AES.new(key, AES.MODE_ECB).encrypt(data)
def dec(key, data): return AES.new(key, AES.MODE_ECB).decrypt(data)

log.info("MITM #2 (k3,k4) 탐색")
tbl = defaultdict(list)
for k3 in range(0x10000):
    mid = enc(u16_to_aes_key(k3), enc2)
    tbl[mid].append(k3)

cands34 = []
for k4 in range(0x10000):
    mid2 = dec(u16_to_aes_key(k4), enc4)
    if mid2 in tbl:
        for k3 in tbl[mid2]:
            cands34.append((k3, k4))
assert len(cands34) >= 1
k3, k4 = cands34[0]
log.success(f"(k3,k4) = {k3:#06x}, {k4:#06x} | raw={u16_to_raw_bytes(k3).hex()} {u16_to_raw_bytes(k4).hex()}")

log.info("MITM #1 (k1,k2) 탐색")
tbl2 = defaultdict(list)
for k2 in range(0x10000):
    mid = dec(u16_to_aes_key(k2), enc4)
    tbl2[mid].append(k2)

cands12 = []
for k1 in range(0x10000):
    mid2 = enc(u16_to_aes_key(k1), dec_resp)
    if mid2 in tbl2:
        for k2 in tbl2[mid2]:
            cands12.append((k1, k2))
assert len(cands12) >= 1
k1, k2 = cands12[0]
log.success(f"(k1,k2) = {k1:#06x}, {k2:#06x} | raw={u16_to_raw_bytes(k1).hex()} {u16_to_raw_bytes(k2).hex()}")

P = dec(u16_to_aes_key(k1), dec(u16_to_aes_key(k2), enc2))
log.info(f"Plain(hex) = {P.hex()}")
try:
    log.success(f"Plain(str) = {P.decode()}")
except:
    log.success("실패")

flag_bytes = P + u16_to_raw_bytes(k1) + u16_to_raw_bytes(k2) + u16_to_raw_bytes(k3) + u16_to_raw_bytes(k4)
flag = flag_bytes.decode('latin-1')
log.success(f"FLAG = {flag}")
print(flag)

 

그으러면

pwn@meow:~/ctftemp$ /usr/bin/env python3 "/home/pwn/ctftemp/crypt/secure-server-2/ex.py"
[*] MITM #2 (k3,k4) 탐색
[+] (k3,k4) = 0x6638, 0x647d | raw=6638 647d
[*] MITM #1 (k1,k2) 탐색
[+] (k1,k2) = 0x6534, 0x6233 | raw=6534 6233
[*] Plain(hex) = 7363726970744354467b7333637233375f6d3373733467335f31333337215f37
[+] Plain(str) = scriptCTF{s3cr37_m3ss4g3_1337!_7
[+] FLAG = scriptCTF{s3cr37_m3ss4g3_1337!_7e4b3f8d}
scriptCTF{s3cr37_m3ss4g3_1337!_7e4b3f8d}

 

쉽게 나옵니다