2026-06-06·1분 읽기·
AES-CBC로 암호화된 관리자 글을 키 없이 읽는다. unpad() 실패가 ValueError로 새어나오는 걸 오라클 삼아 패딩 오라클 공격으로 한 바이트씩 복호했다. 고정 IV, 인증 없는 복호, 구분되는 예외 메시지가 겹쳐 암호문이 그대로 평문이 됐다. 삽질과 캡쳐를 곁들인 풀이 노트.
이 글이 도움이 됐나요?
문제: DreamHack — Padding Oracle 분류: Web / Crypto 난이도: 🥈 Silver 3 FLAG:
DH{0cfeeb1ded546cb87e43caf2df2c6ec078c559b5}
"작성했던 메모를 볼 수 있도록 도와주는 API 서비스. 관리자가 Secret키를 잊어버리지 않게 글을 써뒀으니, 공격으로 그 글을 읽어라." 문제 이름이 곧 정답이다 — Padding Oracle. 그래도 어디에 오라클이 숨어 있고, 왜 그게 키를 흘리는지 직접 더듬어 본 기록이다.

| 항목 | 내용 |
|---|---|
| 문제명 | Padding Oracle |
| 난이도 | 🥈 Silver 3 |
| 분류 | Web / Crypto |
| 스택 | Flask + PyCryptodome (AES-CBC) |
| 제공 | 전체 소스 + 서버 |
| 핵심 | 고정 IV AES-CBC + 인증 없는 복호 + 구분되는 예외 = 패딩 오라클 |
목표는 관리자 글 읽기다. 관리자 글의 평문은 The password is: <8자 비밀번호>이고, 그 비밀번호를 /secure/secret에 넣으면 진짜 플래그(flag.txt)가 나온다. 암호문은 누구나 /gb/1로 받아올 수 있다. 키 없이 그 암호문을 복호하는 게 전부다.
/gb/ — 글 목록 (idx, author, title)/gb/<idx> — 글 한 건 전체. 여기에 enc_data(암호문 base64)와 sig가 그대로 들어 있다./login?id=&pw= — 로그인 성공 시 token(암호화된 {"user_id":..,"group":..}) 발급/secure/decrypt?e_data=&token=&sig= — 글 복호. 권한 검사 후 content 반환/secure/secret?password= — 비밀번호 맞으면 flag 반환목록과 관리자 글부터 본다.

/gb/1을 열면 우리가 복호해야 할 암호문이 통째로 노출돼 있다.

class AESCrypto:
def __init__(self, mode=AES.MODE_CBC):
self._block_size = 16
key = open(".../rand_key", "rb").read(16) # 랜덤 키 (우리는 모름)
iv = bytes([0x00] * 16) # ← IV가 0으로 고정
self.crypto = AES.new(key, mode, iv)
def decrypt(self, enc_text):
dec_data = self.crypto.decrypt(enc_text)
dec_text = unpad(dec_data, self._block_size)
AES-CBC인데 **IV가 항상 0x00 * 16**이다. 그리고 decrypt는 복호 후 unpad를 부른다. PKCS#7 패딩이 어긋나면 unpad은 ValueError를 던진다. 패딩 오라클의 절반은 여기서 이미 갖춰졌다 — 남은 건 "그 예외가 바깥으로 새어나오는가"다.
try:
data = json.loads(
AESCrypto().decrypt(base64.b64decode(e_data)).decode("latin-1").encode("latin-1")
)
article_user_id = data["author"]
content = data["content"]
data = json.loads(AESCrypto().decrypt(base64.b64decode(token)))
user_id, group = data["user_id"], data["group"]
if (user_id == article_user_id and gGuestBook.isValidSig(user_id, sig)) or group == "admin":
return jsonify({"status"
예외 종류마다 다른 메시지를 친절하게 돌려준다. e_data가 가장 먼저 복호되니, e_data의 패딩이 깨지면 unpad → ValueError → "ValueError" 응답으로 즉시 끝난다. 이게 오라클이다.
공격을 보기 전에 두 가지만 짚고 간다. 둘 다 이 문제의 토대다.
AES는 16바이트(128비트) 블록 단위로 동작하는 블록 암호다. CBC(Cipher Block Chaining)는 블록들을 사슬처럼 엮는 운용 모드로, 평문 블록을 암호화하기 전에 앞 블록의 암호문과 XOR한다. 첫 블록은 앞 블록이 없으니 IV(초기화 벡터)와 XOR한다.
복호는 그 반대다. 암호문 블록을 블록 복호(D)한 다음, 앞 블록의 암호문과 XOR해서 평문을 얻는다.

출처: Wikimedia Commons, public domain
P[i] = D(C[i]) XOR C[i-1] (첫 블록의 C[i-1] 은 IV)핵심은 앞 블록 C[i-1] 이 평문에 그대로 XOR로 섞인다는 점이다. 암호문을 우리가 재조립해 보낼 수 있다면, 앞 블록의 한 바이트를 바꾸는 것만으로 다음 블록 평문의 같은 위치 바이트를 원하는 대로 흔들 수 있다. 패딩 오라클은 이 성질을 그대로 쓴다.
블록 암호는 입력 길이가 블록 크기의 배수여야 한다. 모자란 만큼 규칙대로 채우는 게 패딩이고, 가장 흔한 규칙이 PKCS#7이다. N바이트가 모자라면 값이 N인 바이트를 N개 붙인다. 3바이트 모자라면 03 03 03, 1바이트 모자라면 01. 길이가 딱 맞으면 블록 하나를 통째로 10 16개로 채운다.

출처: Wikimedia Commons, Bilgehanturan, CC BY-SA 4.0
복호할 때 unpad 은 마지막 바이트를 읽어 그 수만큼 뒤에서 떼어내고, 떼어낸 바이트가 모두 같은 값인지 검사한다. 어긋나면 ValueError 다. 결국 패딩이 맞는지 틀리는지는 yes/no 한 비트의 정보인데, 앞 블록을 우리가 흔들 수 있는 상황에서 이 한 비트가 새어나가면 평문이 한 바이트씩 드러난다.
오라클을 쓰기 전에, 권한 검사를 정면으로 통과할 수 있는지부터 따져봤다.
/secure/decrypt의 권한 조건은 (user_id == author && 유효 sig) || group == "admin"이다. 관리자 글의 author는 "admin"이고, sig는 /gb/1에 그대로 노출돼 있다. 그러니 user_id가 "admin"인 token만 있으면 첫 번째 조건을 통과한다.
문제는 token은 서버가 키로 암호화해 발급한다는 것. /login으로 받을 수 있는 계정은 guest:guest뿐이고, 그 토큰은 {"user_id":"guest","group":"user"}라 user_id가 admin이 아니다. admin 계정 비번(admin_pw)은 32바이트 랜덤이라 로그인도 못 한다. 키 없이 user_id:"admin" 토큰을 만들 방법이 정공법엔 없다.
CBC라면 이전 블록을 조작해 다음 블록 평문을 비트 단위로 바꿀 수 있다(비트플립). guest 토큰을 받아 "group":"user"를 "group":"admin"으로 바꿔볼까 했는데, user(4자)와 admin(5자)은 길이가 다르고, 비트플립한 블록 자체는 완전히 깨져 JSON이 망가진다. 토큰은 한 블록 안에 user_id와 group이 같이 들어 있어 깔끔하게 조작하기 어렵다. 굳이 토큰을 손대느니, 글 암호문을 직접 복호하는 게 낫다.
여기서 방향을 틀었다. /secure/decrypt는 권한 검사 이전에 e_data를 복호한다. 그리고 복호 과정의 패딩 오류가 "ValueError"로 새어나온다. 토큰도, 권한도 필요 없다. 패딩 오라클로 e_data(=관리자 글 암호문) 자체를 한 바이트씩 복호하면 된다.
오라클이 정말 도는지 눈으로 확인했다. 같은 타깃 블록 앞에 붙인 블록의 마지막 한 바이트만 바꿔서 두 번 요청한다.


URL의 base64에서 딱 한 글자(...2AJ5... ↔ ...2AZ5...)가 다를 뿐인데 응답이 "ValueError"와 "UnicodeDecodeError"로 갈린다. 서버가 "지금 패딩이 맞다/틀리다"를 그대로 알려주는 것이다.
왜 유효할 때 UnicodeDecodeError가 뜰까. e_data 경로는 decrypt(...).decode("latin-1").encode("latin-1")까지 한 뒤 json.loads(bytes)를 부른다. json.loads는 바이트를 받으면 UTF-8로 디코딩하는데, 복호된 게 깨진 바이트라 UTF-8이 아니면 UnicodeDecodeError가 난다. 정리하면 이렇다.
| 응답 message | 의미 |
|---|---|
ValueError | unpad 실패 → 패딩 invalid |
UnicodeDecodeError / JSONDecodeError | 패딩 통과 후 단계에서 실패 → 패딩 valid |
오라클 판정은 단순하다 — message가 ValueError면 패딩이 틀린 것, 아니면 맞은 것.
CBC 복호의 정의에서 출발한다.
P[i] = D(C[i]) XOR Prev
Prev = 앞 블록 C[i-1] (첫 블록은 IV = 0x00*16, 우리가 아는 값)D(C[i])(= 내부 중간값 I)만 알아내면 P[i]는 XOR 한 번으로 나온다. 그리고 I는 키와 무관하게 오라클로 캘 수 있다.
타깃 블록 C[i] 앞에 **내가 조작하는 가짜 블록 C'**를 붙여 두 블록만 보낸다. 서버가 보는 마지막 블록의 평문은 P' = D(C[i]) XOR C' = I XOR C'다. C'[15]를 0~255로 돌리다 패딩이 유효해지는 순간, 마지막 평문 바이트가 0x01이 된 것이다.
P'[15] = I[15] XOR C'[15] = 0x01
⇒ I[15] = C'[15] XOR 0x01다음은 P'[15]=0x02가 되게 C'[15]를 고정해 두고 C'[14]를 돌려 I[14]를 캔다. 이렇게 한 블록 16바이트를 끝에서부터 한 칸씩 복원하고, 블록마다 실제 앞 블록과 XOR하면 평문 전체가 나온다.

마지막 바이트(pad=1)를 캘 때, C'[15] 후보가 두 개 잡히는 경우가 있다. 하나는 진짜 P'[15]=0x01, 다른 하나는 우연히 P'[14..15]=0x02 0x02가 되어버린 경우다. 구분하려면 후보마다 C'[14]를 아무 값으로 흔들어 다시 물어본다. 0x01이 진짜면 마지막 바이트만 보므로 여전히 유효하고, 0x02 0x02였다면 깨진다. 스크립트에 이 검증을 넣어 안정화했다.
속도를 욕심내 스레드 32개로 때렸더니 ConnectTimeout이 쏟아졌다. 원격이 동시 연결을 못 버틴 것. 워커를 8개로 줄이고 세션에 커넥션 풀과 타임아웃 재시도를 붙이니 안정적으로 돌았다. 4블록(64바이트)이라 바이트당 평균 128회, 총 ~1만 회 요청으로 끝났다.
e_data만 보내면 되니 token/sig는 더미(x)로 채운다. e_data가 먼저 복호되며 예외로 끊기기 때문에 토큰은 평가되지 않는다.
#!/usr/bin/env python3
import sys, json, base64, requests
from requests.adapters import HTTPAdapter
from concurrent.futures import ThreadPoolExecutor
BASE = sys.argv[1].rstrip("/")
BS, IV, WORKERS = 16, bytes(16), 8
sess = requests.Session()
sess.mount("http://", HTTPAdapter(pool_connections=WORKERS, pool_maxsize=WORKERS))
def oracle_valid(ct: bytes) -> bool:
e_data = base64.b64encode
plaintext: {"content": "The password is: p45$W0Rd", "author": "admin"}
password : p45$W0Rd
flag : {'result': {'secret': 'DH{0cfeeb1ded546cb87e43caf2df2c6ec078c559b5}'}, 'status': 'success'}복호한 평문에 비밀번호 p45$W0Rd가 들어 있고, 이걸 /secure/secret에 넣으면 플래그가 떨어진다.


복호 오류를 종류별로 알려주면 그게 오라클이 된다.
ValueError, UnicodeDecodeError, JSONDecodeError를 각각 다른 메시지로 돌려준 게 결정타였다. 패딩 검증의 성패가 한 비트라도 바깥으로 새면, 공격자는 그 한 비트를 16바이트 × 블록 수만큼 모아 평문을 통째로 복원한다. 실패는 전부 똑같은 응답·똑같은 시간으로 처리해야 한다.
고정 IV와 "복호 후 인증"이 판을 키웠다.
IV가 0으로 고정돼 첫 블록까지 깔끔하게 복원됐고, 무엇보다 권한 검사 전에 복호를 수행해 인증 없이도 오라클을 두드릴 수 있었다. 복호는 인증을 통과한 뒤에 해야 하고, 가능하면 복호 자체를 노출하지 않아야 한다.
CBC + 패딩은 그 자체로 위험하다.
근본 해법은 패딩 오라클이 원천적으로 불가능한 인증 암호(AES-GCM 등)나 Encrypt-then-MAC을 쓰는 것이다. 복호 전에 MAC을 먼저 검증하면, 패딩을 들여다보기도 전에 위조된 암호문이 걸러진다. 이 문제처럼 메모 하나가 아니라 세션 토큰·쿠키가 같은 구조였다면 곧장 계정 탈취로 이어진다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…