문제: 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
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)가 그 중 하나다.

출처: 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
전문은 이렇다.
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()세 줄이 눈에 걸린다.
| 줄 | 내용 | 왜 문제인가 |
|---|---|---|
| 6 | secret, key, iv 를 한 번만 뽑는다 | 세션이 끝날 때까지 IV 가 고정이다. 매 호출 새로 만들었으면 오라클을 누적할 수 없다 |
| 26~29 | unpad 실패를 except 로 삼켜 고정 문구로 바꾼다 | 문구는 두 가지뿐이지만 그게 곧 참/거짓 1비트다 |
| 23 | bytes.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,rawcat local/run_server.sh && (local/run_server.sh >local/server.log 2>&1 &) && sleep 2 && ss -ltn | grep 17122
이제 복제본에 정찰 스크립트를 던진다. 확인하고 싶은 건 셋이다. 암호화가 결정적인가(= 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
세 가지가 한 번에 확인됐다.
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 RAES_dec(C) 는 고정값이고 R 은 우리가 바이트 단위로 고른다. 즉 마지막 블록 평문을 원하는 대로 흔들 수 있다. 앞 블록 평문은 IV 를 몰라 쓰레기가 나오지만 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 만 탈락한다](/_next/image?url=%2Fimages%2Fblog%2Fdreamhack-padding-miracle-attack-writeup%2F04_lastbyte.png&w=3840&q=75)
평소에는 후보가 하나고, 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)
비용은 블록 두 개, 즉 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
이 도구가 생기면 복원 결과를 식 두 개로 검산할 수 있다.
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
더미 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
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
로컬 환경은 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
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…