[Crypto] scriptCTF 2025 - EaaS write-up

TL;DR

CBC 비트플리핑 문제

 

서버는 고정된 key, iv로 AES-CBC를 사용한다.

그 이후는 애플리케이션 단계에서 요구사항을 충족해야 solve된다.

 

Analysis

#!/usr/bin/env python3
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
import os
import random
email=''
flag=open('flag.txt').read()
has_flag=False
sent=False
key = os.urandom(32)
iv = os.urandom(16)
encrypt = AES.new(key, AES.MODE_CBC,iv)
decrypt = AES.new(key, AES.MODE_CBC,iv)

def send_email(recipient):
    global has_flag
    if recipient.count(b',')>0:
        recipients=recipient.split(b',')
    else:
        recipients=recipient
    for i in recipients:
        if i == email.encode():
            has_flag = True

for i in range(10):
    email += random.choice('abcdefghijklmnopqrstuvwxyz')
email+='@notscript.sorcerer'

print(f"Welcome to Email as a Service!\nYour Email is: {email}\n")
password=bytes.fromhex(input("Enter secure password (in hex): "))

assert not len(password) % 16
assert b"@script.sorcerer" not in password
assert email.encode() not in password

encrypted_pass = encrypt.encrypt(password)
print("Please use this key for future login: " + encrypted_pass.hex())

while True:
    choice = int(input("Enter your choice: "))
    print(f"[1] Check for new messages\n[2] Get flag")

    if choice == 1:
        if has_flag:
            print(f"New email!\nFrom: scriptsorcerers@script.sorcerer\nBody: {flag}")
        else:
            print("No new emails!")

    elif choice == 2:
        if sent:
            exit(0)
        sent=True
        user_email_encrypted = bytes.fromhex(input("Enter encrypted email (in hex): ").strip())
        if len(user_email_encrypted) % 16 != 0:
            print("Email length needs to be a multiple of 16!")
            exit(0)
        user_email = decrypt.decrypt(user_email_encrypted)
        if user_email[-16:] != b"@script.sorcerer":
            print("You are not part of ScriptSorcerers!")
            exit(0)

        send_email(user_email)
        print("Email sent!")

 

1. CBC malleability

CBC 복호 단계는 $P_i = D_K(C_i) \oplus C_{i-1}, (C_{-1} = IV)$ 이므로 이전 블록을 $\Delta$만큼 비트플립하면

$$ C_{i-1}^{,} = C_{i-1} \oplus \Delta \rightarrow P_{i}^{,}=  D_K(C_i) \oplus C_{i-1}^{,} = \underbrace{ D_K(C_i) \oplus C_{i_1} }_{P_i}  \oplus \Delta = P_i \oplus \Delta $$

즉, 이전 암호문 블록에 XOR을 주입하면 다음 평문 블록을 원하는 값으로 직접 조정 가능하다.

 

2. 수신자 split 버그

def send_email(recipient):
    global has_flag
    if recipient.count(b',')>0:
        recipients=recipient.split(b',')
    else:
        recipients=recipient
    for i in recipients:
        if i == email.encode():
            has_flag = True

 

콤마가 있으면, has_flag=츄르

콤마가 없으면, 바이트 단위 비교가 되어 일치 되지 않는다.

 

3. 마지막 16바이트 도메인

user_email = decrypt.decrypt(user_email_encrypted)
if user_email[-16:] != b"@script.sorcerer": exit(0)

@script.sorcerer로 만들어야 통과된다.

 

Exploitation

비밀번호 단계에서 96바이트(6블록)의 평문 $Q$를 보내고, 서버가 준 암호문 $C_0 || C_1 || C_2 || C_3 || C_4 || C_5 $ 부분을 XOR로 조작해 이메일 제출 단계에서 복호화될 평문 $P$를 원하는 모양으로 만든다.

 

$P$ 블록 설계는 다음과 같다.

  • $P_2 = ',' || E[:15] $ ($E$는 내 29바이트짜리 이메일)
  • $P_3 = E[15:] || ',' || 'X' $
  • $P_5 = '@script.sorcerer' $

이렇게 만들면 콤마로 split 했을 때 $P_2[1:] + P_3[:14]) = E$가 정확히 하나의 조각으로 생기고, 맨 뒤 블록은 도메인 검사를 통과한다.

 

단 password 평문 제약 때문에 $E$가 그대로 들어가면 안 된다. 그래서 $Q$에는 $P_2$ 위치에 1바이트만 뒤집힌 가짜 앞부분을 넣고, CBC 비트플립으로 $P_2$만 복구한다.

 

비밀번호 단계에서 $Q$가 CB로 암호화되어 서버가 $C_i$를 돌려준다.

 

복호 기준으로 보면 $Q_i = D(C_i) \oplus C_{i-1} \rightarrow D(C_i) = Q_i \oplus C_{i-1} $ 이다.

이제 로그인 이메일 제출시, $C$를 조작해 $P$를 만들어야 한다.

$$ P_i = D(C_i) \oplus C_{i-1}^{,} $$

 

$C_{i-1}^{,}$는 우리가 서버에 제출한 조작된 이전 블록이고 위 두 식을 합치면 $P_i = (Q_i \oplus C_{i-1} ) \oplus C_{i-1}^{,} $ 이며 원하는 목표 $T_i$를 얻기 위해선 $ P_i = T_i \rightarrow C_{i-1}^{,} = C_{i-1} \oplus (Q_i \oplus T_i) $

 

정리: $i$번째 평문 블록을 $T_i$로 만들고 싶으면, 이전 암호 블록을 $\Delta_i = Q_i \oplus T_i$ 만큼 XOR해서 구성하자.

 

Payload

$Q = Q_0 \| Q_1 \| Q_2 \| Q_3 \| Q_4 \| Q_5$ : 96바이트

  • $ Q_0, Q_1, Q_4, Q_5$: 더미
  • $ Q_2 $: $T_2 = ',' + E[:15]$에서 바이트 1개만 1비트 뒤집은 값
  • $ Q_3  = T_3 = E[15:] + ',' + 'X' $

$T$:

  • $T_2 = ',' + E[:15] $
  • $T_3 = E[15:] + ',' + 'X'$
  • $T_5 = 'script.sorcerer'$

 

조작 블록

$P_2 \rightarrow T_2 $: $C_{1}^{,} = C_1 \oplus (Q_2 \oplus T_2) $

$P_3 \rightarrow T_3 $: $Q_3 = T_3$이므로 따로 건들지 않음

$P_5 \rightarrow T_5 $: $C_{4}^{,} = C_4 \oplus (Q_5 \oplus T_5) $

 

최종

$$ C_0 || C_{1}^{,} || C_2 || C_3 || C_{4}^{,} || C_5 $$

 

Code

from pwn import *
import re

context.log_level = 'debug'

def parse_email(banner: bytes) -> bytes:
    m = re.search(rb"Your Email is:\s*(\S+)", banner)
    if not m:
        log.failure("Email parse failed")
        exit(1)
    return m.group(1)

def main():
    p = remote('주소블라인드', 포트블라인드)

    banner = p.recvuntil(b"Enter secure password (in hex): ")
    E = parse_email(banner)          # 29 len
    assert len(E) == 29
    log.info(f"server email = {E!r} (len={len(E)})")

    # Q0,Q1,Q4,Q5는 더미
    Q0 = b'Z'*16
    Q1 = b'A'*16

    T2 = b',' + E[:15]          
    tweak = bytearray(T2)       
    tweak[1] ^= 0x01            
    Q2 = bytes(tweak)           

    T3 = E[15:] + b',' + b'X'   
    Q3 = T3                     

    Q4 = b'D'*16
    Q5 = b'E'*16

    Q = Q0 + Q1 + Q2 + Q3 + Q4 + Q5
    assert (b"@script.sorcerer" not in Q) and (E not in Q)

    p.sendline(Q.hex().encode())

    line = p.recvline()  
    enc_hex = line.strip().split()[-1]
    C = bytes.fromhex(enc_hex.decode())
    assert len(C) == 96
    C0, C1, C2, C3, C4, C5 = [C[i*16:(i+1)*16] for i in range(6)]

    # CBC 비트플립
    delta2 = xor(Q2, T2)
    C1p = xor(C1, delta2)

    # C2/C3 변경 없음
    C2p = C2
    C3p = C3

    T5 = b'@script.sorcerer'
    C4p = xor(C4, xor(Q5, T5))

    forged = C0 + C1p + C2p + C3p + C4p + C5

    p.sendlineafter(b"Enter your choice: ", b"2")
    p.sendlineafter(b"Enter encrypted email (in hex): ", forged.hex().encode())
    p.recvuntil(b"Email sent!")

    p.sendlineafter(b"Enter your choice: ", b"1")
    while True:
        s = p.recvline(timeout=1)
        if not s: break
        print(s.decode(errors='ignore').rstrip())

    p.close()

if __name__ == "__main__":
    main()

 

 

[+] Opening connection to 주소블라인드 on port 포트블라인드: Done
[DEBUG] Received 0x6d bytes:
    b'Welcome to Email as a Service!\n'
    b'Your Email is: yiaciwomsm@notscript.sorcerer\n'
    b'\n'
    b'Enter secure password (in hex): '
[*] server email = b'yiaciwomsm@notscript.sorcerer' (len=29)
[DEBUG] Sent 0xc1 bytes:
    b'5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a414141414141414141414141414141412c7869616369776f6d736d406e6f747363726970742e736f7263657265722c584444444444444444444444444444444445454545454545454545454545454545\n'
[DEBUG] Received 0xfa bytes:
    b'Please use this key for future login: 91c178c80abca9d5a6a700c82a36f069d17d2f83d683cb9444acaba2413770b528ab41a59c6045c645117339310b699defcbbad78cfa1b841654c1ba5a7ccbcce5daba45a8fa021504a1857a0cda11f87d154bfec747d64863ff319d44d536e1\n'
    b'Enter your choice: '
[DEBUG] Sent 0x2 bytes:
    b'2\n'
[DEBUG] Received 0x48 bytes:
    b'[1] Check for new messages\n'
    b'[2] Get flag\n'
    b'Enter encrypted email (in hex): '
[DEBUG] Sent 0xc1 bytes:
    b'91c178c80abca9d5a6a700c82a36f069d17c2f83d683cb9444acaba2413770b528ab41a59c6045c645117339310b699defcbbad78cfa1b841654c1ba5a7ccbcce0ec9c7284cf337e328bb25c2ced31cf7d154bfec747d64863ff319d44d536e1\n'
[DEBUG] Received 0x1f bytes:
    b'Email sent!\n'
    b'Enter your choice: '
[DEBUG] Sent 0x2 bytes:
    b'1\n'
[DEBUG] Received 0xa0 bytes:
    b'[1] Check for new messages\n'
    b'[2] Get flag\n'
    b'New email!\n'
    b'From: scriptsorcerers@script.sorcerer\n'
    b'Body: scriptCTF{CBC_1s_s3cur3_r1ght?_31c2006b7b42}\n'
    b'\n'
    b'Enter your choice: '
[1] Check for new messages
[2] Get flag
New email!
From: scriptsorcerers@script.sorcerer
Body: scriptCTF{CBC_1s_s3cur3_r1ght?_31c2006b7b42}

[*] Closed connection to 주소블라인드 port 포트블라인드

 

보통 CBC면 오라클 패딩 문제가 많이 나오는데 비트플리핑은 또 처음이라 재밌네요

 

scriptCTF{CBC_1s_s3cur3_r1ght?_31c2006b7b42}