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}
'CTF' 카테고리의 다른 글
[Rev] scriptCTF 2025 - plastic-shield 1, 2 write-up (3) | 2025.08.21 |
---|---|
[Crypto] scriptCTF 2025 - Secure-Server-2 write-up (1) | 2025.08.21 |
[Crypto] CCE 2025 Qual - jokes write-up (2) | 2025.08.21 |
[Rev] CCE 2025 Qual - Directcalc write-up (4) | 2025.08.16 |
[Forensic] CCE 2025 Qual - something from a friend write-up (2) | 2025.08.16 |