[🥈 Silver 4] 참/거짓 한 비트만 흘려도 AES 블록이 통째로 뜯긴다 — DreamHack Padding Miracle Attack 풀이

2026-10-28·1분 읽기·

[🥈 Silver 4] 참/거짓 한 비트만 흘려도 AES 블록이 통째로 뜯긴다 — DreamHack Padding Miracle Attack 풀이

복호 메뉴가 알려주는 건 unpad 가 성공했는지 아닌지 딱 한 비트다. 그 한 비트로 AES 블록을 바이트 단위로 뜯는 게 패딩 오라클인데, 이 문제는 IV 를 끝까지 숨겨서 오라클 한 번으로는 secret 이 아니라 secret XOR IV 까지만 나온다. 평문 0 을 암호화해 받은 블록으로 오라클을 한 번 더 돌려 IV 를 상쇄한 과정과, 로컬에서는 멀쩡한데 원격에서만 터진 버그 하나를 정리했다.

문제: DreamHack — Padding Miracle Attack 분류: crypto 난이도: 🥈 Silver 4 FLAG: DH{Can_you_do_it_even_faster??}

문제 설명은 두 줄이다. "unpad는 정말 까다로운 친구에요... Padding oracle attack을 공부해봅시다!" 드림핵 강의 Exploit Tech: Padding oracle attack 과 같이 보는 실습 문제다.

받는 건 chall.py 하나. 서버에 붙으면 메뉴가 셋 뜬다. 암호화, 복호화, 그리고 secret 제출.

printf "1\nsecret\n3\n00112233445566778899aabbccddeeff\n4\n" | nc -q 2 host3.dreamhack.games 20886
라이브 서버에 nc 로 붙어 메뉴 3개를 확인한 화면. 1번으로 secret 을 암호화시키면 64자 hex 가 나오고, 3번에 아무 값이나 넣으면 Try again.. T.T 로 거절당한다
라이브 서버에 nc 로 붙어 메뉴 3개를 확인한 화면. 1번으로 secret 을 암호화시키면 64자 hex 가 나오고, 3번에 아무 값이나 넣으면 Try again.. T.T 로 거절당한다

1번을 누르고 secret 이라고 쓰면 서버가 자기 비밀값을 암호화해서 보여준다. 암호문은 주는데 키는 없다. 3번에 그 비밀값의 원문을 hex 로 맞혀 넣어야 flag가 나온다.

그러니까 이 문제의 목표는 "복호"가 아니라 16바이트를 정확히 알아맞히는 것이다. 무작위로는 2의 128제곱이라 가망이 없고, 2번 메뉴가 흘리는 한 비트가 전부다.


문제 개요

항목내용
문제명Padding Miracle Attack
난이도🥈 Silver 4
분류crypto
제공 파일 / 서버chall.py 39줄 / nc host3.dreamhack.games <포트>
스택Python 3 · PyCryptodome · AES-128-CBC · PKCS#7
핵심 취약점unpad 성공/실패가 그대로 새어 나오는 복호 오라클 + 세션 내내 고정된 IV
최종 질의 수4,486회 (9초)

흐름은 이렇다. secret 을 암호화시켜 C_secret 을 받고, 패딩 오라클로 그 블록의 AES 복호값을 바이트 단위로 복원한다. 그런데 거기서 나오는 건 secret 이 아니라 secret XOR IV 다. IV 를 모르니까. 그래서 평문 0 을 암호화해 C_zero 를 하나 더 받아 같은 오라클을 한 번 더 돌리면 그쪽은 IV 자체가 나온다. 둘을 XOR 하면 IV 가 지워지고 secret 만 남는다.



🧩 배경 — CBC 는 앞 블록을 평문에 그대로 XOR 한다

AES 는 16바이트 블록 하나만 처리할 줄 안다. 그보다 긴 데이터를 다루려면 운용 모드가 필요하고, CBC(Cipher Block Chaining)가 그 중 하나다.

CBC 복호 과정 — 각 암호문 블록을 블록 복호한 뒤 바로 앞 암호문 블록과 XOR 하고, 첫 블록만 IV 와 XOR 해서 평문이 나온다
CBC 복호 과정 — 각 암호문 블록을 블록 복호한 뒤 바로 앞 암호문 블록과 XOR 하고, 첫 블록만 IV 와 XOR 해서 평문이 나온다

출처: Wikimedia Commons, WhiteTimberwolf, public domain

복호 쪽 식만 보면 된다.

P[0] = AES_dec(C[0]) XOR IV
P[i] = AES_dec(C[i]) XOR C[i-1]     (i >= 1)

여기서 중요한 건 C[i-1] 이 평문에 그대로 XOR 된다는 것이다. 암호문 앞 블록을 1바이트 바꾸면 다음 블록 평문의 같은 자리가 정확히 그만큼 바뀐다. 키는 몰라도 된다.

PKCS#7 패딩은 평문 길이를 16의 배수로 맞추는 규칙이다. 모자란 바이트 수 n 을 그 값 그대로 n 개 채운다. 3바이트가 비면 03 03 03, 딱 맞으면 통째로 한 블록을 10 으로 채운다. unpad() 는 복호 결과의 마지막 바이트 n 을 읽어 1부터 16 사이인지 보고, 끝 n 바이트가 전부 n 인지 확인한다. 하나라도 어긋나면 예외다.

그러니까 공격자가 C[i-1] 을 조작해 P[i] 를 흔들고, unpad 가 통과했는지만 볼 수 있으면 — 키 없이 AES_dec(C[i]) 를 읽어낼 수 있다. 이게 패딩 오라클이다.



🔬 코드 정찰 — 39줄이 전부다

ls -l extracted && wc -l extracted/chall.py && grep -nE "urandom|input\(|unpad|flag" extracted/chall.py
배포본에는 chall.py 941바이트 한 개뿐이고 39줄이다. grep 으로 추린 핵심 줄에 os.urandom(16) 세 개, 세 군데 input, unpad 호출, flag 출력이 그대로 보인다
배포본에는 chall.py 941바이트 한 개뿐이고 39줄이다. grep 으로 추린 핵심 줄에 os.urandom(16) 세 개, 세 군데 input, unpad 호출, flag 출력이 그대로 보인다

전문은 이렇다.

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
import os
 
flag = open("flag.txt", "r").read()
secret, key, iv = [os.urandom(16) for _ in range(3)]
 
while True:
	mode = int(input("[1] Encrypt [2] Decrypt [3] Submit: "))
 
	if mode == 1:
		pt = input("Input plaintext: ")
		if pt == "secret":
			pt = secret
		else:
			pt = bytes.fromhex(pt)
		pt = pad(pt, 16)
		cipher = AES.new(key, AES.MODE_CBC, iv)
		ct = bytes.hex(cipher.encrypt(pt))
		print("pt ==Encryption=>", ct)
		
	elif mode == 2:
		ct = bytes.fromhex(input("Input ciphertext: "))
		try:
			cipher = AES.new(key, AES.MODE_CBC, iv)
			pt = unpad(cipher.decrypt(ct), 16)
			pt = "Don't steal my secret!"
		except:
			pt = "Invalid ciphertext."
		print("ct ==Decryption=>", pt)
 
	elif mode == 3:
		if bytes.fromhex(input("Enter secret: ")) == secret:
			print("You are a human decryptor!!", flag)
			exit()
		else:
			print("Try again.. T.T")
	else:
		exit()

세 줄이 눈에 걸린다.

줄내용왜 문제인가
6secret, key, iv 를 한 번만 뽑는다세션이 끝날 때까지 IV 가 고정이다. 매 호출 새로 만들었으면 오라클을 누적할 수 없다
26~29unpad 실패를 except 로 삼켜 고정 문구로 바꾼다문구는 두 가지뿐이지만 그게 곧 참/거짓 1비트다
23bytes.fromhex 가 try 밖에 있다길이 제한이 없어 원하는 만큼 블록을 붙여 보낼 수 있다. 대신 hex 가 깨지면 서버가 그냥 죽는다

그리고 2번은 복호 결과를 절대 보여주지 않는다. 성공하면 Don't steal my secret!, 실패하면 Invalid ciphertext. 둘뿐이다.

무엇을 제어할 수 있나 — 직접 찔러 보기

라이브 서버에 바로 붙어서 더듬기 전에 로컬 복제본을 먼저 세웠다. 드림핵 배포 형태(socat + pty)를 그대로 흉내 내고 flag 자리에는 더미를 넣는다.

#!/bin/bash
# 로컬 복제 서버: 드림핵 배포 형태(socat + pty)를 흉내 낸다.
cd "$(dirname "$0")"
exec socat -T 120 TCP-LISTEN:17122,reuseaddr,fork EXEC:"python3 chall.py",pty,echo=0,raw
cat local/run_server.sh && (local/run_server.sh >local/server.log 2>&1 &) && sleep 2 && ss -ltn | grep 17122
run_server.sh 내용을 출력한 뒤 백그라운드로 띄우고 ss 로 확인한 화면. 17122 포트가 LISTEN 상태로 잡혀 있다
run_server.sh 내용을 출력한 뒤 백그라운드로 띄우고 ss 로 확인한 화면. 17122 포트가 LISTEN 상태로 잡혀 있다

이제 복제본에 정찰 스크립트를 던진다. 확인하고 싶은 건 셋이다. 암호화가 결정적인가(= IV 고정인가), 복호 메뉴가 정말 두 가지 답만 하는가, 무작위 암호문이 우연히 통과할 확률은 얼마인가.

#!/usr/bin/env python3
"""정찰 — 이 서비스가 무엇을 내주고 무엇을 숨기는지 직접 찔러 본다."""
import argparse
import os
 
from solve import Service, BS
 
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=17122)
a = ap.parse_args()
 
svc = Service(a.host, a.port)
print(f"[*] 접속 {a.host}:{a.port}")
 
ct_secret = svc.encrypt("secret")
print(f"[1] Encrypt('secret')     = {ct_secret.hex()}  ({len(ct_secret)}바이트 = 2블록)")
 
z1 = svc.encrypt(bytes(BS))
z2 = svc.encrypt(bytes(BS))
print(f"[2] Encrypt(00*16) 1회차  = {z1.hex()}")
print(f"[3] Encrypt(00*16) 2회차  = {z2.hex()}")
print(f"    -> 두 결과가 같은가: {z1 == z2}  (같으면 key·IV 가 세션 내내 고정)")
 
good = svc.decrypt_batch([ct_secret])[0]
bad_ct = bytearray(ct_secret)
bad_ct[-1] ^= 0x01
bad = svc.decrypt_batch([bytes(bad_ct)])[0]
print(f"[4] Decrypt(정상 암호문)   = 패딩통과 {good}")
print(f"[5] Decrypt(끝 1비트 뒤집음) = 패딩통과 {bad}")
 
trials = 512
rnd = [os.urandom(2 * BS) for _ in range(trials)]
hits = sum(svc.decrypt_batch(rnd))
print(f"[6] 무작위 암호문 {trials}개 중 패딩 통과 = {hits}개  (이론값 ~{trials / 256:.1f}개 = 1/256)")
print(f"[*] 총 질의 {svc.queries}회 — 서버가 흘리는 정보는 오직 이 참/거짓 1비트다")
python3 probe.py
probe.py 실행 결과. secret 암호문은 32바이트 2블록이고, 평문 0 을 두 번 암호화한 결과가 완전히 같아 IV 고정이 확인된다. 정상 암호문은 패딩통과 True, 끝 1비트를 뒤집으면 False. 무작위 암호문 512개 중 1개만 통과했다
probe.py 실행 결과. secret 암호문은 32바이트 2블록이고, 평문 0 을 두 번 암호화한 결과가 완전히 같아 IV 고정이 확인된다. 정상 암호문은 패딩통과 True, 끝 1비트를 뒤집으면 False. 무작위 암호문 512개 중 1개만 통과했다

세 가지가 한 번에 확인됐다.

secret 암호문이 32바이트, 즉 두 블록이다. 16바이트 평문에 PKCS#7 이 한 블록을 더 붙인 결과다.

평문 0 을 두 번 암호화한 결과가 완전히 같다. CBC 인데 결정적이라는 건 IV 가 재사용된다는 뜻이고, 이게 공격의 전제다.

무작위 암호문 512개 중 통과한 건 1개. 1/256에 가깝다. 끝 바이트가 우연히 01 이 되는 확률과 맞아떨어진다 — 서버가 패딩만 보고 있다는 증거다.



🧱 배경 — 1비트로 한 바이트를 긁어내는 방법

오라클을 어떻게 굴리는지가 이 문제의 본체다.

목표 블록 C 하나의 AES 복호값 Dec(C) 를 알고 싶다. 서버에 우리가 만든 블록 R 과 C 를 이어 붙여 32바이트로 보낸다. 서버는 CBC 로 복호하니 마지막 블록의 평문은 이렇게 된다.

P = AES_dec(C) XOR R

AES_dec(C) 는 고정값이고 R 은 우리가 바이트 단위로 고른다. 즉 마지막 블록 평문을 원하는 대로 흔들 수 있다. 앞 블록 평문은 IV 를 몰라 쓰레기가 나오지만 unpad 는 끝만 보므로 상관없다.

패딩 오라클 구조도 — 우리가 고른 R 과 서버가 준 목표 블록 C 를 이어 보내면 마지막 블록 평문은 Dec(C) XOR R 이 되고, R 의 마지막 바이트를 256가지로 돌려 unpad 가 통과하는 지점을 찾는다
패딩 오라클 구조도 — 우리가 고른 R 과 서버가 준 목표 블록 C 를 이어 보내면 마지막 블록 평문은 Dec(C) XOR R 이 되고, R 의 마지막 바이트를 256가지로 돌려 unpad 가 통과하는 지점을 찾는다

R[15] 를 0x00 부터 0xFF 까지 돌리면 P[15] 도 256가지를 전부 지난다. 그 중 unpad 가 통과하는 값에서 P[15] 는 0x01 이다. 그러므로

Dec(C)[15] = R[15] XOR 0x01

끝 바이트를 알았으면 그 다음은 끝 두 바이트를 02 02 로 만들어 놓고 15번째를 같은 식으로 훑는다. R[15] 는 Dec(C)[15] XOR 0x02 로 고정하고 R[14] 만 돌린다. 이렇게 뒤에서 앞으로 16번 반복하면 블록 하나가 끝난다.

여기에 함정이 하나 있다. unpad 는 0x01 만 통과시키는 게 아니다. 끝 두 바이트가 02 02 여도 통과한다. P[14] 가 하필 0x02 인 세션에서는 후보가 둘 나온다. 구분법은 간단하다 — 앞 바이트를 아무 값으로나 흔들어 본다. 진짜 0x01 이면 패딩 길이가 1이라 앞 바이트가 뭐든 상관없어 계속 통과하고, 02 02 였다면 깨져서 거절당한다.

말로만 하면 와닿지 않으니 그 상황을 일부러 만들어 봤다. 블록 하나를 정상적으로 복원해 Dec[14] 를 알아낸 다음, R[14] 를 그 값과 0x02 의 XOR 로 맞춰 P[14] 를 강제로 0x02 로 만든다.

#!/usr/bin/env python3
"""마지막 바이트 복원과, 0x01 인 척하는 0x02 0x02 함정을 눈으로 본다."""
import argparse
 
from solve import Service, recover_block, BS
 
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=17122)
a = ap.parse_args()
 
svc = Service(a.host, a.port)
target = svc.encrypt(bytes(BS))[:BS]          # C_z = E(IV)
print(f"[*] 대상 블록 C_z = {target.hex()}")
 
 
def scan(base: bytearray):
    """R[15] 를 0x00~0xFF 로 돌려 unpad 가 통과하는 값만 모은다."""
    cts = []
    for g in range(256):
        r = bytearray(base)
        r[15] = g
        cts.append(bytes(r) + target)
    return [g for g, ok in enumerate(svc.decrypt_batch(cts)) if ok]
 
 
plain = bytearray(BS)
cand = scan(plain)
print(f"[1] R = 00*16 일 때 통과한 R[15] = {[hex(c) for c in cand]}")
 
dec = recover_block(svc, target, "C_z      ")
print(f"[2] 이 블록의 Dec(C_z) = {dec.hex()}")
print(f"    -> 위 후보 {hex(cand[0])} 는 Dec[15]({hex(dec[15])}) ^ 0x01 = {hex(dec[15] ^ 1)} 과 일치")
 
# 앞 바이트가 하필 0x02 가 되는 상황을 일부러 만들어 함정을 재현한다
trap = bytearray(BS)
trap[14] = dec[14] ^ 0x02
cand2 = scan(trap)
print(f"[3] R[14] 를 손봐 P[14]=0x02 로 만들면 통과한 R[15] = {[hex(c) for c in cand2]}  <- 후보가 둘")
for g in cand2:
    probe = bytearray(trap)
    probe[15] = g
    probe[14] ^= 0xFF
    alive = svc.decrypt_batch([bytes(probe) + target])[0]
    verdict = "진짜 0x01" if alive else "0x02 0x02 였다"
    print(f"    R[15]={hex(g)} -> R[14] 를 0xFF 로 흔든 뒤 통과 {alive}  ({verdict})")
print(f"[*] 총 질의 {svc.queries}회")
python3 demo_lastbyte.py
demo_lastbyte.py 실행 결과. R 이 전부 0일 때는 통과 후보가 0x36 하나뿐이고 Dec[15] 0x37 과 XOR 0x01 로 맞아떨어진다. R[14] 를 손봐 P[14] 를 0x02 로 만들자 후보가 0x35 와 0x36 둘로 늘고, 앞 바이트를 0xFF 로 흔들자 0x35 만 탈락한다
demo_lastbyte.py 실행 결과. R 이 전부 0일 때는 통과 후보가 0x36 하나뿐이고 Dec[15] 0x37 과 XOR 0x01 로 맞아떨어진다. R[14] 를 손봐 P[14] 를 0x02 로 만들자 후보가 0x35 와 0x36 둘로 늘고, 앞 바이트를 0xFF 로 흔들자 0x35 만 탈락한다

평소에는 후보가 하나고, P[14] 가 0x02 인 순간 둘이 된다. 흔들어 보면 가짜가 떨어져 나간다. 이 분기를 안 넣으면 256번에 한 번꼴로 블록 전체가 틀어진다.



🐛 삽질

▶🐛 삽질 1 — 오라클을 다 돌렸는데 나온 값이 secret 이 아니었다

처음엔 교과서대로 C_secret 한 블록만 복원했다. 16바이트가 깔끔하게 나왔고 3번 메뉴에 넣었는데 Try again.. T.T.

당연했다. 패딩 오라클이 주는 건 AES_dec(C) 지 평문이 아니다. CBC 첫 블록의 평문은

P[0] = AES_dec(C[0]) XOR IV

이고, C_secret 은 secret 을 첫 블록으로 암호화한 것이니

AES_dec(C_secret) = secret XOR IV

가 나온다. IV 를 모르는 한 secret 이 아니다. 보통 패딩 오라클 문제는 IV 를 공개하거나 암호문 앞에 붙여 주는데 이 문제는 os.urandom(16) 으로 만들고 끝까지 안 보여준다. 문제가 Miracle 이라는 이름을 붙인 자리가 여기다.

암호화 메뉴로 IV 를 직접 뽑을 방법도 없다. Encrypt 는 E(평문 XOR IV) 를 주지 IV 를 주지 않는다. 역으로 E(IV) = C_zero 인 C_zero 는 손에 넣을 수 있지만, 그걸 IV 로 되돌리려면 복호가 필요하고 복호는 못 한다.

막힌 것 같았는데, 복호를 못 하는 게 아니라 패딩 오라클이 바로 그 복호였다. C_zero 에 같은 오라클을 돌리면 AES_dec(C_zero) = IV 가 그대로 나온다.

▶🐛 삽질 2 — 로컬은 완벽한데 원격 21바이트째에서 오라클이 전부 거짓이 됐다

로컬 복제본에서 6초 만에 끝까지 돌았다. 그대로 라이브 서버에 붙였더니 첫 블록 16바이트는 멀쩡히 나오고, 두 번째 블록 5바이트째에서 멈췄다.

RuntimeError: IV       : 10번째 바이트에서 유효 패딩을 못 찾음

256개 후보가 전부 거절당했다는 뜻인데, 수학적으로는 그럴 수가 없다. 반드시 하나는 통과해야 한다.

원인은 응답을 세는 방식이었다. 질의를 32개씩 묶어 한 번에 밀어 넣고 응답을 받는데, 그때 Decryption=> 라는 문자열이 몇 번 나왔는지로 "다 왔다"를 판정하고 있었다.

while self.buf.count(b"Decryption=>") < count:
    self.buf += self.io.recv(timeout=20)

루프백에서는 32개 응답이 한 덩어리로 도착해서 아무 문제가 없다. 그런데 원격은 TCP 가 아무 데서나 자른다. ct ==Decryption=> Don 까지만 도착한 상태에서도 Decryption=> 는 이미 32번째라 루프가 끝나 버리고, 잘린 조각을 정규식으로 읽으면 Don 이다. Don't steal 이 아니니까 성공을 실패로 기록한다.

진짜 정답이 배치의 마지막 자리에 걸릴 때만 터지니까 32번에 한 번. 바이트를 32개 복원하는 동안 한 번쯤 걸리는 게 당연했다.

고친 건 두 가지다. 정규식에 줄 끝을 넣어 완결된 줄만 세고, 남은 바이트를 버리지 않고 다음 배치로 이월한다.

RE_DEC = re.compile(rb"Decryption=> ([^\r\n]*)[\r\n]")
...
matches = list(pattern.finditer(self.buf))
if len(matches) >= count:
    self.buf = self.buf[matches[count - 1].end():]
    return [m.group(1) for m in matches[:count]]

로컬에서만 검증하고 넘어갔으면 영영 못 봤을 버그다. 반대로 말하면 로컬 검증은 알고리즘만 보장하지 전송 계층은 보장하지 않는다.



💣 핵심 — 오라클을 두 번 돌려 IV 를 지운다

정리하면 이렇다. 암호화 메뉴로 블록 두 개를 확보한다.

C_secret = E(secret XOR IV)      Encrypt("secret") 의 1블록
C_zero   = E(0x00*16 XOR IV)     Encrypt(00 * 16) 의 1블록 = E(IV)

각각에 패딩 오라클을 돌리면

Dec(C_secret) = secret XOR IV
Dec(C_zero)   = 0x00*16 XOR IV = IV

둘을 XOR 하면 같은 IV 가 양쪽에서 만나 상쇄된다.

secret = Dec(C_secret) XOR Dec(C_zero)
IV 상쇄 구조도 — Encrypt(secret) 과 Encrypt(0) 로 블록 두 개를 받아 각각 패딩 오라클을 돌리면 secret XOR IV 와 IV 가 나오고, XOR 하면 IV 가 지워져 secret 만 남는다
IV 상쇄 구조도 — Encrypt(secret) 과 Encrypt(0) 로 블록 두 개를 받아 각각 패딩 오라클을 돌리면 secret XOR IV 와 IV 가 나오고, XOR 하면 IV 가 지워져 secret 만 남는다

비용은 블록 두 개, 즉 32바이트어치 오라클이다. 한 바이트에 평균 128회니까 4천 번쯤 물어보면 된다.

제출 전 검산 — CBC 를 접어 생 AES 를 부른다

3번 메뉴는 정답인지 아닌지만 알려주고 횟수 제한도 없지만, 틀린 값을 던져 보는 건 확인이 아니다. 복원이 맞았는지 제출 전에 알고 싶었다.

암호화 메뉴를 조금 비틀면 임의 블록의 생 AES 암호화를 얻을 수 있다. 평문 두 블록 P1 || P2 를 암호화하면 CBC 라서

C1 = E(P1 XOR IV)
C2 = E(P2 XOR C1)

인데, P1 을 0으로 두면 C1 은 항상 C_zero 다. 그러면 P2 를 Z XOR C_zero 로 주는 순간 C2 = E(Z) 가 된다. IV 가 식에서 빠진다.

#!/usr/bin/env python3
"""CBC 를 접어 '생 AES 암호화' 오라클을 만든다 — 제출 전 검산에 쓰는 도구."""
import argparse
import os
 
from solve import Service, xor, BS
 
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=17122)
a = ap.parse_args()
 
svc = Service(a.host, a.port)
c_zero = svc.encrypt(bytes(BS))[:BS]
print(f"[*] C_zero = Enc(00*16) 의 1블록 = E(IV) = {c_zero.hex()}")
 
z = bytes(range(BS))
e1 = svc.ecb_encrypt(z, c_zero)
e2 = svc.ecb_encrypt(z, c_zero)
print(f"[1] E({z.hex()}) = {e1.hex()}")
print(f"[2] 같은 Z 재질의           = {e2.hex()}  동일: {e1 == e2}")
 
# 접은 오라클이 진짜 CBC 와 맞물리는지 — 임의 2블록을 암호화해 2번째 블록을 예측해 본다
p1, p2 = os.urandom(BS), os.urandom(BS)
ct = svc.encrypt(p1 + p2)
pred = svc.ecb_encrypt(xor(p2, ct[:BS]), c_zero)
print(f"[3] Enc(P1||P2) 의 2블록    = {ct[BS:2 * BS].hex()}")
print(f"[4] 접은 오라클 E(P2^C1)     = {pred.hex()}")
print(f"    -> 일치: {pred == ct[BS:2 * BS]}  (CBC 를 ECB 처럼 호출할 수 있다)")
print(f"[*] 총 질의 {svc.queries}회")
python3 demo_ecb.py
demo_ecb.py 실행 결과. 접은 오라클로 뽑은 E(000102...0f) 가 두 번 호출에 동일하고, 임의 2블록 평문을 암호화한 실제 2번째 블록과 접은 오라클이 예측한 E(P2 XOR C1) 이 정확히 일치한다
demo_ecb.py 실행 결과. 접은 오라클로 뽑은 E(000102...0f) 가 두 번 호출에 동일하고, 임의 2블록 평문을 암호화한 실제 2번째 블록과 접은 오라클이 예측한 E(P2 XOR C1) 이 정확히 일치한다

이 도구가 생기면 복원 결과를 식 두 개로 검산할 수 있다.

E(IV)            == C_zero
E(secret XOR IV) == C_secret

둘 다 참이면 IV 와 secret 이 맞은 것이다. 질의 두 번이면 끝난다. 실제 풀이에서 이 검산은 항상 통과했지만, 안 넣었으면 삽질 2의 버그를 "오답"으로만 보고 엉뚱한 데를 뒤졌을 것이다.


🎯 익스플로잇 설계 — 4,486회를 9초에

질의 수가 4천 번대라 왕복 지연이 그대로 시간이 된다. 한 번 보내고 한 번 받으면 원격에서 50ms 씩 잡아도 3분이 넘는다.

다행히 서버는 input() 으로 한 줄씩 읽을 뿐 순서를 따지지 않는다. 그래서 질의를 묶어서 한꺼번에 밀어 넣고 응답을 세어 끊어 읽는다. 32개씩 묶으면 한 바이트에 평균 4번만 왕복한다.

payload = b"".join(b"2\n" + ct.hex().encode() + b"\n" for ct in cts)
self.io.send(payload)
return [b"Don't steal" in ln for ln in self._collect(RE_DEC, len(cts))]

한 바이트의 정답을 찾으면 그 묶음의 나머지는 버리고 다음 위치로 넘어간다. 256개를 다 보내지 않아도 되니 평균 질의가 절반으로 준다.

라이브로 가기 전에 복제본에서 전 과정을 먼저 돌렸다.

python3 solve.py
로컬 복제 서버 대상 solve.py 실행 결과. secret^IV 와 IV 두 블록을 각각 16/16 복원하고 검산 두 개가 모두 True, 총 4614회 질의로 더미 flag 까지 받아냈다
로컬 복제 서버 대상 solve.py 실행 결과. secret^IV 와 IV 두 블록을 각각 16/16 복원하고 검산 두 개가 모두 True, 총 4614회 질의로 더미 flag 까지 받아냈다

더미 flag 가 나오고 검산도 통과했다. 여기까지 확인한 다음에야 드림핵 VM 을 띄웠다.



🚀 Full Exploit

solve.py 전문이다. 복제본과 라이브 모두 이 파일 하나로 돌렸다.

#!/usr/bin/env python3
"""Padding Miracle Attack (DreamHack Silver 4, crypto) 풀이.
 
[1] Encrypt 는 고정 IV 로 AES-CBC 암호화를, [2] Decrypt 는 unpad 성공/실패만 알려준다.
목표는 복호가 아니라 secret 16바이트 그 자체이고, IV 가 끝까지 비공개라
패딩 오라클 한 번으로는 secret 이 아니라 secret^IV 까지밖에 못 간다.
 
  C_s = E(secret ^ IV)        -> Dec(C_s) = secret ^ IV
  C_z = E(0x00*16 ^ IV)       -> Dec(C_z) = IV          (평문 0 을 암호화해 받아 둔다)
  secret = Dec(C_s) ^ Dec(C_z)
 
그래서 블록을 두 개 복원한다. 복원 결과는 제출 전에 암호화 오라클로 자체 검산한다
(E(IV) == C_z, E(secret^IV) == C_s).
"""
import argparse
import re
import sys
 
from pwn import remote, context
 
context.log_level = "error"
BS = 16
# 응답은 반드시 줄바꿈까지 받은 뒤에 센다(아래 _collect 주석 참고)
RE_ENC = re.compile(rb"Encryption=> ([0-9a-f]+)[\r\n]")
RE_DEC = re.compile(rb"Decryption=> ([^\r\n]*)[\r\n]")
 
 
def xor(a: bytes, b: bytes) -> bytes:
    return bytes(x ^ y for x, y in zip(a, b))
 
 
class Service:
    """chall.py 와 줄 단위로 대화한다. 질의는 파이프라이닝해서 왕복을 줄인다."""
 
    def __init__(self, host, port):
        self.io = remote(host, port)
        self.buf = b""
        self.queries = 0
 
    def _collect(self, pattern, count: int) -> list:
        """완결된 줄만 센다.
 
        ★ 줄 끝(개행)을 요구하지 않고 'Decryption=>' 개수만 세면,
        TCP 가 응답을 중간에서 쪼갰을 때 잘린 조각을 하나의 답으로 세어
        성공을 실패로 읽는다. 로컬 루프백에서는 한 번에 다 와서 안 드러나고
        원격에서만 터졌다(21바이트째에서 오라클이 전부 거짓이 됐다).
        """
        while True:
            matches = list(pattern.finditer(self.buf))
            if len(matches) >= count:
                self.buf = self.buf[matches[count - 1].end():]
                return [m.group(1) for m in matches[:count]]
            chunk = self.io.recv(timeout=30)
            if not chunk:
                raise RuntimeError(f"응답 끊김: {len(matches)}/{count}")
            self.buf += chunk
 
    def encrypt(self, data) -> bytes:
        """mode 1. data 가 'secret' 이면 서버가 진짜 secret 을 암호화한다."""
        arg = data if isinstance(data, str) else data.hex()
        self.io.send(b"1\n" + arg.encode() + b"\n")
        self.queries += 1
        hexval = self._collect(RE_ENC, 1)[0]
        return bytes.fromhex(hexval.decode())
 
    def decrypt_batch(self, cts) -> list:
        """mode 2 를 여러 건 한꺼번에 밀어 넣고 패딩 성공 여부 리스트를 받는다."""
        payload = b"".join(b"2\n" + ct.hex().encode() + b"\n" for ct in cts)
        self.io.send(payload)
        self.queries += len(cts)
        return [b"Don't steal" in ln for ln in self._collect(RE_DEC, len(cts))]
 
    def submit(self, secret: bytes) -> str:
        self.io.send(b"3\n" + secret.hex().encode() + b"\n")
        self.queries += 1
        out = self.buf
        self.buf = b""
        for _ in range(10):
            try:
                out += self.io.recv(timeout=3)
            except EOFError:
                break
            if b"decryptor" in out or b"Try again" in out:
                break
        return out.decode(errors="replace")
 
    def ecb_encrypt(self, block: bytes, c_zero: bytes) -> bytes:
        """CBC 를 접어 임의 블록의 생 AES 암호화를 얻는다.
        평문 (0x00*16 || block^C_z) 를 넣으면 1블록은 C_z, 2블록이 E(block) 이다."""
        ct = self.encrypt(bytes(16) + xor(block, c_zero))
        return ct[BS:2 * BS]
 
 
def recover_block(svc: Service, target: bytes, label: str, chunk: int = 32) -> bytes:
    """R || target 를 보내 마지막 블록 평문 = Dec(target) ^ R 로 패딩 오라클을 돌린다."""
    dec = bytearray(BS)
    for idx in range(BS - 1, -1, -1):
        pad_val = BS - idx
        base = bytearray(BS)
        for j in range(idx + 1, BS):
            base[j] = dec[j] ^ pad_val
 
        hit = None
        for start in range(0, 256, chunk):
            guesses = list(range(start, min(start + chunk, 256)))
            cts = []
            for g in guesses:
                r = bytearray(base)
                r[idx] = g
                cts.append(bytes(r) + target)
            for g, ok in zip(guesses, svc.decrypt_batch(cts)):
                if not ok:
                    continue
                if idx == BS - 1:
                    # 0x01 인 척하는 0x02 0x02 를 걸러낸다: 앞 바이트를 흔들어도 살아남아야 진짜다
                    probe = bytearray(base)
                    probe[idx] = g
                    probe[idx - 1] ^= 0xFF
                    if not svc.decrypt_batch([bytes(probe) + target])[0]:
                        continue
                hit = g
                break
            if hit is not None:
                break
        if hit is None:
            raise RuntimeError(f"{label}: {idx}번째 바이트에서 유효 패딩을 못 찾음")
        dec[idx] = hit ^ pad_val
        sys.stderr.write(f"\r[{label}] {BS - idx:2d}/16  Dec={dec.hex()}  q={svc.queries}")
        sys.stderr.flush()
    sys.stderr.write("\n")
    return bytes(dec)
 
 
def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--host", default="127.0.0.1")
    ap.add_argument("--port", type=int, default=17122)
    args = ap.parse_args()
 
    svc = Service(args.host, args.port)
 
    c_secret = svc.encrypt("secret")[:BS]            # E(secret ^ IV)
    c_zero = svc.encrypt(bytes(BS))[:BS]             # E(0x00*16 ^ IV) = E(IV)
    print(f"[+] C_secret = {c_secret.hex()}")
    print(f"[+] C_zero   = {c_zero.hex()}")
 
    d_secret = recover_block(svc, c_secret, "secret^IV")
    iv = recover_block(svc, c_zero, "IV       ")
    secret = xor(d_secret, iv)
    print(f"[+] IV       = {iv.hex()}")
    print(f"[+] secret   = {secret.hex()}")
 
    # 제출 전 검산 — 암호화 오라클로 두 관계식이 모두 맞는지 확인한다
    ok_iv = svc.ecb_encrypt(iv, c_zero) == c_zero
    ok_sec = svc.ecb_encrypt(xor(secret, iv), c_zero) == c_secret
    print(f"[+] 검산 E(IV)==C_zero: {ok_iv} / E(secret^IV)==C_secret: {ok_sec}")
    if not (ok_iv and ok_sec):
        print("[-] 검산 실패. 제출하지 않는다.")
        return 1
 
    print(f"[+] 총 질의 {svc.queries}회")
    print(svc.submit(secret).strip())
    return 0
 
 
if __name__ == "__main__":
    sys.exit(main())
python3 solve.py --host host3.dreamhack.games --port 20886
라이브 드림핵 서버 대상 solve.py 실행 결과. secret^IV 와 IV 를 각각 16바이트 복원하고 검산 두 개가 True, 총 3942회 질의 끝에 You are a human decryptor 와 함께 flag 가 출력됐다
라이브 드림핵 서버 대상 solve.py 실행 결과. secret^IV 와 IV 를 각각 16바이트 복원하고 검산 두 개가 True, 총 3942회 질의 끝에 You are a human decryptor 와 함께 flag 가 출력됐다
DH{Can_you_do_it_even_faster??}

flag 문구가 "더 빨리도 할 수 있겠어?" 다. 질의를 묶어 보낸 게 의도된 방향이었던 모양이다.

재현 스크립트

문제 폴더만 들고 다시 돌릴 수 있게 reproduce.sh 를 같이 남겼다. 인자 없이 돌리면 복제 서버를 띄워 기법만 검증하고(더미 flag), host·port 를 주면 라이브에서 진짜 flag 를 받아 기대값과 대조한다.

#!/usr/bin/env bash
# Padding Miracle Attack (DreamHack, Silver 4 crypto) — 한 방 재현.
#
#   ./reproduce.sh                 로컬 복제 서버를 띄워 공격 기법만 검증한다(더미 flag).
#   ./reproduce.sh <host> <port>   드림핵 라이브 VM 에 붙어 진짜 flag 를 딴다.
#
# 라이브 VM 은 시간이 지나면 만료되므로 인자를 안 주면 기본값은 "로컬 검증"이다.
# 로컬 모드가 통과해도 진짜 flag 가 아니므로 그렇게 표기한다.
set -eu
ROOT="$(dirname "$(readlink -f "$0")")"
cd "$ROOT"
 
REAL=$(python3 -c "import json;print(json.load(open('문제.json'))['flag'])" 2>/dev/null \
       || echo 'DH{Can_you_do_it_even_faster??}')
 
if [ $# -ge 2 ]; then
  MODE=live; HOST=$1; PORT=$2; EXPECT=$REAL
else
  MODE=local; HOST=127.0.0.1; PORT=17122
  EXPECT='DH{dummy_local_flag_for_verification_only}'
  mkdir -p local
  cp -f extracted/chall.py local/chall.py
  printf '%s\n' "$EXPECT" > local/flag.txt
  # socat 의 pty 릴레이는 자식 출력을 제 stdout 으로도 흘린다 — 로그로 보낸다
  ( cd local && exec socat -T 120 "TCP-LISTEN:$PORT,reuseaddr,fork" \
      EXEC:"python3 chall.py",pty,echo=0,raw ) >local/socat.log 2>&1 &
  SOCAT=$!
  trap 'kill "$SOCAT" 2>/dev/null || true' EXIT
  sleep 1
fi
 
OUT=$(timeout 600 python3 solve.py --host "$HOST" --port "$PORT" 2>&1) || true
echo "$OUT" | tail -8
FLAG=$(printf '%s' "$OUT" | grep -aoE 'DH\{[^}]+\}' | head -1)
 
if [ "${FLAG:-}" = "$EXPECT" ]; then
  if [ "$MODE" = live ]; then
    echo; echo "✅ PASS  $FLAG"
  else
    echo; echo "✅ 기법 검증 PASS (로컬 복제본·더미 flag — 진짜 flag 아님)"
    echo "   진짜 flag 는: ./reproduce.sh <드림핵 host> <port>"
  fi
  exit 0
fi
echo; echo "❌ FAIL (얻은 값: '${FLAG:-없음}' / 기대: '$EXPECT')"; exit 1
./reproduce.sh host3.dreamhack.games 20886
reproduce.sh 를 라이브 host·port 로 실행한 결과. 4455회 질의로 secret 을 복원하고 검산 두 개가 True, 마지막 줄에 초록 체크와 함께 PASS 와 flag 가 찍혔다
reproduce.sh 를 라이브 host·port 로 실행한 결과. 4455회 질의로 secret 을 복원하고 검산 두 개가 True, 마지막 줄에 초록 체크와 함께 PASS 와 flag 가 찍혔다

로컬 환경은 Ubuntu 25.10 · Python 3.13.7 · pycryptodome 3.23.0 · pwntools 4.15.0 · socat 이다. 복제 서버는 socat 의 pty 모드를 써야 라이브와 버퍼링 동작이 비슷해진다.



📝 결론

패딩 오라클은 에러 메시지를 숨긴다고 막히지 않는다

이 문제의 except 는 예외 종류를 구분하지 않고 전부 Invalid ciphertext. 한 줄로 뭉갠다. 정보를 가린 것처럼 보이지만 성공과 실패가 다른 문구라는 사실 자체가 1비트다. 응답 문구를 통일해도 응답 시간이 갈리면 똑같이 새고, 이전 세션에서 풀었던 blindsc 는 출력이 아예 없는데도 프로세스 종료 시각만으로 플래그가 나왔다. 관측 가능한 차이가 하나라도 남으면 오라클이다.

고치는 자리는 패딩이 아니라 인증이다

PKCS#7 을 빈틈없이 검사하는 식으로는 해결이 안 된다. 복호하기 전에 MAC 을 확인하고 틀리면 복호 자체를 하지 않는 Encrypt-then-MAC 이거나, AES-GCM 같은 AEAD 를 쓰면 공격자가 조작한 암호문은 패딩 검사까지 도달하지 못한다. 지금 새로 짜는 코드라면 운용 모드를 고민할 게 아니라 그냥 AEAD 를 쓰면 된다.

IV 를 숨긴 건 방어가 아니라 비용 2배였다

이 문제가 원본 교과서 공격과 다른 점은 IV 비공개 하나다. 덕분에 블록을 두 개 복원해야 했지만 질의가 두 배로 늘었을 뿐 난이도는 그대로다. 오히려 IV 를 세션 내내 재사용한 쪽이 치명적이다. 매 호출 새 IV 를 뽑았다면 Encrypt(0) 이 매번 다른 값을 내서 C_zero 를 고정할 수 없었다.

로컬 검증은 알고리즘까지만 보장한다

복제본에서 4천 번 질의가 6초에 깨끗이 돌았는데 라이브에서는 21바이트째에 멈췄다. 차이는 TCP 가 응답을 어디서 자르느냐 하나였다. 네트워크 너머로 파이프라이닝할 거면 "응답이 몇 개 왔나"가 아니라 "완결된 응답이 몇 개 왔나"를 세야 한다. 프레이밍을 대충 짜면 조용히 틀린 답을 쥐고 엉뚱한 곳을 뒤지게 된다.

이 글이 도움이 됐나요?

Comments

댓글

0개

댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)

댓글 불러오는 중…

Related

관련 글

3개
[🥈 Silver 3] 에러 메시지 한 줄이 키를 흘린다 — DreamHack Padding Oracle 풀이
blog

[🥈 Silver 3] 에러 메시지 한 줄이 키를 흘린다 — DreamHack Padding Oracle 풀이

AES-CBC로 암호화된 관리자 글을 키 없이 읽는다. unpad() 실패가 ValueError로 새어나오는 걸 오라클 삼아 패딩 오라클 공격으로 한 바이트씩 복호했다. 고정 IV, 인증 없는 복호, 구분되는 예외 메시지가 겹쳐 암호문이 그대로 평문이 됐다. 삽질과 캡쳐를 곁들인 풀이 노트.
#dreamhack#ctf#web+4
2026-06-06#dreamhack +5
[🥈 Silver 3] 평문이 어느 1/4 구간인지만 흘려도 RSA 는 통째로 털린다 — DreamHack Supernova 풀이
blog

[🥈 Silver 3] 평문이 어느 1/4 구간인지만 흘려도 RSA 는 통째로 털린다 — DreamHack Supernova 풀이

서버는 복호한 평문이 0 부터 N 사이의 어느 1/4 구간에 있는지만 알려준다. 2비트짜리 정보인데, textbook RSA 의 곱셈 준동형으로 2의 k승을 곱해 가며 물어보면 그 응답이 곧 m/N 의 2진 전개가 된다. 520번 물어보고 1024비트 평문을 그대로 복원한 과정과, 그 전에 세 번 미끄러진 자리를 정리했다.
#dreamhack#ctf#crypto+5
2026-10-25#dreamhack +4
[🥈 Silver 1] MixColumns 하나가 빠지면 AES 는 XOR 표로 무너진다 — DreamHack locked present 풀이
blog

[🥈 Silver 1] MixColumns 하나가 빠지면 AES 는 XOR 표로 무너진다 — DreamHack locked present 풀이

25바이트 비밀번호를 검사하는 stripped ELF 안에 5x5 블록 AES 변형이 통째로 들어 있다. S-box 도 Rcon 도 진짜 AES 인데 MixColumns 만 없다. 그 하나가 빠지자 확산이 사라져 평문 바이트 하나가 암호문 바이트 하나로 그대로 대응했고, 라운드가 10 이라 열 회전마저 상쇄돼 자리 순열까지 항등이 됐다. 재구현으로 되감아 비밀번호를 복구하고, 키를 전혀 모르는 상태에서도 같은 값이 나오는 두 가지 경로를 따로 실증했다.
#dreamhack#ctf#reversing+7
2026-08-19#dreamhack +4