Step 233. AES 구조와 모드별 취약점 — 세계 표준의 해부도와 실패 지도

Step 233. AES 구조와 모드별 취약점 — 세계 표준의 해부도와 실패 지도

Level 3 — CTF 실전과 공격 스킬 심화 | 난이도 ★★★☆☆ | 예상 소요 시간 4시간

전제: Step 231~232(Cryptopals Set 1~2) — 오늘은 이틀간 깨뜨린 그 AES의 내부 구조를 열어 봅니다.

⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.

  • 준비물: 파이썬 3(실측: 3.12.14), pycryptodome.
  • 주의: 오늘은 개념 챕터입니다 — 직접 구현보다 "구조를 읽고 모드의 실패 지점을 지도로 그리는" 것이 목표입니다. 실험 출력은 전부 실측입니다.

AES는 미국 표준(NIST FIPS 197)이자 사실상 전 세계 대칭키 암호의 단일 표준입니다 — TLS, 디스크 암호화, Wi-Fi까지 전부 AES 위에 있습니다. 그런데 이틀간 우리는 이 "세계 표준"을 반복해 깨뜨렸습니다. 모순이 아닙니다 — 깨진 것은 AES 알고리즘이 아니라 모드의 사용법이었습니다. 오늘은 AES의 내부 구조(SPN, 라운드)를 이해하고, ECB/CBC/CTR/GCM 네 모드가 각각 어디서 어떻게 무너지는지 정리합니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • AES의 블록·키 크기와 라운드 수(10/12/14)의 대응을 말한다
  • SPN 구조의 네 연산(SubBytes, ShiftRows, MixColumns, AddRoundKey)의 역할을 설명한다
  • ECB가 왜 패턴을 보존하는지 이미지 실험으로 증명한다
  • CBC, CTR의 구조와 각각의 대표 취약점(오라클, 키스트림 재사용)을 연결한다
  • CTR nonce 재사용 공격을 실험하고, GCM(AEAD)이 무엇을 더하는지 설명한다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 파이썬 3 (실측: 3.12.14) + pycryptodome
오늘의 명령어 AES.new(key, AES.MODE_ECB/CBC/CTR/GCM), Crypto.Util.Counter
필요한 개념 SPN(치환-치환 네트워크), 라운드, 블록 암호 모드, nonce, AEAD
오늘의 산출물 ECB 패턴 유출 이미지 증명 + CTR 재사용 공격 실측 + 모드별 취약점 표

2-1. AES 한눈에 — 블록, 키, 라운드

AES는 128비트(16바이트) 블록을 다루는 대칭키 블록 암호입니다. 키 크기는 128/192/256비트 셋이고, 라운드 수는 각각 10/12/14회입니다. 입력 16바이트를 4×4 상태 행렬(state)에 담고, 라운드마다 네 연산을 적용해 섞습니다.

SubBytes   : 바이트마다 S-box 치환        — 비선형성(표를 거치면 선형 관계가 깨짐)
ShiftRows  : 행을 엇갈리게 밀기           — 행 안 바이트를 다른 열로 확산
MixColumns : 열을 행렬 곱으로 섞기        — 한 바이트 변화가 열 전체로 번짐
AddRoundKey: 라운드 키와 XOR              — 비밀(키)이 들어오는 유일한 지점

마지막 라운드는 MixColumns를 생략합니다. 이 구조를 SPN(Substitution-Permutation Network)이라 부릅니다 — 치환(S-box)과 교란(행 이동·열 섞기)을 반복해, 10라운드 뒤에는 입력 1비트의 변화가 출력 전체에 퍼집니다(눈사태 효과). 알고리즘 자체는 20년 넘게 실용적 공격이 없습니다. 취약점은 전부 모드에서 나옵니다.

2-2. 모드 — 블록 암호를 긴 데이터에 쓰는 방법

AES는 16바이트만 암호화합니다. 긴 데이터에 쓰는 "이어붙이기 규칙"이 모드입니다:

  • ECB: 블록마다 독립 암호화. 같은 블록 = 같은 암호문. 사용 금지.
  • CBC: C[i] = Enc(P[i] ⊕ C[i-1]). 연쇄로 패턴을 숨기지만 무결성이 없어 비트 플리핑·패딩 오라클에 노출(Step 232 실증).
  • CTR: 카운터를 암호화해 만든 키스트림을 평문과 XOR — 사실상 스트림 암호. 패딩 불필요, 병렬화 가능. 단, nonce 재사용이 치명적.
  • GCM: CTR + 인증 태그(GHASH). 변조까지 검출하는 AEAD(인증된 암호). 현대 표준 선택지.

2-3. nonce — "한 번만 쓰는 수"

CTR/GCM의 nonce(number used once)는 키스트림의 시작점입니다. 같은 키 + 같은 nonce로 두 평문을 암호화하면 같은 키스트림이 재사용되고, C1 ⊕ C2 = P1 ⊕ P2 — 키스트림이 상쇄돼 평문끼리의 XOR만 남습니다. 한쪽 평문을 알면 다른 쪽이 그대로 복구됩니다. 오늘 실측합니다.


3. 따라 하기

이 챕터의 모든 출력은 2026-09-09 파이썬 3.12.14 + pycryptodome 실측입니다.

3-1. 같은 평문 블록의 운명 — ECB vs CBC

from Crypto.Cipher import AES
import secrets

key = secrets.token_bytes(16)
pt = b"same block here!" * 8          # 16바이트 블록이 8번 반복
ct_ecb = AES.new(key, AES.MODE_ECB).encrypt(pt)
ct_cbc = AES.new(key, AES.MODE_CBC, iv=secrets.token_bytes(16)).encrypt(pt)
for name, ct in (("ECB", ct_ecb), ("CBC", ct_cbc)):
    blocks = [ct[i:i+16] for i in range(0, len(ct), 16)]
    print(f"{name}: 평문 8개 동일 블록 -> 암호문 고유 블록 {len(set(blocks))}개")
ECB: 평문 8개 동일 블록 -> 암호문 고유 블록 1개
CBC: 평문 8개 동일 블록 -> 암호문 고유 블록 8개

출력 읽는 법: 같은 키, 같은 평문입니다. ECB는 8개 블록이 1종류로 접혔고(Step 231~232의 모든 ECB 공격이 이 성질 위에 서 있습니다), CBC는 연쇄 덕에 전부 갈라졌습니다. 이 두 줄이 오늘 전체의 압축입니다.

3-2. ECB 펭귄 실험 — 이미지로 보는 패턴 유출

CTF의 명물 실험입니다. 그림 파일을 ECB로 암호화하면 윤곽이 그대로 보입니다. 원본 비트맵 대신, 코드로 직접 그린 240×240 BMP(단색 배경 + 중앙 흰 사각형)로 재현합니다.

import struct
W = H = 240
row_size = (W * 3 + 3) & ~3
pixels = bytearray()
for y in range(H):
    row = bytearray()
    for x in range(W):
        on = (60 <= x < 180) and (60 <= y < 180)      # 중앙 사각형
        row += bytes([255]*3) if on else bytes([30]*3)
    row += b"\x00" * (row_size - W * 3)
    pixels += row

bmp_header = b"BM" + struct.pack("<IHHI", 54 + len(pixels), 0, 0, 54)
bmp_header += struct.pack("<IiiHHIIiiII", 40, W, H, 1, 24, 0, len(pixels), 2835, 2835, 0, 0)

def encrypt_bmp(mode, pixels, key):
    pad = (16 - len(pixels) % 16) % 16
    body = bytes(pixels) + bytes([pad or 16]) * (pad or 16)
    if mode == "ECB":
        ct = AES.new(key, AES.MODE_ECB).encrypt(body)
    else:
        ct = AES.new(key, AES.MODE_CBC, iv=secrets.token_bytes(16)).encrypt(body)
    return bmp_header + ct[:len(pixels)]   # 헤더 54바이트는 유지해야 그림이 열린다

open("orig.bmp", "wb").write(bmp_header + bytes(pixels))
open("ecb.bmp", "wb").write(encrypt_bmp("ECB", pixels, key))
open("cbc.bmp", "wb").write(encrypt_bmp("CBC", pixels, key))

for name in ("orig", "ecb", "cbc"):
    body = open(f"{name}.bmp", "rb").read()[54:]
    blocks = [body[i:i+16] for i in range(0, len(body) - len(body) % 16, 16)]
    print(f"{name}.bmp: {len(blocks)}블록 중 고유 {len(set(blocks))}개 "
          f"(중복률 {100*(1-len(set(blocks))/len(blocks)):.1f}%)")
orig.bmp: 10800블록 중 고유 4개 (중복률 100.0%)
ecb.bmp: 10800블록 중 고유 4개 (중복률 100.0%)
cbc.bmp: 10800블록 중 고유 10800개 (중복률 0.0%)

출력 읽는 법: 원본은 단색 배경·사각형이라 4종 블록뿐입니다. ECB 암호문도 정확히 4종 — 암호화했는데 구조가 그대로입니다. 생성된 ecb.bmp를 그림 뷰어로 열어 보세요. 중앙 사각형의 윤곽이 암호문 위에 선명하게 남아 있고(2026-09-09 렌더링 실측), CBC 버전은 완전한 노이즈입니다. "암호화했는데 내용이 보인다"의 시각적 증명이 이것입니다.

3-3. CTR 모드 — 스트림 암호로서의 AES

CTR은 평문을 암호화하지 않습니다. 카운터 값을 암호화해 키스트림을 만들고 평문과 XOR합니다:

keystream[i] = Enc(key, nonce + i)
C[i] = P[i] XOR keystream[i]

복호화는 같은 키스트림을 다시 만들어 XOR — 암호화와 복호화가 같은 연산입니다. 패딩도 필요 없고(스트림이라 바이트 단위), 블록이 독립이라 병렬화도 됩니다. 대가는 nonce 관리입니다 — 바로 다음 실험의 주제입니다.

3-4. CTR nonce 재사용 공격

from Crypto.Util import Counter

ctr1 = Counter.new(128, initial_value=100)   # 고정 nonce/카운터
c1 = AES.new(key, AES.MODE_CTR, counter=ctr1).encrypt(b"Transfer 1000 USD to Alice")
ctr2 = Counter.new(128, initial_value=100)   # 같은 nonce 재사용 (실수!)
c2 = AES.new(key, AES.MODE_CTR, counter=ctr2).encrypt(b"Transfer 9000 USD to Mallory")

x = bytes(a ^ b for a, b in zip(c1, c2))     # c1 ⊕ c2 = m1 ⊕ m2
m1 = b"Transfer 1000 USD to Alice"            # 공격자가 아는 한쪽 평문
m2_rec = bytes(a ^ b for a, b in zip(x, m1))
print("CTR 재사용: c1^c2^m1 =", m2_rec)
CTR 재사용: c1^c2^m1 = b'Transfer 9000 USD to Mallo'

출력 읽는 법: 같은 키스트림이 양쪽에 걸렸으므로 c1 ⊕ c2에서 키스트림이 상쇄됩니다. 아는 평문 m1을 다시 XOR하니 두 번째 평문이 복구됐습니다(마지막 글자는 m1보다 1바이트 긴 부분이라 zip 범위 밖 — 실측 그대로입니다). "1000"과 "9000"처럼 숫자 하나가 다른 송금 지시라면, 공격자는 내용을 읽는 것을 넘어 차이를 이용한 조작까지 노릴 수 있습니다. nonce 재사용 한 번이 기밀성 전부를 무너뜨립니다.

3-5. GCM — 변조까지 막는 모드

gcm = AES.new(key, AES.MODE_GCM)
ct4, tag = gcm.encrypt_and_digest(b"important message")
bad = bytearray(ct4); bad[0] ^= 1
try:
    AES.new(key, AES.MODE_GCM, nonce=gcm.nonce).decrypt_and_verify(bytes(bad), tag)
    print("GCM 변조 검출: 실패(통과됨)")
except ValueError as e:
    print("GCM 변조 검출:", repr(str(e)))
GCM 변조 검출: 'MAC check failed'

출력 읽는 법: 암호문 1비트를 뒤집었더니 복호화 결과를 주기 전에 인증 태그 검증이 실패했습니다. Step 232의 패딩 오라클·비트 플리핑이 성립하려면 "변조된 암호문을 복호화해 주는 서버"가 필요했는데, GCM은 그 서버의 첫 행동을 "변조 폐기"로 바꿉니다. 그래서 현대 TLS는 AES-GCM(또는 ChaCha20-Poly1305)을 씁니다.

3-6. 모드별 취약점 지도

모드 구조 대표 취약점 현대적 평가
ECB 블록 독립 패턴 유출(3-2), 바이트 앳 어 타임 사용 금지
CBC 앞 블록 연쇄 패딩 오라클, 비트 플리핑(Step 232) 레거시. 쓴다면 Encrypt-then-MAC
CTR 카운터 키스트림 XOR nonce 재사용(3-4), 무결성 없음 GCM의 암호화 부품으로만
GCM CTR + GHASH 태그 nonce 재사용 시 인증 키까지 유출 현대 표준(AEAD)

주의할 점: GCM도 nonce 재사용은 치명적입니다(인증 키 유출까지 갑니다). "좋은 모드"도 규칙을 어기면 무너지고, 규칙은 단 하나 — 키와 nonce의 조합은 단 한 번만.


4. 미션과 연습문제

미션 — AES 모드 비교 실험 노트

  1. 3-1의 블록 통계 실험을 함수 block_uniqueness(ct, bs=16)로 정리하고, ECB/CBC/CTR 세 모드에 같은 반복 평문을 넣어 고유 블록 수를 비교합니다
  2. 3-2의 BMP 실험을 재현하고 ecb.bmpcbc.bmp를 그림 뷰어로 열어 봅니다 — 사각형이 보이는지 직접 확인하는 것이 이 미션의 완료 조건입니다
  3. 3-4의 CTR 재사용 공격에서, 이번에는 두 평문 중 아무것도 모른다고 가정하고 c1 ⊕ c2만 출력해 봅니다 — 영어 XOR 영어의 패턴(공백 XOR 문자 = 대소문자 반전)을 관찰하세요
  4. (선택) GCM에서 nonce를 재사용해 두 메시지를 암호화하고, 태그 검증은 여전히 동작하는지(기밀성만 깨지고 무결성 검사는 통과하는지) 실험해 보세요

연습문제

문제 1. AES의 네 라운드 연산 중 "키가 관여하는 연산"과 "비선형성을 만드는 연산"을 각각 짚고, 둘이 없으면 어떤 일이 생기는지 답해 보세요.

문제 2. ECB 암호화된 비트맵에서 사각형이 보이는 이유를, 블록 통계(고유 블록 수)와 연결해 설명해 보세요.

문제 3. CTR 모드에서 복호화가 암호화와 정확히 같은 연산인 이유를 수식으로 보여 주세요. 이 성질이 구현 실수(키스트림 재사용)를 어떻게 더 위험하게 만드는지도 답해 보세요.

문제 4. 패딩 오라클(Step 232)이 GCM 모드에서는 왜 성립하지 않는지, "변조된 암호문의 처리 순서"를 근거로 설명해 보세요.


5. 모범 답안과 완료 기준

미션 모범 답안

def block_uniqueness(ct, bs=16):
    blocks = [ct[i:i+bs] for i in range(0, len(ct) - len(ct) % bs, bs)]
    return len(blocks), len(set(blocks))

판정 기준: ① 세 모드 비교에서 ECB만 고유 블록 수가 1에 수렴(반복 평문 기준) ② ecb.bmp에서 사각형 윤곽을 눈으로 확인 ③ c1 ⊕ c2 관찰에서 "공백(0x20)과의 XOR이 알파벳의 대소문자를 뒤집는다"를 확인할 것 — 예: 'A' ⊕ ' ' = 'a'. 이 관찰이 두 평문을 모를 때의 해독 단서(crib dragging)입니다.

검증하는 법: 미션 4에서 nonce 재사용 GCM은 암호문 XOR로 평문이 새지만 태그 검증은 정상 통과합니다 — 기밀성과 무결성이 별개 축임을 확인하는 것이 이 실험의 정답입니다.

연습문제 해답

문제 1 해답. 키가 관여하는 것은 AddRoundKey뿐입니다 — 없으면 암호가 키와 무관한 공개 함수가 되어 누구나 역산할 수 있습니다. 비선형성은 SubBytes(S-box)가 만듭니다 — 없으면 ShiftRows/MixColumns/AddRoundKey는 전부 선형(XOR과 행렬 곱)이라, 라운드를 아무리 반복해도 전체가 하나의 선형 변환으로 접혀 연립방정식으로 풀립니다. 강한 암호는 "비선형 치환 + 선형 확산 + 키 주입"의 반복이라는 것이 SPN의 설계 원리입니다.

문제 2 해답. 비트맵의 단색 영역은 같은 픽셀 패턴이 수천 블록 반복됩니다. ECB는 같은 입력 블록에 같은 출력을 대응시키는 고정 표이므로, 단색 영역은 암호화 후에도 "하나의 고유 블록"으로 유지됩니다 — 실측에서 원본 4종 블록이 ECB 후에도 4종이었습니다. 픽셀값은 바뀌어도 "여기까지 같은 색"이라는 경계 정보는 1비트도 지워지지 않았고, 그 경계가 사각형의 윤곽으로 렌더링됩니다.

문제 3 해답. 암호화 C = P ⊕ KS(KS는 키스트림)에서 C ⊕ KS = P ⊕ KS ⊕ KS = P — XOR은 자기 역함수라 같은 키스트림을 다시 XOR하면 복호화됩니다. 구현상 Enc와 Dec가 같은 코드 경로를 타므로, nonce 관리 실수(같은 nonce 재호출)를 컴파일러나 라이브러리가 잡아주지 않습니다. 게다가 재사용이 일어나도 출력은 멀쩡한 암호문처럼 보여, 실수가 조용히 치명적으로 됩니다.

문제 4 해답. 패딩 오라클의 연료는 "변조된 암호문을 복호화까지 수행한 뒤 패딩 검사 결과를 알려주는" 서버입니다. GCM은 복호화 전에 인증 태그를 검증하고, 1비트라도 다르면 평문을 만들지도 않은 채 MAC check failed로 폐기합니다(3-5 실측). 오라클이 되려면 복호화 경로에 도달해야 하는데, 변조된 입력은 그 입구에서 차단되므로 질의가 성립하지 않습니다.

완료 기준 체크리스트

  • [ ] AES의 블록 16바이트, 키 128/192/256비트 → 라운드 10/12/14 대응을 말할 수 있다
  • [ ] SPN 네 연산의 역할(치환·확산·키 주입)을 설명할 수 있다
  • [ ] ECB/CBC의 고유 블록 수 차이를 실측으로 확인했다
  • [ ] ecb.bmp의 사각형 윤곽을 눈으로 확인했다
  • [ ] CTR nonce 재사용으로 두 번째 평문을 복구했다
  • [ ] GCM의 MAC check failed를 실측했다
  • [ ] 모드별 취약점 표를 닫고 재현할 수 있다
  • [ ] 미션: 비교 실험 노트 완성(세 모드 통계 + BMP 관찰 + XOR 관찰)

6. 흔한 실수와 해결

벽 1. BMP를 암호화했더니 그림 뷰어가 못 연다

증상: ecb.bmp를 열면 "손상된 파일" 오류.
원인: BMP 헤더(앞 54바이트)까지 암호화했습니다 — 뷰어는 헤더의 매직 BM과 크기 필드로 파일을 해석합니다.
해결: 3-2처럼 헤더는 평문으로 유지하고 본문(픽셀)만 암호화하세요. 이 실험의 관점에서 헤더는 "파일 형식의 틀"이고 암호 대상이 아닙니다.

벽 2. CTR에서 TypeError 또는 재사용이 안 된다

증상: 두 번째 암호화가 첫 번째와 다른 결과.
원인: pycryptodome의 Counter 객체는 암호화하며 내부 상태가 진행합니다 — 같은 Counter 객체를 두 번 쓰면 이어지는 카운터로 암호화됩니다.
해결: 3-4처럼 nonce 재사용을 재현하려면 Counter.new(128, initial_value=100)를 매번 새로 만드세요. 반대로 실무에서는 이 "재사용"이 바로 취약점이라는 점을 구분해서 이해하세요.

벽 3. ECB/CBC에 패딩 없이 넣어 ValueError

증상: ValueError: Data must be aligned to block boundary in ECB mode (Step 232에서 실측).
원인: ECB/CBC는 16바이트 배수 입력만 받습니다. CTR/GCM은 스트림 계열이라 제한이 없어 이 에러가 안 납니다 — 모드별 패딩 필요 여부가 다른 것이 정상입니다.
해결: 블록 모드에는 항상 pkcs7_pad(Step 232)를 먼저 적용하세요.

벽 4. GCM에서 decrypt만 써서 검증을 건너뛴다

증상: 변조된 암호문인데 복호화 결과가 나옵니다.
원인: decrypt()만 호출하면 태그 검증이 없이 복호화됩니다 — GCM의 보안은 decrypt_and_verify 한 벌에 있습니다.
해결: 항상 decrypt_and_verify(ct, tag)를 쓰세요. 검증 없는 복호화는 CBC 시절의 오라클 문제를 그대로 되살립니다.

벽 5. "AES-256이니까 안전"이라는 결론

증상: 키 길이만 보고 설계의 안전을 판단합니다.
원인: 오늘 실측 전체가 반증입니다 — ECB(3-2)와 CTR 재사용(3-4)은 키가 256비트여도 똑같이 깨집니다. 공격은 키를 찾는 게 아니라 모드의 규칙 위반을 이용합니다.
해결: 체크리스트는 키 길이가 아니라 모드·nonce·인증입니다 — "ECB가 아닌가? nonce가 유일한가? 변조를 검증하는가(AEAD)?"


7. 정리

오늘의 개념

개념 한 줄 설명
SPN 치환(S-box) + 교란(행/열) + 키 XOR의 반복 — 10~14라운드
눈사태 효과 입력 1비트 변화가 출력 전체로 확산 — 강한 암호의 조건
ECB 블록 독립 → 패턴 보존 — 사용 금지
CBC 앞 블록 연쇄 — 무결성 없어 오라클/플리핑에 취약
CTR 카운터 키스트림 XOR — 스트림 암호화, nonce 재사용 금지
GCM (AEAD) CTR + 인증 태그 — 기밀성과 무결성 동시, 현대 표준
nonce 키와의 조합이 단 한 번만 쓰여야 하는 시작값

오늘의 명령어·코드

명령 하는 일
AES.new(key, AES.MODE_ECB) ECB(학습·비교용, 실전 금지)
AES.new(key, AES.MODE_CBC, iv=iv) CBC(레거시)
AES.new(key, AES.MODE_CTR, counter=ctr) CTR 스트림 모드
AES.new(key, AES.MODE_GCM) + encrypt_and_digest AEAD 암호화 + 태그
decrypt_and_verify(ct, tag) 검증 포함 복호화 — GCM의 정석
Counter.new(128, initial_value=v) CTR 카운터 생성

명령어보다 중요한 감각

Crypto 트랙의 중반을 돌아보면 패턴이 보입니다 — 깨진 것은 언제나 수학의 전제를 어긴 구현이었습니다. RSA는 "n이 안 쪼개진다"를 어긴 키 생성에서, AES는 "블록이 독립이면 안 된다"(ECB), "변조를 검증해야 한다"(CBC), "nonce는 유일해야 한다"(CTR)를 어긴 사용법에서 무너졌습니다. 강한 알고리즘은 조건이고, 조건을 지키는 쪽이 방어자, 어긴 쪽이 문제 출제자입니다. 모드별 취약점 표를 외우는 것이 아니라, 각 칸의 위반된 규칙을 외우세요.


전부 체크되면 Step 233 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.