
사실 이런 문제 풀이를 많이 해본 적이 없어서 문제 해석부터 시간이 좀 걸렸습니다.
문제 설명을 읽고 주목해야 하는 부분은 두 부분이었습니다.
"직접 ECB 암호화 모드를 CTR 방식으로 바꿨다." -> 버그가 있을 것이니 코드를 읽고 그 부분을 공략하라
"너는 내 암호화한 사진을 못 읽는다." -> 사진 파일을 복호화해서 확인하라
사진 파일은 CTR로 암호화된 상태이고, 우리는 코드의 버그를 공략하여 복호화를 진행해야 플래그를 얻을 수 있을 것으로 예상됩니다.
학습 목표 : CTR의 원리를 이해하고 취약점을 찾아 복호화하기
ECB의 원리는 직전 과제에서 다뤘으니 넘어갑니다.
Nonce와 Counter로 구성된 입력을 AES로 암호화하여 키 스트림을 생성하는 방식입니다.
Nonce || Counter 1 ── AES(K) ── S1
│
P1 ───────────────────────────── XOR ── C1
Nonce || Counter 2 ── AES(K) ── S2
│
P2 ───────────────────────────── XOR ── C2
# 여기서 ||는 두 값을 이어 붙이는 연산
Nonce는 "number used once"의 줄임말로, 같은 키로 암호화할 때 한 번만 쓰이는 값을 말합니다.
처음에는 엄청 큰 랜덤수인가? 생각했는데 정확하게는 무작위성보다 유일성 개념에 가깝다고 합니다.
Nonce는 그대로 두고 Counter 값을 증가시키면서 서로 다른 AES 입력을 생성합니다.
서버에서 제공하는 함수는 암호화 함수 encrypt() 하나 뿐입니다.
이 함수의 코드를 확인해보면
Nonce + Counter 를 입력으로 받아 AES-ECB 모드로 암호화를 한 뒤,
png 파일을 16바이트씩 가져와서 블록 단위 XOR 연산을 거치고,
그렇게 반복하여 PNG 파일을 암호화한 암호문을 반환합니다.
첫 번째로 ECB 암호화를 거치고 XOR 연산을 하기 전의 블록이 S1이 되는 것입니다.
취약점은 encrypt() 가 아닌 카운터 쪽의 코드에서 발견됩니다.
def __init__(self, step_up=False): # step_up이 False로 지정된 상황
self.value = os.urandom(16).hex() # nonce 생성
self.step = 1
self.stup = step_up
def increment(self):
if self.stup:
self.newIV = hex(int(self.value, 16) + self.step)
else: # step_up = False 이므로 항상 실행
self.newIV = hex(int(self.value, 16) - self.stup)
# self.step이 아닌 self.stup을 빼고 있음 = 0으로 뺄셈을 하는 상황
현재 카운터 값을 증가시키지도 감소시키지도 못하고 있기 때문에 카운터 값이 고정된 상태입니다.
따라서 ECB 암호화에서 계속 같은 입력을 받고 있기 때문에 S1이 계속 반복되는 키 스트림이 생성됩니다.
encrypt() 내부의 XOR 연산은 사실 Repeating key XOR이었던 것입니다.
이에 따라 복호화 과정은 3단계로 정리됩니다.
C1 ⊕ P1 = S1여기서 문제, PNG 파일 원본을 가지고 있지 않은데 P1을 어떻게 찾을 수 있을까요?
이번 문제는 코드 분석도 중요하지만, PNG 파일이 특별하게도 첫 16바이트의 값이 고정값이라는 사실을 아는 것이 더욱 중요한 것 같습니다.
포렌식 공부 경험이 있다면 이미 알고 있는 개념인데, 파일들이 각각 가지고 있는 고유한 포맷을 파일 시그니처라고 합니다.
파일 첫부분에 위치하는 헤더 시그니처와 마지막에 위치하는 푸터 시그니처가 있습니다.
PNG 파일의 헤더 시그니처는 8바이트 크기의 Hex 값인 89 50 4E 47 0D 0A 1A 0A 입니다.
그리고 PNG 파일은 이 헤더 시그니처 다음으로 IHDR 이라는 청크(Chunk)가 옵니다.
PNG 파일에는 반드시 포함되어야 하는 청크 3가지가 있는데, 그 중 IHDR은 가장 앞에 위치하면서 이미지의 크기, 필터링 방식, 압축 방식 등의 정보를 담고 있습니다.
PNG 파일의 청크는 기본적으로 다음과 같은 구조를 갖습니다.
{
Length (4 byte), # Chunk의 크기
Chunk Type (4 byte), # Chunk의 타입
Chunk Data (length byte), # Chunk의 내용
CRC (4byte) # 오류 검사를 위한 값
}
IHDR 청크의 크기(length)는 항상 13바이트로 고정값이므로, 4바이트 크기의 length 부분에는 13의 16진수 값인 0x0000000D 가 들어갑니다.
IHDR 청크이므로 4바이트 크기의 Chunk Type 값은 IHDR를 뜻하는 0x49484452 가 됩니다.
결론적으로, PNG 파일의 첫 16바이트는 항상 0x89504e470d0a1a0a0000000D49484452 라는 고정값을 갖는 것입니다.
import requests
# 서버 기본 주소
BASE = "https://aes.cryptohack.org/bean_counter"
# ECB + CTR 암호문 받기
response = requests.get(f"{BASE}/encrypt/", timeout=10)
response.raise_for_status()
data = bytes.fromhex(response.json()["encrypted"])
# 암호문에서 C1 추출
C1 = data[:16]
먼저 XOR 연산을 위해서 C1을 추출합니다.
# PNG의 헤더 시그니처 + IHDR 청크 = PNG 파일의 앞 16바이트(고정값)
png_hex = "89504e470d0a1a0a" + "0000000D" + "49484452"
png_bytes = bytes.fromhex(png_hex)
앞서 설명한 PNG 파일의 특수성대로 P1(png_bytes)을 만들어줍니다.
def xor_bytes(a, b):
return bytes(x ^ y for x, y in zip(a, b))
# 반복되는 key 찾기
key = xor_bytes(C1, png_bytes)
같은 크기의 바이트 XOR 연산만 가능한 xor_bytes 로 S1(key)를 찾아줍니다.
# repeating key XOR
def xor_with_repeating_key(data: bytes, key: bytes) -> bytes:
result = []
for i, byte in enumerate(data):
# key의 인덱스를 돌려가며(0~15) XOR 연산
result.append(byte ^ key[i % len(key)])
return bytes(result)
new_data = xor_with_repeating_key(data, key)
이전의 문제풀이에 사용했던 함수를 재탕했고, PNG 파일 원본(new_data)을 복원합니다.
# (진짜 헤맴) PNG 파일 원본 만들기
with open('flag.png', 'wb') as save:
save.write(new_data)
생각 외로 여기서 좀 오래 걸리게 되었는데... new_data 를 출력해서 그 값에서 Ctrl+F 써가면서 찾느라 그랬습니다.
문제에서 PNG 파일을 확인하라는 암시를 주었는데 뒤늦게 알게 되었습니다...
동일한 경로에 만들어진 flag.png 파일을 열어봤을 때 최종적으로 crypto{...} 형식의 FLAG가 적힌 이미지를 볼 수 있습니다.