36. Padding Oracle: 암호문이 알려주는 비밀
Padding oracle은 공격자가 보낸 암호문의 패딩이 유효한지 외부에서 구분할 수 있는 복호화 인터페이스다. 공격자는 키나 복호화 결과를 직접 보지 못해도, 이 1비트짜리 응답을 반복해서 물어 CBC 평문을 바이트 단위로 복구할 수 있다.
중요한 정정부터 하자. 이것은 “암호문만 보고 푸는 공격”이 아니다. 반드시 변조한 암호문을 여러 번 제출하고, 서버가 돌려주는 유효/무효 신호를 관찰해야 한다. 깨지는 것도 AES 자체가 아니라 무결성 없이 CBC를 사용하고 내부 오류 상태를 노출한 프로토콜이다.
이 문서의 실습은 고정된 로컬 바이트 배열만 사용한다. 허가받은 CTF, 개인 실습 환경, 명시적인 점검 범위 밖의 시스템에는 시도하지 않는다.
공격이 성립하는 조건
다음 조건이 함께 있어야 한다.
- 클라이언트가 다시 제출할 수 있는 값이 CBC 모드와 PKCS#7 계열 패딩으로 암호화돼 있다.
- 공격자가 IV 또는 암호문 블록을 바꿀 수 있다.
- 암호문 인증이 없거나, 서버가 인증을 확인하기 전에 복호화와 unpadding을 수행한다.
- 패딩 성공과 실패를 반복 요청으로 구분할 수 있다.
오라클은 꼭 PaddingError 같은 친절한 문구일 필요가 없다. HTTP 상태 코드, 본문 길이, 리다이렉트 위치, 쿠키 설정 여부, 연결 종료 방식이 달라도 된다. 처리 시간만 다르다면 여러 표본의 분포를 비교해야 한다. 한 번 빠르거나 느린 응답은 네트워크 잡음이지 증거가 아니다.
HTTPS도 애플리케이션 토큰의 padding oracle을 자동으로 막지 않는다. TLS가 전송 구간을 보호해도, 서버가 TLS 안에서 받은 암호화 쿠키를 취약하게 복호화하면 애플리케이션 계층 오라클은 그대로 존재한다.
PKCS#7 패딩은 무엇을 검증하는가
블록 크기가 16바이트일 때 평문 길이가 13바이트라면 세 바이트를 채워야 한다. PKCS#7은 남은 칸의 개수 03을 세 번 쓴다.
73 65 73 73 69 6f 6e 3d 67 75 65 73 74 03 03 03
s e s s i o n = g u e s t
평문 길이가 이미 16의 배수여도 패딩을 생략하지 않는다. 새 블록 하나를 추가해 10을 16번 넣는다. 그래서 마지막 바이트가 04라면 끝의 네 바이트가 모두 04인지, 01이라면 마지막 한 바이트가 01인지 검사해 모호함 없이 제거할 수 있다. 이 규칙은 RFC 5652 §6.3에 정의돼 있다.
패딩 검사는 평문 전체가 맞는지 알려주지 않는다. 마지막 바이트열이 문법에 맞는지만 알려준다. 그런데 CBC의 XOR 구조와 결합하면 이 작은 정보가 평문을 복구하기에 충분하다.
CBC 식에서 공격이 보이는 지점
CBC에서 현재 암호문 블록을 C_i, 이전 블록을 C_{i-1}, 복호화된 평문을 P_i라고 하자. 블록 암호의 중간값 I_i를 도입하면 복호화는 다음과 같다.
공격자는 키를 모르므로 I_i를 직접 계산할 수 없다. 하지만 이전 블록을 C'_{i-1}로 바꿔 제출하면 서버가 검사하는 평문은 다음처럼 바뀐다.
마지막 바이트의 후보 평문을 g, 만들고 싶은 패딩을 01이라고 하자. 이전 블록의 마지막 바이트를 다음처럼 바꾼다.
g가 실제 평문 바이트와 같으면 변조된 평문의 마지막 바이트가 의도한 01이 되어 오라클이 성공한다. 다만 원문에 있던 더 긴 유효 패딩도 별도의 성공 후보를 만들 수 있으므로 추가 확인이 필요하다. 의도한 01 후보임을 확인했다면 같은 관계를 다음처럼 풀 수 있다.
마지막 바이트를 알아낸 뒤에는 그 바이트가 02로 복호화되도록 고정하고 바로 앞 바이트 후보를 순회한다. 그다음은 03 03 03, 그다음은 04 04 04 04를 만드는 식이다. 오른쪽에서 왼쪽으로 16번 반복하면 한 블록이 복구된다.
한 블록을 복구하는 순서
- 복구할
C_i는 고정하고C_{i-1}의 마지막 바이트만 0부터 255까지 바꾼다. - 패딩 성공 후보를 찾으면 의도한
01인지 확인한다. 이미 원문 끝에 있던02 02나03 03 03이 우연히 성공할 수 있기 때문이다. - 알아낸 바이트를 다음 라운드의 패딩 값으로 고정한다.
- 목표 패딩을
02,03, …,10으로 늘리며 왼쪽으로 이동한다. - 앞 블록으로 이동해 같은 작업을 반복한다. 첫 평문 블록은 공격자가 IV를 변조해 제출할 수 있을 때 같은 방식으로 복구된다.
정답 후보의 탐색 위치가 균등하다고 가정하면 AES의 16바이트 블록 하나에는 약 16 × 128 = 2,048번의 오라클 질의가 기대된다. 실제 횟수는 평문 바이트 분포와 탐색 순서에 따라 달라진다. 최악의 단순 탐색은 4,096번이고, 오탐 확인과 네트워크 재시도는 여기에 더해진다. 오라클 응답이 느리다고 취약점이 사라지는 것은 아니다.
안전한 로컬 실습
공격은 D_K(C_i)를 계산하지 않는다. 같은 C_i를 공격하는 동안 중간값 I_i가 고정돼 있다는 사실만 사용한다. 따라서 아래 코드는 AES 라이브러리나 네트워크 없이 그 중간값을 비밀 바이트열로 모델링한다. 공격 루프가 보는 것은 oracle()의 불리언뿐이다.
BLOCK = 16
def xor_bytes(left: bytes, right: bytes) -> bytes:
return bytes(a ^ b for a, b in zip(left, right, strict=True))
def pkcs7_pad(data: bytes) -> bytes:
length = BLOCK - len(data) % BLOCK
return data + bytes([length]) * length
def valid_pkcs7(block: bytes) -> bool:
if len(block) != BLOCK:
return False
length = block[-1]
return 1 <= length <= BLOCK and block[-length:] == bytes([length]) * length
# 고정된 C_i에 대한 I_i = D_K(C_i)를 모델링한다. 공격 코드는 이 값을 모른다.
plaintext = pkcs7_pad(b"session=guest")
previous = bytes.fromhex("00112233445566778899aabbccddeeff")
intermediate = xor_bytes(previous, plaintext)
queries = 0
def oracle(candidate_previous: bytes) -> bool:
global queries
queries += 1
candidate_plaintext = xor_bytes(intermediate, candidate_previous)
return valid_pkcs7(candidate_plaintext)
def recover_block(original_previous: bytes) -> bytes:
recovered = bytearray(BLOCK)
for index in reversed(range(BLOCK)):
pad = BLOCK - index
crafted = bytearray(original_previous)
# 이미 복구한 오른쪽 바이트를 현재 목표 패딩으로 고정한다.
for known in range(index + 1, BLOCK):
crafted[known] = original_previous[known] ^ recovered[known] ^ pad
for guess in range(256):
crafted[index] = original_previous[index] ^ guess ^ pad
if not oracle(bytes(crafted)):
continue
# 목표 패딩 밖의 바이트를 뒤집어 기존 패딩이 만든 오탐을 거른다.
if index > 0:
verification = bytearray(crafted)
verification[index - 1] ^= 1
if not oracle(bytes(verification)):
continue
recovered[index] = guess
break
else:
raise RuntimeError(f"no candidate at byte {index}")
return bytes(recovered)
recovered = recover_block(previous)
assert valid_pkcs7(recovered)
pad_length = recovered[-1]
print(recovered[:-pad_length].decode())
print(f"queries: {queries}")
출력의 첫 줄은 session=guest다. 질의 횟수는 후보 바이트 값에 따라 달라지지만, 공격 함수는 plaintext와 intermediate를 참조하지 않는다. 실제 환경에서는 로컬 함수 호출 자리에 변조한 토큰을 보내는 요청과 응답 분류기가 들어간다.
오탐과 불안정한 오라클
원본 평문이 이미 03 03 03으로 끝난다면 마지막 바이트를 바꾸지 않은 후보도 “유효”로 보인다. 그대로 01을 찾았다고 기록하면 이후 복구가 틀어진다. 위 실습은 목표 패딩 영역 바로 앞 바이트를 한 번 더 뒤집는다. 진짜 01 패딩은 마지막 바이트만 검사하므로 여전히 성공하지만, 우연히 유지되던 03 03 03은 깨진다.
실서비스에서는 더 많은 변수가 있다.
- 로드 밸런서나 여러 애플리케이션 버전이 서로 다른 오류를 낼 수 있다.
- rate limit, 재시도, 캐시가 응답 분류를 흐릴 수 있다.
- 압축, Base64 정규화, URL encoding 때문에 보낸 바이트가 그대로 도착하지 않을 수 있다.
- 타이밍 오라클은 상태 코드 오라클보다 훨씬 많은 표본과 통계 검증이 필요하다.
따라서 한두 번의 응답 차이만으로 취약점을 단정하지 않는다. 동일한 기준 토큰과 동일 위치 변조를 반복해 재현성을 확인하고, 가능한 경우 서버 로그와 코드 경로로 교차 검증한다.
방어: 오류를 숨기기 전에 인증하라
가장 좋은 해결책은 CBC 오류 처리를 정교하게 꾸미는 것이 아니라 인증된 암호화로 교체하는 것이다.
1. 새 설계는 AEAD 사용
검증된 라이브러리의 AES-GCM 또는 ChaCha20-Poly1305 같은 AEAD를 사용한다. AEAD는 기밀성과 무결성을 하나의 인터페이스로 제공하므로, 인증 태그가 틀린 암호문을 평문으로 넘기지 않는다.
- 같은 키에서 nonce를 재사용하지 않는다. 알고리즘별 권장 길이와 검증된 라이브러리의 생성 전략을 따른다. 카운터 방식은 재시작 후에도 중복되지 않게 상태를 보존하고, 무작위 방식은 충돌 예산과 키 교체 시점을 설계한다. 특히 GCM의 nonce 재사용은 별개의 치명적 취약점이다.
- 버전, 알고리즘 식별자, 프로토콜 컨텍스트처럼 암호화하지 않지만 변조되면 안 되는 값은 모호하지 않게 인코딩해 AAD로 인증한다.
- 인증 실패 뒤에는 복호화된 값의 파싱이나 부분 사용을 하지 않는다.
- 라이브러리의 인증 실패를 외부에는 하나의 일반 오류로 매핑한다.
TLS 1.3이 이전 버전과 달리 모든 레코드 암호를 AEAD로 모델링하는 것도 같은 설계 방향이다.
2. 레거시 CBC는 Encrypt-then-MAC
즉시 교체할 수 없다면 암호화 키와 별도의 MAC 키를 사용하고, IV와 암호문 전체의 MAC을 먼저 검증한 뒤 CBC 복호화와 unpadding을 수행한다.
receive(iv, ciphertext, tag)
expected = HMAC(mac_key, version || iv || ciphertext)
if !constant_time_equal(tag, expected):
return generic_failure
padded = CBC_decrypt(enc_key, iv, ciphertext)
plaintext = strict_unpad(padded)
return parse(plaintext)
MAC 검증 전에 복호화하면 패딩 경로가 다시 외부 입력에 닿는다. 암호화와 MAC에 같은 키를 재사용해서도 안 된다. 신규 코드라면 조합 순서와 키 분리를 직접 설계하지 말고 AEAD API를 선택한다.
3. 균일한 오류는 보조 방어
패딩 오류, MAC 오류, 파싱 오류를 동일한 상태 코드와 본문으로 반환하고 내부 로그에서만 구분한다. 다만 이것만으로는 충분하지 않다. 코드 경로와 처리량 차이가 남으면 타이밍 오라클이 될 수 있고, 임의의 고정 sleep은 스케줄링과 네트워크 잡음을 통제하지 못한다. 인증되지 않은 암호문이 복호화 경로에 도달하지 않게 만드는 것이 근본 해결이다.
검증 체크리스트
- 클라이언트가 보낸 IV·암호문을 인증 전에 복호화하지 않는가?
- 인증 태그 실패 시 평문, padding, parser 상태를 전혀 사용하지 않는가?
- 오류 상태 코드·본문·헤더·리다이렉트가 외부에서 하나로 보이는가?
- 타이밍 비교는 단일 요청이 아니라 반복 표본으로 검증했는가?
- AEAD nonce가 재시작·다중 인스턴스까지 포함해 같은 키에서 재사용되지 않도록 보장되는가?
- 레거시 Encrypt-then-MAC의 MAC 범위에 버전·IV·암호문이 모두 포함되는가?
- 회귀 테스트가 정상 토큰 1개와 여러 종류의 변조 토큰을 모두 다루는가?
관련 문서
- AES-CBC 비트플립 공격 빠른 감 — 같은 XOR 가변성이 무결성 없는 CBC에서 어떻게 값 변조로 이어지는지
- 암호학 공격 기법 입문 — padding oracle, bit-flipping, ECB를 함께 보는 개요
- ECB Cut-and-Paste — 블록 모드 선택과 인증 부재의 또 다른 실패 사례
참고 자료
- RFC 5652 §6.3 — Content-encryption Process — CMS/PKCS#7 패딩 바이트 규칙
- Serge Vaudenay, “Security Flaws Induced by CBC Padding” (EUROCRYPT 2002) — padding oracle을 체계화한 원 논문
- OWASP WSTG-CRYP-02 — Testing for Padding Oracle — 관찰 신호와 테스트 조건
- RFC 5116 — Authenticated Encryption — AEAD 인터페이스와 nonce 요구사항
- RFC 8446 §5.2 — TLS 1.3 Record Payload Protection — TLS 1.3의 AEAD 레코드 보호