문제: DreamHack — locked present 분류: reversing · crypto 난이도: 🥈 Silver 1 FLAG:
DH{W0w_U_kn0w_4b0ut_AES?}
문제 설명은 두 줄이 전부다. "선물 상자는 잠겨있어 비밀번호를 입력해야만 열 수 있습니다. 자물쇠의 프로그램을 분석하여 비밀번호를 알아내세요."
배포본도 파일 하나뿐이다. 14KB 짜리 prob.

문제 페이지 맨 위에 "해당 문제는 2022 Christmas CTF 에 출제된 문제입니다" 라고 적혀 있다. 나중에 나오는 마스터키가 왜 크리스마스 인사인지는 여기서 이미 예고돼 있는 셈이다.
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | locked present |
| 난이도 | 🥈 Silver 1 |
| 분류 | reversing · crypto |
| 제공 파일 | prob (14,528 B · ELF 64-bit PIE · stripped) |
| 스택 | C / glibc, gcc 9.4.0 (Ubuntu 20.04) |
| 핵심 취약점 | AES 라운드 함수에서 MixColumns 만 빠져 확산(diffusion)이 0 |
풀이 흐름은 이렇다. stripped 바이너리에서 함수 일곱 개를 복원하니 5×5 블록을 쓰는 AES 변형이 나온다. S-box 도 Rcon 도 진짜 AES 표를 그대로 쓰는데 라운드 함수에 MixColumns 가 없다. 그 자리가 비면 암호가 바이트 단위로 쪼개지고, 라운드 수가 10 이라 남은 열 회전마저 상쇄된다. 남는 건 자리마다 독립인 XOR 표 하나뿐이라 그냥 거꾸로 감으면 비밀번호가 나온다.
🧩 배경 — AES 라운드가 하는 네 가지
AES 의 한 라운드는 네 단계다. SubBytes 로 바이트를 S-box 에 통과시키고, ShiftRows 로 행을 밀고, MixColumns 로 열을 섞고, AddRoundKey 로 라운드키를 XOR 한다.
이 중 MixColumns 를 뺀 셋은 전부 자리 단위로 논다. SubBytes 는 바이트 하나를 바이트 하나로 바꾸고, ShiftRows 는 자리만 옮기고, AddRoundKey 는 같은 자리끼리 XOR 한다. 어느 것도 두 바이트를 섞지 않는다.

출처: Wikimedia Commons, Matt Crypto, public domain
바이트를 실제로 섞는 건 MixColumns 하나뿐이다. 열 하나를 GF(2^8) 위의 다항식 c(x) 와 곱해서, 그 열의 네 바이트가 서로에게 번지게 한다.

출처: Wikimedia Commons, Matt Crypto, public domain
ShiftRows 와 MixColumns 를 합쳐야 비로소 "두 라운드만 지나면 입력 한 비트가 출력 전체에 영향을 준다"는 성질이 선다. 둘 중 하나만 빠져도 그게 무너진다.
이 문제가 정확히 그 이야기다.
🔬 정찰 — 심볼 없는 14KB
먼저 파일 정보와 눈에 띄는 문자열부터 본다.
file extracted/prob; nm extracted/prob; strings -n 5 extracted/prob | grep -nE "password|wrong|correct|merry"
PIE, stripped, 심볼 0개. 쓸 만한 문자열은 넷뿐인데 마지막이 눈에 걸린다. a_merry_christmas_for_you — 세어 보면 정확히 25글자다. 나중에 이게 그대로 마스터키로 쓰인다.
main 은 진입점이 __libc_start_main 에 넘기는 0x1757 이다. 앞머리부터 본다.
objdump -d -M intel extracted/prob --start-address=0x1775 --stop-address=0x17af | sed -E -f annot_main.sed주석은 내가 annot_main.sed 로 얹은 것이고, 나머지 줄은 objdump 원본 그대로다. 치환 규칙 파일은 이렇게 생겼다.
s|(mov edi,0x113)|\1 <-- 275 = 25바이트 x 라운드키 11개|
s|(call [0-9a-f]+ <malloc@plt>)|\1 <-- 라운드키 버퍼|
s|(lea rdi,\[rip\+0x9ad\].*)|\1 <-- "%s"|
s|(call [0-9a-f]+ <__isoc99_scanf@plt>)|\1 <-- 입력 (길이 검사 없음)|
s|(call 1254 <exit@plt\+0x164>)|\1 <-- key_expand(rk, .data 0x4030)|
s|(call 16e2 <exit@plt\+0x5f2>)|\1 <-- encrypt(state, rk)|
s|(lea rsi,\[rip\+0x27f8\].*)|\1 <-- 마스터키 .data 0x4030|
s|(lea rax,\[rip\+0x2756\].*)|\1 <-- 정답 암호문 .data 0x4010|
s|(cmp cl,al)|\1 <-- 한 바이트씩 비교|
s|(je 18da <exit@plt\+0x7ea>)|\1 <-- 같으면 다음 바이트|
s|(call [0-9a-f]+ <exit@plt>)|\1 <-- 첫 불일치에서 즉시 종료|
malloc(0x113) = 275 바이트. 25 로 나누면 11 이다. 25바이트짜리 라운드키 11개 라고 읽으면 라운드 10개짜리 블록암호가 된다. 입력은 scanf("%s") 로 길이 검사 없이 받는다.
그다음 main 은 입력 25바이트를 스택의 다른 버퍼로 5×5 로 옮기고, 함수 두 개를 부른다. 하나는 .data 0x4030 을 인자로 받고(키 확장), 하나는 그 결과와 상태를 받는다(암호화). 암호화 함수 0x16e2 를 열어 보면 구조가 그대로 드러난다.
objdump -d -M intel extracted/prob --start-address=0x16e2 --stop-address=0x1757 | sed -E -f annot_enc.seds|(mov edi,0x0)|\1 <-- 라운드 0|
s|(call 14a7 <exit@plt\+0x3b7>)|\1 <-- add_round_key|
s|(call 1572 <exit@plt\+0x482>)|\1 <-- sub_bytes (S-box)|
s|(call 15fb <exit@plt\+0x50b>)|\1 <-- shift_cols (열 회전)|
s|(cmp BYTE PTR \[rbp-0x1\],0xa)|\1 <-- 라운드 1..10, MixColumns 없음|
라운드 하나에 call 이 세 번뿐이다. 0x1572(SubBytes), 0x15fb(자리 이동), 0x14a7(AddRoundKey). 네 번째가 없다. AES 라면 여기 MixColumns 가 있어야 한다.
0x15fb 이 무슨 자리 이동인지 확인한다.
objdump -d -M intel extracted/prob --start-address=0x1619 --stop-address=0x16ca | sed -E -f annot_cols.seds|(movzx eax,BYTE PTR \[rdx\+rax\*1\])|\1 <-- t = st\[i\]|
s|(mov BYTE PTR \[rdx\+rax\*1\],cl)$|\1 <-- st\[i\] = st\[i+5\]|
s|(movzx ecx,BYTE PTR \[rdx\+rax\*1\+0xa\])|\1 <-- st\[i+10\]|
s|(mov BYTE PTR \[rdx\+rax\*1\+0x5\],cl)|\1 <-- st\[i+5\] = st\[i+10\]|
s|(mov BYTE PTR \[rdx\+rax\*1\+0xa\],cl)|\1 <-- st\[i+10\] = st\[i+15\]|
s|(mov BYTE PTR \[rdx\+rax\*1\+0xf\],cl)|\1 <-- st\[i+15\] = st\[i+20\]|
s|(mov BYTE PTR \[rdx\+rax\*1\+0x14\],cl)|\1 <-- st\[i+20\] = t|
s|(cmp al,BYTE PTR \[rbp-0x3\])|\1 <-- 안쪽 루프를 i 번 (열 i 는 i 칸)|
건드리는 오프셋이 i, i+5, i+10, i+15, i+20 이다. 상태를 st[5*행 + 열] 로 잡으면 이 다섯은 같은 열이다. 즉 AES 의 ShiftRows 를 전치한 모양 — 행이 아니라 열을 민다. 바깥 루프가 i 를 0부터 4까지 돌리고 안쪽에서 i 번 반복하니, 열 i 는 i 칸 위로 간다.
마지막으로 표를 확인한다.
objdump -s -j .rodata --start-address=0x2020 --stop-address=0x2060 extracted/prob; objdump -s -j .data extracted/prob
0x2020:8d 01 02 04 08 10 20 40 80 1b 36— 표준 AES Rcon 이다. 선두의0x8d까지 그대로다.0x2040:63 7c 77 7b f2 6b 6f c5 …— 표준 AES S-box.0x4010: 정답 암호문 25바이트37a6b227…e20e.0x4030:a_merry_christmas_for_you— 25바이트 마스터키.
표를 직접 만들지 않고 진짜 AES 것을 그대로 가져다 쓴 셈이다. 그래서 겉보기엔 AES 인데, 정작 확산을 담당하는 단계만 빠져 있다.
💣 핵심 — MixColumns 가 없으면 남는 게 없다
MixColumns 를 빼면 라운드 함수는 세 조각이 되는데, 셋 다 자리 단위 연산이다.
| 단계 | 하는 일 | 바이트를 섞는가 |
|---|---|---|
| SubBytes | st[x] = SBOX[st[x]] | 아니오 — 자리 안에서 값만 바뀐다 |
| ShiftCols | 열 j 를 j 칸 위로 | 아니오 — 값은 그대로 자리만 옮긴다 |
| AddRoundKey | st[x] ^= rk[25r + x] | 아니오 — 같은 자리끼리 XOR |
세 개를 아무리 겹쳐 쌓아도 "평문 바이트 하나 → 암호문 바이트 하나" 라는 관계가 유지된다. 라운드를 열 번 돌리면 자리가 여기저기 옮겨 다닐 뿐, 서로 섞이지는 않는다.
그런데 이 문제는 한 술 더 뜬다. 열 회전마저 상쇄된다.
열 j 는 라운드마다 j 칸씩 위로 간다. 열의 길이가 5 이므로 5라운드가 지나면 5j 칸, 즉 정확히 j 바퀴를 돌아 제자리다. 라운드가 10 이니 두 바퀴다.

말로만 하면 미덥지 않으니 숫자로 확인한다. 1비트를 뒤집었을 때 암호문 바이트가 몇 개나 바뀌는지 세고, 자리 대응이 실제로 어떻게 되는지 뽑고, 마지막으로 키를 전혀 모른다고 가정했을 때 코드북을 만들 수 있는지까지 한 번에 본다.
#!/usr/bin/env python3
"""이 문제의 급소가 'MixColumns 없음'이라는 것을 수치로 확인한다.
확산(diffusion)이 없으면 암호문 바이트 하나가 평문 바이트 하나에만 의존한다.
그러면 키를 몰라도 선택평문 256개로 코드북을 통째로 만들 수 있다.
"""
from Crypto.Cipher import AES
import cipher
rk = cipher.key_expand()
BASE = bytes(range(25))
def flipped(pt, bit):
b = bytearray(pt)
b[bit // 8] ^= 1 << (bit % 8)
return bytes(b)
print("=== 1. 눈사태 효과 (1비트 뒤집고 바뀐 바이트 수) ===")
c0 = cipher.encrypt(BASE, rk)
worst = 0
for bit in range(25 * 8):
c1 = cipher.encrypt(flipped(BASE, bit), rk)
n = sum(x != y for x, y in zip(c0, c1))
worst = max(worst, n)
print(f"locked present : 200비트 전부 시험 -> 바뀐 바이트 최대 {worst} / 25")
aes = AES.new(b"a_merry_christma", AES.MODE_ECB) # 비교용 진짜 AES-128
p0 = bytes(range(16))
a0 = aes.encrypt(p0)
aworst, abest = 0, 16
for bit in range(16 * 8):
b = bytearray(p0)
b[bit // 8] ^= 1 << (bit % 8)
n = sum(x != y for x, y in zip(a0, aes.encrypt(bytes(b))))
aworst, abest = max(aworst, n), min(abest, n)
print(f"진짜 AES-128 : 128비트 전부 시험 -> 바뀐 바이트 {abest}~{aworst} / 16")
print()
print("=== 2. 평문 자리 -> 암호문 자리 순열 ===")
perm = {}
for i in range(25):
b = bytearray(BASE)
b[i] ^= 0xFF
diff = [j for j in range(25) if cipher.encrypt(bytes(b), rk)[j] != c0[j]]
assert len(diff) == 1, f"자리 {i} 가 {len(diff)}곳에 번졌다"
perm[i] = diff[0]
print(" 평문 idx -> 암호문 idx :", ", ".join(f"{i}->{perm[i]}" for i in range(25)))
print()
print("=== 3. 키 없이 코드북 복구 (선택평문 256개) ===")
# 자리마다 '평문 바이트 v -> 암호문 바이트' 표를 통째로 만든다.
table = [dict() for _ in range(25)]
for v in range(256):
ct = cipher.encrypt(bytes([v]) * 25, rk)
for i in range(25):
table[i][ct[perm[i]]] = v
flag = bytes(table[i][cipher.CIPHER[perm[i]]] for i in range(25))
print(f" 질의 256회로 복원한 평문: {flag!r}")
assert flag == cipher.decrypt(cipher.CIPHER, rk)
print(" -> .data 의 마스터키를 한 번도 읽지 않고 같은 값이 나온다")python3 diffusion.py
세 줄 다 예상대로다.
- 눈사태 효과: 200비트를 전부 뒤집어 봐도 바뀌는 암호문 바이트는 최대 1개. 비교용으로 같이 돌린 진짜 AES-128 은 128비트 어느 것을 뒤집어도 16바이트 중 15~16개가 바뀐다.
- 자리 순열:
0→0, 1→1, … 24→24. 예상대로 항등이다. - 코드북: 자리마다 "평문 바이트 → 암호문 바이트" 표가 독립이므로, 같은 바이트로 채운 평문 256개만 넣어 보면 표 25개가 통째로 완성된다. 앞 단계에서 자리 대응을 확인하는 데 쓴 25회까지 세면 281회다.
.data의 마스터키를 한 번도 읽지 않고 같은 평문이 나온다.
마지막 항목이 이 설계의 진짜 문제다. 키를 숨겨 봐야 소용이 없다. 암호화 오라클만 있으면 300회 남짓한 질의로 사전이 다 털린다.
🎯 풀이 — 되감기
구조를 알았으니 되감으면 된다. 먼저 바이너리의 루틴을 그대로 옮긴다. S-box·Rcon·암호문·키는 하드코딩하지 않고 ELF 에서 직접 읽게 했다 — 옮겨 적다 틀리는 걸 막고, 나중에 누가 다시 돌려도 같은 값이 나온다.
#!/usr/bin/env python3
"""locked present (DreamHack, Silver 1) - prob 바이너리의 암호 루틴을 그대로 재구현한다.
상수(S-box / Rcon / 암호문 / 마스터키)는 하드코딩하지 않고 ELF 에서 직접 읽는다.
바이너리는 PIE 라 objdump 가 보여 주는 0x2040 같은 주소가 곧 파일 오프셋이다
(.rodata: vaddr 0x2000 == file offset 0x2000, .data: vaddr 0x4000 == file offset 0x3000).
"""
import struct
from pathlib import Path
BIN = Path(__file__).resolve().parent / "extracted" / "prob"
def read_sections(path):
"""ELF 섹션 헤더를 직접 파싱해 vaddr -> file offset 변환표를 만든다."""
blob = path.read_bytes()
e_shoff, = struct.unpack_from("<Q", blob, 0x28)
e_shentsize, = struct.unpack_from("<H", blob, 0x3A)
e_shnum, = struct.unpack_from("<H", blob, 0x3C)
e_shstrndx, = struct.unpack_from("<H", blob, 0x3E)
def sh(i):
off = e_shoff + i * e_shentsize
name, _typ, _flags, addr, offset, size = struct.unpack_from("<IIQQQQ", blob, off)
return name, addr, offset, size
_, _, stroff, _ = sh(e_shstrndx)
out = {}
for i in range(e_shnum):
name, addr, offset, size = sh(i)
end = blob.index(b"\0", stroff + name)
out[blob[stroff + name:end].decode()] = (addr, offset, size)
return blob, out
BLOB, SECTIONS = read_sections(BIN)
def at(vaddr, n):
"""가상주소 vaddr 에서 n 바이트를 읽는다."""
for _name, (addr, offset, size) in SECTIONS.items():
if addr and addr <= vaddr < addr + size:
delta = vaddr - addr
return BLOB[offset + delta:offset + delta + n]
raise ValueError(f"0x{vaddr:x} 를 담은 섹션이 없다")
SBOX = at(0x2040, 256) # 0x1572 sub_bytes 가 참조
RCON = at(0x2020, 16) # 0x1336 key_expand 가 참조
CIPHER = at(0x4010, 25) # main 0x18b3 이 비교하는 정답
KEY = at(0x4030, 25) # main 0x1831 이 key_expand 로 넘기는 마스터키
INV_SBOX = bytes(SBOX.index(i) for i in range(256))
NR = 10 # encrypt 0x174d: 라운드 1..10
BS = 25 # 5x5 상태
def key_expand(key=KEY):
"""0x1254 를 그대로 옮긴 것. 라운드키 11개(25*11=275=malloc(0x113)) 를 만든다."""
rk = bytearray(25 * (NR + 1))
rk[0:25] = key[0:25]
temp = bytearray(rk[15:20]) # 0x12ac: rk[i+0xf] — 마지막 행(20..24)이 아니라 행 3이다
for n in range(1, NR + 1):
temp = temp[1:] + temp[:1] # 0x11e9 rot5: 좌회전 1
for k in range(5): # 0x12f0 루프
rk[25 * n + k] = rk[25 * (n - 1) + k] ^ SBOX[temp[k]] ^ RCON[n]
for i in range(1, 5): # 0x138e 루프
for j in range(5):
rk[25 * n + 5 * i + j] = (rk[25 * n + 5 * (i - 1) + j]
^ rk[25 * (n - 1) + 5 * i + j])
temp[j] = rk[25 * n + 5 * i + j]
return bytes(rk)
def add_round_key(st, rk, r):
"""0x14a7. state[5i+j] ^= rk[25r + 5i + j]"""
for x in range(BS):
st[x] ^= rk[25 * r + x]
def sub_bytes(st):
"""0x1572. 25바이트 전부 S-box 치환"""
for x in range(BS):
st[x] = SBOX[st[x]]
def inv_sub_bytes(st):
for x in range(BS):
st[x] = INV_SBOX[st[x]]
def shift_cols(st):
"""0x15fb. 열 i(=인덱스 i, i+5, i+10, i+15, i+20)를 i 번 '위로' 회전."""
for i in range(5):
col = [st[5 * k + i] for k in range(5)]
col = col[i:] + col[:i]
for k in range(5):
st[5 * k + i] = col[k]
def inv_shift_cols(st):
for i in range(5):
col = [st[5 * k + i] for k in range(5)]
col = col[-i:] + col[:-i] if i else col
for k in range(5):
st[5 * k + i] = col[k]
def encrypt(pt, rk):
"""0x16e2. ARK(0) 다음 r=1..10 으로 SubBytes -> ShiftCols -> ARK."""
st = bytearray(pt)
add_round_key(st, rk, 0)
for r in range(1, NR + 1):
sub_bytes(st)
shift_cols(st)
add_round_key(st, rk, r)
return bytes(st)
def decrypt(ct, rk):
"""encrypt 를 역순으로 되감는다. MixColumns 가 없어 각 단계가 전부 바이트 단위로 가역."""
st = bytearray(ct)
for r in range(NR, 0, -1):
add_round_key(st, rk, r)
inv_shift_cols(st)
inv_sub_bytes(st)
add_round_key(st, rk, 0)
return bytes(st)키 확장에서 진짜 AES 와 다른 지점이 둘 있는데, 둘 다 objdump 를 한 줄씩 따라가야 보인다.
- 초기
temp를rk[20:25](마지막 워드)가 아니라rk[15:20]에서 가져온다.0x12af의add eax,0xf가 그것이다. - Rcon 을 첫 바이트에만 XOR 하지 않고 5바이트 전부에 XOR 한다.
0x12f0루프가k와 무관하게RCON[n]을 섞는다.
복호는 라운드를 역순으로 되감는 것뿐이다.
#!/usr/bin/env python3
"""locked present — 정답 암호문을 복호해 비밀번호를 뽑는다.
MixColumns 가 없으므로 SubBytes(바이트 단위 전단사) / ShiftCols(자리 순열) /
AddRoundKey(XOR) 셋 다 개별적으로 되감을 수 있다. 키가 .data 에 박혀 있으니
라운드키를 그대로 만들어 10라운드를 역순으로 풀면 끝이다.
"""
import cipher
rk = cipher.key_expand()
pt = cipher.decrypt(cipher.CIPHER, rk)
print(f"[*] 라운드키 {len(rk)}바이트 = 25 x {cipher.NR + 1}")
print(f"[*] 암호문 : {cipher.CIPHER.hex()}")
print(f"[*] 복호결과: {pt!r}")
# 되감은 값을 다시 암호화해서 .data 의 정답과 완전히 같은지 확인한다.
back = cipher.encrypt(pt, rk)
assert back == cipher.CIPHER, "재암호화 불일치 — 구현이 틀렸다"
print(f"[+] 재암호화 검증: {back.hex()} == .data@0x4010 OK")
print()
print(f"password = {pt.decode()}")python3 solve.py
DH{W0w_U_kn0w_4b0ut_AES?} — 정확히 25바이트다. 블록 하나에 딱 맞으니 패딩 고민도 없었던 것이다.
🐛 삽질 — 조용히 틀리는 오독 네 가지
이 문제는 틀려도 예외가 안 난다. 어떻게 잘못 읽든 25바이트짜리 그럴듯한 바이트열이 나오고, 그게 답이 아닐 뿐이다. 실제로 헤맨 지점들을 그대로 재현해 봤다.
#!/usr/bin/env python3
"""이 문제에서 '조용히 틀리는' 오독 4종을 실제로 돌려 본다.
넷 다 예외가 안 난다. 25바이트짜리 그럴듯한 바이트열이 나오고, 그게 flag 가 아닐 뿐이다.
즉 눈으로는 못 잡고 '재암호화해서 .data 와 대조' 하는 검증만이 판별기다.
"""
import cipher
REAL = b"DH{W0w_U_kn0w_4b0ut_AES?}"
def report(name, pt):
printable = all(0x20 <= b < 0x7F for b in pt)
verdict = "정답" if pt == REAL else "오답"
print(f" [{verdict}] {name}")
print(f" -> {pt!r}")
print(f" 출력가능문자만인가: {printable} / 예외 발생: 없음")
def expand(temp_src=slice(15, 20), rcon_all=True):
rk = bytearray(25 * 11)
rk[0:25] = cipher.KEY
temp = bytearray(rk[temp_src])
for n in range(1, 11):
temp = temp[1:] + temp[:1]
for k in range(5):
v = rk[25 * (n - 1) + k] ^ cipher.SBOX[temp[k]]
if rcon_all or k == 0:
v ^= cipher.RCON[n]
rk[25 * n + k] = v
for i in range(1, 5):
for j in range(5):
rk[25 * n + 5 * i + j] = (rk[25 * n + 5 * (i - 1) + j]
^ rk[25 * (n - 1) + 5 * i + j])
temp[j] = rk[25 * n + 5 * i + j]
return bytes(rk)
def dec(rk, inv_shift):
st = bytearray(cipher.CIPHER)
for r in range(10, 0, -1):
cipher.add_round_key(st, rk, r)
inv_shift(st)
cipher.inv_sub_bytes(st)
cipher.add_round_key(st, rk, 0)
return bytes(st)
def inv_cols_down(st): # 정답: 암호화가 '위로' 돌렸으니 복호는 '아래로'
cipher.inv_shift_cols(st)
def inv_cols_up(st): # 함정 3: 방향을 반대로
cipher.shift_cols(st)
def inv_rows(st): # 함정 4: 열이 아니라 '행'을 돌린다고 읽음 (진짜 AES 의 ShiftRows)
for i in range(5):
row = [st[5 * i + k] for k in range(5)]
row = row[-i:] + row[:-i] if i else row
for k in range(5):
st[5 * i + k] = row[k]
print("정답 구현")
report("temp=rk[15:20] · Rcon 5바이트 전부 · 열을 아래로", dec(expand(), inv_cols_down))
print("\n함정 1 — 진짜 AES 처럼 '마지막 워드'(rk[20:25])로 시작한다고 읽음")
report("temp=rk[20:25]", dec(expand(temp_src=slice(20, 25)), inv_cols_down))
print("\n함정 2 — 진짜 AES 처럼 Rcon 을 첫 바이트에만 XOR")
report("Rcon on k==0 only", dec(expand(rcon_all=False), inv_cols_down))
print("\n함정 3 — ShiftCols 역방향 (10라운드가 상쇄되니 방향은 상관없다고 착각)")
report("열을 위로 되돌림", dec(expand(), inv_cols_up))
print("\n함정 4 — 열이 아니라 행을 돌린다고 읽음 (AES 의 ShiftRows 로 오독)")
report("행 회전", dec(expand(), inv_rows))
print("\n참고 — 블록이 25바이트라 진짜 AES 라이브러리는 애초에 못 쓴다")
try:
from Crypto.Cipher import AES
AES.new(cipher.KEY, AES.MODE_ECB)
except ValueError as e:
print(f" Crypto.Cipher.AES.new(25바이트 키) -> ValueError: {e}")python3 traps.py
▶🐛 삽질 1 — 진짜 AES 처럼 '마지막 워드'로 시작한다고 읽었다
AES-128 키 확장은 W[4] = W[0] ^ g(W[3]) 처럼 직전 워드를 물고 시작한다. 25바이트 키를 5워드로 보면 마지막 워드는 rk[20:25] 여야 자연스럽다.
그런데 이 바이너리는 rk[15:20] 을 가져온다. 라운드 루프 끝에서 temp 를 갱신할 때는 마지막 행(i=4)을 쓰면서, 최초 한 번만 행 3 을 쓴다. 출제자의 실수인지 의도인지는 알 수 없지만 그대로 옮겨야 맞는다.
0x12ac 의 세 줄이 근거다. add eax,0xf 의 0xf = 15.
12ac: mov eax,DWORD PTR [rbp-0x1c] ; i
12af: add eax,0xf ; i + 15 <- 20 이 아니다
12b2: mov edx,eax이걸 20 으로 읽으면 라운드키 열 개가 전부 어긋나는데, 복호는 멀쩡히 끝나고 b'\xb7\x06\x82L…' 이 나온다. 길이도 25 바이트다.
▶🐛 삽질 2 — Rcon 을 첫 바이트에만 XOR 했다
AES 의 Rcon 은 워드의 최상위 바이트 하나에만 들어간다. 습관대로 if k == 0 을 붙였더니 역시 조용히 틀렸다.
바이너리는 k 를 0부터 4까지 도는 루프 안에서 매번 RCON[n] 을 섞는다. 0x1333~0x1362 구간에 k 로 갈라지는 분기가 없다.
▶🐛 삽질 3 — 열 회전 방향을 반대로 잡았다
"어차피 10라운드면 상쇄되니 방향은 상관없지 않나" 라고 잠깐 생각했다. 틀렸다.
자리 순열의 최종 결과는 항등이 맞지만, 라운드 사이사이에 AddRoundKey 가 끼어든다. 라운드키 바이트가 어느 자리에 얹히는지는 그 시점의 배열에 달려 있으므로, 방향을 뒤집으면 중간 대응이 전부 달라진다.
결과는 역시 예외 없는 25바이트 쓰레기다.
▶🐛 삽질 4 — 열이 아니라 행을 돌린다고 읽었다
가장 그럴듯한 오독이다. AES 를 아는 사람일수록 0x15fb 을 보고 "아 ShiftRows 구나" 하고 넘어가기 쉽다.
구분점은 오프셋 간격 하나다. 행을 돌린다면 인접한 i, i+1, i+2 … 를 건드려야 하는데, 실제로는 i, i+5, i+10 … 이다. 5칸씩 건너뛰니 열이다.
한 가지 더. 25바이트 블록이라 진짜 AES 라이브러리는 애초에 못 쓴다. Crypto.Cipher.AES.new 에 25바이트 키를 주면 Incorrect AES key length (25 bytes) 로 거부당한다. "AES 니까 라이브러리로 풀면 되겠지" 라는 길은 처음부터 막혀 있는 셈이다.
✅ 검증 — 눈이 아니라 바이너리가 판정하게
삽질 넷이 전부 "그럴듯한 25바이트"였다는 게 요점이다. 눈으로 보고 맞다고 하면 안 된다. 판정은 도구에 넘기고 세 가지 경로로 따로 확인했다.
1) 재구성한 C 소스가 같은 암호문을 낸다
디컴파일 결과를 C 로 다시 써서, 같은 표를 읽고 같은 암호문이 나오는지 본다. 함수 이름은 하는 일을 보고 붙였고, 인덱스 계산은 objdump 그대로다.
/* locked present — 디컴파일 결과를 그대로 옮긴 재구성 소스.
*
* 원본 prob 은 stripped 라 이름이 없다. 아래 이름은 하는 일을 보고 붙인 것이고,
* 연산 순서·인덱스 계산은 objdump 출력 그대로다.
*
* gcc -O0 -no-pie -o recon recon.c
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define NR 10 /* encrypt 0x174d: 라운드 1..10 */
#define BS 25 /* 5 x 5 상태 */
/* .rodata 0x2040 — 표준 AES S-box */
static unsigned char SBOX[256];
/* .rodata 0x2020 — 표준 AES Rcon (8d 01 02 04 08 10 20 40 80 1b 36) */
static unsigned char RCON[16];
/* .data 0x4030 */
static unsigned char KEY[BS];
/* .data 0x4010 — 정답 암호문 */
static unsigned char CIPHER[BS];
/* 0x11e9 — 5바이트를 왼쪽으로 1칸 회전 */
static void rot5(unsigned char *p)
{
unsigned char t = p[0];
p[0] = p[1]; p[1] = p[2]; p[2] = p[3]; p[3] = p[4]; p[4] = t;
}
/* 0x1254 — 라운드키 11개(275바이트, main 의 malloc(0x113)) */
static void key_expand(unsigned char *rk, const unsigned char *key)
{
unsigned char temp[5];
int n, i, j, k;
for (i = 0; i <= 24; i++)
rk[i] = key[i];
/* 여기가 진짜 AES 와 다르다: 마지막 행(20..24)이 아니라 행 3(15..19)을 집는다 */
for (i = 0; i <= 4; i++)
temp[i] = rk[i + 15];
for (n = 1; n <= NR; n++) {
rot5(temp);
for (k = 0; k <= 4; k++)
/* Rcon 이 첫 바이트가 아니라 5바이트 전부에 XOR 된다 */
rk[25 * n + k] = rk[25 * (n - 1) + k] ^ SBOX[temp[k]] ^ RCON[n];
for (i = 1; i <= 4; i++) {
for (j = 0; j <= 4; j++) {
rk[25 * n + 5 * i + j] =
rk[25 * n + 5 * (i - 1) + j] ^ rk[25 * (n - 1) + 5 * i + j];
temp[j] = rk[25 * n + 5 * i + j];
}
}
}
}
/* 0x14a7 */
static void add_round_key(unsigned char r, unsigned char *st, const unsigned char *rk)
{
unsigned char i, j;
for (i = 0; i <= 4; i++)
for (j = 0; j <= 4; j++)
st[5 * i + j] ^= rk[25 * r + 5 * i + j];
}
/* 0x1572 */
static void sub_bytes(unsigned char *st)
{
unsigned char i, j;
for (i = 0; i <= 4; i++)
for (j = 0; j <= 4; j++)
st[5 * j + i] = SBOX[st[5 * j + i]];
}
/* 0x15fb — 열 i 를 i 번 위로 회전. AES 의 ShiftRows 를 전치한 모양이다 */
static void shift_cols(unsigned char *st)
{
unsigned char i, c, t;
for (i = 0; i <= 4; i++) {
for (c = 0; c < i; c++) {
t = st[i];
st[i] = st[i + 5];
st[i + 5] = st[i + 10];
st[i + 10] = st[i + 15];
st[i + 15] = st[i + 20];
st[i + 20] = t;
}
}
}
/* 0x16e2 — MixColumns 가 없다 */
static void encrypt(unsigned char *st, const unsigned char *rk)
{
unsigned char r;
add_round_key(0, st, rk);
for (r = 1; r <= NR; r++) {
sub_bytes(st);
shift_cols(st);
add_round_key(r, st, rk);
}
}
/* 원본 바이너리에서 표를 그대로 떠 온다 (하드코딩 대신) */
static void load_tables(const char *path)
{
FILE *f = fopen(path, "rb");
if (!f) { perror("fopen"); exit(2); }
fseek(f, 0x2040, SEEK_SET); fread(SBOX, 1, 256, f);
fseek(f, 0x2020, SEEK_SET); fread(RCON, 1, 16, f);
fseek(f, 0x3010, SEEK_SET); fread(CIPHER, 1, BS, f); /* .data vaddr 0x4010 -> off 0x3010 */
fseek(f, 0x3030, SEEK_SET); fread(KEY, 1, BS, f);
fclose(f);
}
int main(int argc, char **argv)
{
unsigned char rk[25 * (NR + 1)];
unsigned char st[BS];
int i;
load_tables(argc > 1 ? argv[1] : "extracted/prob");
key_expand(rk, KEY);
if (argc > 2) { /* 임의 평문을 암호화해 본다 */
memcpy(st, argv[2], BS);
} else { /* 기본: 우리가 찾은 비밀번호 */
memcpy(st, "DH{W0w_U_kn0w_4b0ut_AES?}", BS);
}
printf("plain : %.25s\n", st);
encrypt(st, rk);
printf("cipher : ");
for (i = 0; i < BS; i++) printf("%02x", st[i]);
printf("\n.data : ");
for (i = 0; i < BS; i++) printf("%02x", CIPHER[i]);
printf("\nmatch : %s\n", memcmp(st, CIPHER, BS) == 0 ? "YES" : "no");
return memcmp(st, CIPHER, BS) != 0;
}gcc -O0 -no-pie -o recon recon.c && ./recon extracted/prob
2) 실제 프로세스의 라운드별 상태를 뜬다
재구현이 맞는지는 결국 진짜 실행과 대조해야 한다. gdb 로 encrypt 안에 브레이크를 걸고 라운드마다 상태 25바이트를 통째로 읽는다. PIE 라 로드 베이스는 관례로 가정하지 않고 info proc mappings 에서 직접 읽었다.
# 실제 prob 프로세스의 라운드별 상태를 그대로 떠서 파이썬 재구현과 대조한다.
# gdb -batch -x trace_rounds.gdb
set confirm off
set pagination off
file ./extracted/prob
starti
python
# PIE 베이스를 관례로 가정하지 않는다 — 매핑에서 직접 읽는다
maps = gdb.execute("info proc mappings", to_string=True)
base = next(int(l.split()[0], 16) for l in maps.splitlines() if l.rstrip().endswith("/prob"))
print("PIE base = %s" % hex(base))
gdb.execute("break *%s" % hex(base + 0x170F)) # encrypt: AddRoundKey(0) 직후
gdb.execute("break *%s" % hex(base + 0x1743)) # encrypt: 각 라운드 AddRoundKey(r) 직후
gdb.execute("run < ./pw.txt", to_string=True)
inf = gdb.selected_inferior()
print("master key .data@0x4030 : %r" % bytes(inf.read_memory(base + 0x4030, 25)))
print("cipher .data@0x4010 : %s" % bytes(inf.read_memory(base + 0x4010, 25)).hex())
print("round key[0..24] : %s" %
bytes(inf.read_memory(int(gdb.parse_and_eval("*(unsigned long*)($rbp-0x20)")), 25)).hex())
for r in range(11):
ptr = int(gdb.parse_and_eval("*(unsigned long*)($rbp-0x18)"))
print("round %2d state : %s" % (r, bytes(inf.read_memory(ptr, 25)).hex()))
if r < 10:
gdb.execute("continue", to_string=True)
end
quitgdb -batch -x trace_rounds.gdb
라운드 10 의 상태가 .data 0x4010 의 암호문과 같다. 당연하지만 중간 아홉 개가 더 중요하다. 그걸 파이썬 재구현과 한 줄씩 대조한다.
#!/usr/bin/env python3
"""gdb 로 뜬 실제 프로세스의 라운드별 상태와 파이썬 재구현을 한 라운드씩 대조한다.
gdb_rounds.txt 는 trace_rounds.gdb 가 만든다:
gdb -batch -x trace_rounds.gdb | grep -E '^round +[0-9]+ state' > gdb_rounds.txt
"""
import re
from pathlib import Path
import cipher
live = {}
for line in Path("gdb_rounds.txt").read_text().splitlines():
m = re.match(r"round\s+(\d+) state : ([0-9a-f]{50})", line)
if m:
live[int(m.group(1))] = bytes.fromhex(m.group(2))
rk = cipher.key_expand()
st = bytearray(b"DH{W0w_U_kn0w_4b0ut_AES?}")
cipher.add_round_key(st, rk, 0)
ok = True
for r in range(11):
if r:
cipher.sub_bytes(st)
cipher.shift_cols(st)
cipher.add_round_key(st, rk, r)
same = bytes(st) == live[r]
ok &= same
print(f"round {r:2d} py={bytes(st).hex()} gdb={'일치' if same else '불일치'}")
print()
print("11개 라운드 상태 전부 일치" if ok else "불일치 발생 — 재구현이 틀렸다")
assert okgdb -batch -x trace_rounds.gdb | grep -E "^round +[0-9]+ state" > gdb_rounds.txt && python3 verify_rounds.py
11개 라운드가 전부 일치한다. 중간 상태까지 맞았으니 키 확장·S-box·열 회전 셋 다 제대로 옮긴 것이다.
3) 문제 바이너리에 직접 넣는다
마지막은 문제가 스스로 답하게 한다. 대조군으로 한 글자 바꾼 것과 한 글자 뺀 것도 같이 넣었다.
#!/usr/bin/env python3
"""찾아낸 비밀번호를 문제 바이너리에 실제로 넣어 판정을 받는다.
한 글자만 바꾼 대조군을 같이 돌려 'correct' 가 우연이 아님을 보인다.
"""
import subprocess
PW = "DH{W0w_U_kn0w_4b0ut_AES?}"
CASES = [
("정답", PW),
("마지막 S 를 s 로", PW.replace("AES", "AEs")),
("길이 24 (마지막 } 제거)", PW[:-1]),
]
for label, pw in CASES:
r = subprocess.run(["./extracted/prob"], input=pw + "\n",
capture_output=True, text=True)
print(f"[{label}]")
print(f" 입력 : {pw}")
print(f" 출력 : {r.stdout.strip()}")
print(f" 종료코드: {r.returncode}")python3 verify.py
🔓 키를 몰라도 열린다 — 조기종료 오라클
여기까지는 .data 의 마스터키를 읽어서 푼 것이다. 그런데 이 문제는 암호를 한 줄도 역산하지 않고도 뚫린다. main 의 마지막 비교 루프가 새는 곳이다.
objdump -d -M intel extracted/prob --start-address=0x1868 --stop-address=0x1910 | sed -E -f annot_main.sed
cmp cl,al 이 어긋나면 곧바로 puts("wrong") + exit(1) 이다. 남은 바이트는 비교조차 하지 않는다. 즉 몇 번째에서 틀렸는지가 프로그램 상태에 남는다. 화면에는 어느 쪽이든 wrong 만 찍히지만, 루프 변수 i(rbp-0xa0)와 j(rbp-0x9c)를 읽으면 첫 불일치 위치가 그대로 나온다.
여기에 앞에서 확인한 성질이 붙는다. 확산이 0 이고 자리 순열이 항등이니, 자리 p 의 비교 결과는 입력 바이트 p 하나에만 달려 있다. 그러면 앞에서부터 한 글자씩 확정해 나갈 수 있다.
gdb 를 붙여 자동화했다. run 을 세션 안에서 반복하는 방식이라 gdb 기동 비용이 한 번만 든다.
# gdb -batch -x oracle_attack.py 로 돌린다.
#
# main 의 마지막 비교 루프(0x1874~0x18d5)는 첫 불일치에서 곧바로 puts("wrong") + exit(1) 한다.
# 즉 "몇 번째 바이트까지 맞았는가"가 프로그램 내부 상태에 남는다.
# 암호를 한 줄도 역산하지 않고 이 조기종료만 이용해 비밀번호를 자리별로 복구한다.
import string, time
WRONG = 0x18C4 # lea rdi,"wrong" — 첫 불일치 지점
DONE = 0x18FE # lea rdi,"correct" — 25바이트 전부 일치
IDX_I = 0xA0 # main 바깥 루프 변수 i 는 rbp-0xa0
IDX_J = 0x9C # 안쪽 루프 변수 j 는 rbp-0x9c
INFILE = "/tmp/lp_oracle_in"
gdb.execute("set confirm off")
gdb.execute("set pagination off")
gdb.execute("file ./extracted/prob")
gdb.execute("starti", to_string=True)
# PIE 라 로드 베이스를 관례로 가정하지 않고 실제 매핑에서 읽는다.
maps = gdb.execute("info proc mappings", to_string=True)
base = next(int(l.split()[0], 16) for l in maps.splitlines() if l.rstrip().endswith("/prob"))
print(f"[*] PIE base = {hex(base)}")
gdb.execute(f"break *{hex(base + WRONG)}", to_string=True)
gdb.execute(f"break *{hex(base + DONE)}", to_string=True)
gdb.execute("kill", to_string=True)
def probe(guess: bytes) -> int:
"""guess 를 넣고 '몇 번째 바이트에서 처음 틀렸는지'를 돌려준다. 25 면 전부 정답."""
with open(INFILE, "wb") as f:
f.write(guess + b"\n")
gdb.execute(f"run < {INFILE}", to_string=True)
stopped_at = int(gdb.parse_and_eval("$pc"))
if stopped_at == base + DONE:
gdb.execute("kill", to_string=True)
return 25
i = int(gdb.parse_and_eval(f"*(int*)($rbp-{hex(IDX_I)})"))
j = int(gdb.parse_and_eval(f"*(int*)($rbp-{hex(IDX_J)})"))
gdb.execute("kill", to_string=True)
return 5 * i + j
# scanf("%s") 라 공백/NUL 은 애초에 못 넣는다. 탐색은 출력 가능 문자로 충분하다.
ALPHABET = (string.ascii_letters + string.digits + "{}_?!@#$%^&*()-+=[]<>.,:;/\\|~`'\"").encode()
FILLER = b"A"
t0 = time.time()
known = b""
runs = 0
for pos in range(25):
for c in ALPHABET:
cand = known + bytes([c]) + FILLER * (24 - pos)
runs += 1
if probe(cand) > pos:
known += bytes([c])
print(f"[{pos:2d}] {chr(c)!r} 확정 -> {known.decode()}")
break
else:
print(f"[!] 자리 {pos} 에서 후보 소진")
break
print()
print(f"password = {known.decode()}")
print(f"[*] 바이너리 실행 {runs}회 / {time.time() - t0:.1f}초 — 복호 루틴은 한 줄도 안 썼다")
print(f"[*] 최종 확인: probe(password) = {probe(known)} / 25")
gdb.execute("quit")gdb -batch -x oracle_attack.py | grep -E "^\[|^password"
바이너리 실행 1,046회 / 95초. 복호 루틴은 한 줄도 안 썼는데 같은 값이 나왔다.
25자리를 전수로 뒤지면 94^25 인데, 자리별로 잘리니 25 × 94 로 떨어진다. 확산이 있었다면 이 공격은 애초에 성립하지 않는다. 한 바이트만 바꿔도 암호문 전체가 뒤집혀서 "앞에서 몇 번째까지 맞았다"는 개념 자체가 없기 때문이다.
정리하면 이 바이너리는 같은 급소로 두 번 열린다.
| 경로 | 필요한 것 | 비용 |
|---|---|---|
| 라운드 역산 | .data 의 마스터키 | 즉시 |
| 선택평문 코드북 | 암호화 오라클 | 질의 256회 |
| 조기종료 오라클 | 검증 바이너리만 | 실행 1,046회 · 95초 |
세 번째가 제일 고약하다. 키도 알고리즘도 몰라도 되고, 실행 파일 하나만 있으면 된다.
🚀 재현
작업 폴더만 있으면 처음부터 끝까지 한 번에 돈다.
#!/usr/bin/env bash
# locked present (DreamHack, Silver 1) — 한 방 재현.
# cipher.py 재구현으로 .data 의 암호문을 복호 → 문제 바이너리로 판정까지.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
EXPECT=$(python3 -c "import json;print(json.load(open('문제.json'))['flag'])" 2>/dev/null || echo '')
echo "== 1) 재구현으로 복호 =="
OUT=$(timeout 120 python3 solve.py 2>&1) || true
echo "$OUT"
FLAG=$(printf '%s' "$OUT" | grep -aoE 'DH\{[^}]+\}' | head -1)
echo
echo "== 2) 재구성 C 소스로 같은 암호문이 나오는지 =="
gcc -O0 -no-pie -o recon recon.c
./recon extracted/prob | tail -2
echo
echo "== 3) 문제 바이너리에 실제 입력 =="
chmod +x extracted/prob
python3 verify.py | head -4
RC=$(python3 - "$FLAG" <<'PY'
import subprocess, sys
r = subprocess.run(["./extracted/prob"], input=sys.argv[1] + "\n",
capture_output=True, text=True)
print(r.returncode)
PY
)
echo
if [ -n "$FLAG" ] && [ "$RC" = "0" ] && { [ -z "$EXPECT" ] || [ "$FLAG" = "$EXPECT" ]; }; then
echo "✅ PASS $FLAG"
exit 0
fi
echo "❌ FAIL (얻은 값: '${FLAG:-없음}' / 기대: '${EXPECT:-미기록}' / 바이너리 rc: $RC)"
exit 1./reproduce.sh
로컬 환경은 이렇다.
| 항목 | 값 |
|---|---|
| OS | Ubuntu 기반 Linux 6.17 x86-64 |
| gcc | 9.4.0 으로 빌드된 배포본 / 재구성은 로컬 gcc -O0 -no-pie |
| gdb | GNU gdb 16.3 (파이썬 확장 사용) |
| python | 3.13 · pycryptodome (비교용 AES-128 에만 필요) |
| 대상 | extracted/prob sha256 065408f1439584c7bd4d37c3e353538241686a358ad442511c5bfbac954cfd25 |
📝 결론
MixColumns 는 장식이 아니다.
이 문제의 암호는 표를 하나도 대충 만들지 않았다. S-box 도 Rcon 도 진짜 AES 것을 그대로 쓴다. 라운드도 10번 돈다. 그런데 바이트를 섞는 단계 하나가 빠지자 남은 건 자리마다 독립인 XOR 표 25개였다. 라운드를 100번 돌려도 결과는 같다. 없는 확산은 반복으로 만들어지지 않는다.
블록 크기와 라운드 수를 같이 봐야 한다.
열 회전이 통째로 상쇄된 건 우연이다. 열 길이가 5, 라운드가 10 이라 10j ≡ 0 (mod 5) 가 됐다. 라운드를 11 로 잡았으면 자리 순열이 살아 있었을 것이다. 물론 그래도 바이트는 안 섞이니 근본은 그대로지만, 이런 식으로 파라미터가 서로 맞물려 성질을 죽이는 일은 자작 암호에서 흔하다.
"틀려도 티가 안 난다"가 이 문제의 난이도다.
키 확장의 시작 워드, Rcon 을 몇 바이트에 넣는지, 회전 방향, 행이냐 열이냐 — 네 군데를 잘못 읽어도 예외 하나 안 나고 25바이트가 나온다. 결국 판정은 재암호화 결과가 .data 와 같은지, gdb 로 뜬 실제 라운드 상태와 맞는지, 문제 바이너리가 correct 를 뱉는지로만 할 수 있다. 자작 암호를 되감을 때는 검증을 먼저 설계해 두는 게 낫다.
검증 로직에서도 정보가 샌다.
암호를 아무리 잘 만들어도 비교를 첫 불일치에서 끊으면 자리별 오라클이 생긴다. 여기서는 확산이 0 이라 그게 곧바로 전체 복구로 이어졌지만, 확산이 멀쩡한 암호라도 이런 조기종료는 HMAC 검증 같은 데서 실제 문제가 된다. 상수 시간 비교를 쓰는 이유가 이것이다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…