[🥈 Silver 1] MixColumns 하나가 빠지면 AES 는 XOR 표로 무너진다 — DreamHack locked present 풀이

2026-08-19·1분 읽기·

[🥈 Silver 1] MixColumns 하나가 빠지면 AES 는 XOR 표로 무너진다 — DreamHack locked present 풀이

25바이트 비밀번호를 검사하는 stripped ELF 안에 5x5 블록 AES 변형이 통째로 들어 있다. S-box 도 Rcon 도 진짜 AES 인데 MixColumns 만 없다. 그 하나가 빠지자 확산이 사라져 평문 바이트 하나가 암호문 바이트 하나로 그대로 대응했고, 라운드가 10 이라 열 회전마저 상쇄돼 자리 순열까지 항등이 됐다. 재구현으로 되감아 비밀번호를 복구하고, 키를 전혀 모르는 상태에서도 같은 값이 나오는 두 가지 경로를 따로 실증했다.

문제: DreamHack — locked present 분류: reversing · crypto 난이도: 🥈 Silver 1 FLAG: DH{W0w_U_kn0w_4b0ut_AES?}

문제 설명은 두 줄이 전부다. "선물 상자는 잠겨있어 비밀번호를 입력해야만 열 수 있습니다. 자물쇠의 프로그램을 분석하여 비밀번호를 알아내세요."

배포본도 파일 하나뿐이다. 14KB 짜리 prob.

드림핵 locked present 문제 페이지 — reversing·crypto 태그가 같이 붙어 있고 배포 파일은 prob 하나뿐이다
드림핵 locked present 문제 페이지 — reversing·crypto 태그가 같이 붙어 있고 배포 파일은 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 의 AES ShiftRows 도식 — 4x4 상태에서 행 i 를 왼쪽으로 i 칸 회전시킨다. 자리만 옮길 뿐 값은 그대로다
Wikimedia Commons 의 AES ShiftRows 도식 — 4x4 상태에서 행 i 를 왼쪽으로 i 칸 회전시킨다. 자리만 옮길 뿐 값은 그대로다

출처: Wikimedia Commons, Matt Crypto, public domain

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

Wikimedia Commons 의 AES MixColumns 도식 — 열 하나를 GF 2^8 다항식 c x 와 곱해 그 열의 네 바이트를 서로 섞는다
Wikimedia Commons 의 AES 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"
file·nm·strings 로 본 prob — PIE 이고 stripped 라 심볼이 없다. 문자열은 input password 프롬프트와 wrong·correct, 그리고 25글자짜리 a_merry_christmas_for_you 넷뿐이다
file·nm·strings 로 본 prob — PIE 이고 stripped 라 심볼이 없다. 문자열은 input password 프롬프트와 wrong·correct, 그리고 25글자짜리 a_merry_christmas_for_you 넷뿐이다

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       <-- 첫 불일치에서 즉시 종료|
objdump 로 본 main 앞머리 — malloc 0x113 으로 275바이트 버퍼를 잡고 printf 로 프롬프트를 찍은 뒤 scanf %s 로 길이 검사 없이 입력을 받는다
objdump 로 본 main 앞머리 — malloc 0x113 으로 275바이트 버퍼를 잡고 printf 로 프롬프트를 찍은 뒤 scanf %s 로 길이 검사 없이 입력을 받는다

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.sed
s|(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 없음|
objdump 로 본 encrypt 함수 — add_round_key 로 시작해 라운드 1부터 10까지 sub_bytes 와 shift_cols 와 add_round_key 세 개만 부른다. MixColumns 자리에 해당하는 네 번째 호출이 없다
objdump 로 본 encrypt 함수 — add_round_key 로 시작해 라운드 1부터 10까지 sub_bytes 와 shift_cols 와 add_round_key 세 개만 부른다. 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.sed
s|(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 칸)|
objdump 로 본 shift_cols 본체 — st i 를 저장해 두고 st i 5 10 15 20 을 한 칸씩 당긴 뒤 마지막에 되돌려 놓는다. 상태가 5x5 라 이 다섯 자리는 행이 아니라 열이다
objdump 로 본 shift_cols 본체 — st i 를 저장해 두고 st i 5 10 15 20 을 한 칸씩 당긴 뒤 마지막에 되돌려 놓는다. 상태가 5x5 라 이 다섯 자리는 행이 아니라 열이다

건드리는 오프셋이 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
objdump -s 로 본 rodata 와 data — 0x2020 의 8d 01 02 04 08 10 20 40 80 1b 36 은 표준 AES Rcon 이고 0x2040 은 표준 AES S-box 다. data 에는 정답 암호문 25바이트와 마스터키 a_merry_christmas_for_you 가 있다
objdump -s 로 본 rodata 와 data — 0x2020 의 8d 01 02 04 08 10 20 40 80 1b 36 은 표준 AES Rcon 이고 0x2040 은 표준 AES S-box 다. data 에는 정답 암호문 25바이트와 마스터키 a_merry_christmas_for_you 가 있다
  • 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 를 빼면 라운드 함수는 세 조각이 되는데, 셋 다 자리 단위 연산이다.

단계하는 일바이트를 섞는가
SubBytesst[x] = SBOX[st[x]]아니오 — 자리 안에서 값만 바뀐다
ShiftCols열 j 를 j 칸 위로아니오 — 값은 그대로 자리만 옮긴다
AddRoundKeyst[x] ^= rk[25r + x]아니오 — 같은 자리끼리 XOR

세 개를 아무리 겹쳐 쌓아도 "평문 바이트 하나 → 암호문 바이트 하나" 라는 관계가 유지된다. 라운드를 열 번 돌리면 자리가 여기저기 옮겨 다닐 뿐, 서로 섞이지는 않는다.

그런데 이 문제는 한 술 더 뜬다. 열 회전마저 상쇄된다.

열 j 는 라운드마다 j 칸씩 위로 간다. 열의 길이가 5 이므로 5라운드가 지나면 5j 칸, 즉 정확히 j 바퀴를 돌아 제자리다. 라운드가 10 이니 두 바퀴다.

직접 그린 도식 — 열 3 이 라운드마다 3칸씩 위로 밀리다가 5라운드 만에 원래 배열로 돌아온다. 라운드가 10 이라 모든 열이 두 바퀴를 돌아 자리 순열이 항등이 된다
직접 그린 도식 — 열 3 이 라운드마다 3칸씩 위로 밀리다가 5라운드 만에 원래 배열로 돌아온다. 라운드가 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
diffusion.py 실행 결과 — locked present 는 1비트를 뒤집어도 바뀌는 암호문 바이트가 최대 1개인데 진짜 AES-128 은 15~16개가 바뀐다. 자리 대응은 0에서 24까지 전부 항등이고 선택평문 256개로 평문 전체가 복구된다
diffusion.py 실행 결과 — locked present 는 1비트를 뒤집어도 바뀌는 암호문 바이트가 최대 1개인데 진짜 AES-128 은 15~16개가 바뀐다. 자리 대응은 0에서 24까지 전부 항등이고 선택평문 256개로 평문 전체가 복구된다

세 줄 다 예상대로다.

  • 눈사태 효과: 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
solve.py 실행 결과 — 라운드키 275바이트를 만들어 암호문을 되감으니 25바이트 비밀번호가 나오고 그걸 다시 암호화한 값이 data 0x4010 의 정답 암호문과 완전히 일치한다
solve.py 실행 결과 — 라운드키 275바이트를 만들어 암호문을 되감으니 25바이트 비밀번호가 나오고 그걸 다시 암호화한 값이 data 0x4010 의 정답 암호문과 완전히 일치한다

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
traps.py 실행 결과 — 정답 구현만 DH 로 시작하는 문자열이 나오고 나머지 네 가지 오독은 전부 예외 없이 출력 불가능한 25바이트를 낸다. 25바이트 키라서 진짜 AES 라이브러리는 ValueError 로 거부한다
traps.py 실행 결과 — 정답 구현만 DH 로 시작하는 문자열이 나오고 나머지 네 가지 오독은 전부 예외 없이 출력 불가능한 25바이트를 낸다. 25바이트 키라서 진짜 AES 라이브러리는 ValueError 로 거부한다
▶🐛 삽질 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
recon.c 를 gcc -O0 로 빌드해 실행한 결과 — 복구한 비밀번호를 넣으면 data 0x4010 에 박혀 있는 정답 암호문과 25바이트가 전부 일치해 match YES 가 뜬다
recon.c 를 gcc -O0 로 빌드해 실행한 결과 — 복구한 비밀번호를 넣으면 data 0x4010 에 박혀 있는 정답 암호문과 25바이트가 전부 일치해 match YES 가 뜬다

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
quit
gdb -batch -x trace_rounds.gdb
gdb -batch 로 뜬 실제 prob 프로세스의 라운드별 상태 — PIE 베이스를 매핑에서 읽어 브레이크포인트를 걸고 라운드 0부터 10까지 25바이트씩 덤프했다. 라운드 10 의 상태가 곧 data 의 정답 암호문이다
gdb -batch 로 뜬 실제 prob 프로세스의 라운드별 상태 — PIE 베이스를 매핑에서 읽어 브레이크포인트를 걸고 라운드 0부터 10까지 25바이트씩 덤프했다. 라운드 10 의 상태가 곧 data 의 정답 암호문이다

라운드 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 ok
gdb -batch -x trace_rounds.gdb | grep -E "^round +[0-9]+ state" > gdb_rounds.txt && python3 verify_rounds.py
verify_rounds.py 실행 결과 — gdb 로 뜬 실제 프로세스의 라운드 상태와 파이썬 재구현의 상태를 라운드 0부터 10까지 하나씩 대조해 11개가 전부 일치한다
verify_rounds.py 실행 결과 — gdb 로 뜬 실제 프로세스의 라운드 상태와 파이썬 재구현의 상태를 라운드 0부터 10까지 하나씩 대조해 11개가 전부 일치한다

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
verify.py 실행 결과 — 복구한 비밀번호를 넣으면 correct 와 종료코드 0 이고 마지막 S 를 소문자로 바꾸거나 닫는 괄호를 빼면 wrong 과 종료코드 1 이 나온다
verify.py 실행 결과 — 복구한 비밀번호를 넣으면 correct 와 종료코드 0 이고 마지막 S 를 소문자로 바꾸거나 닫는 괄호를 빼면 wrong 과 종료코드 1 이 나온다


🔓 키를 몰라도 열린다 — 조기종료 오라클

여기까지는 .data 의 마스터키를 읽어서 푼 것이다. 그런데 이 문제는 암호를 한 줄도 역산하지 않고도 뚫린다. main 의 마지막 비교 루프가 새는 곳이다.

objdump -d -M intel extracted/prob --start-address=0x1868 --stop-address=0x1910 | sed -E -f annot_main.sed
objdump 로 본 main 의 비교 루프 — 암호화한 상태와 data 0x4010 의 정답을 한 바이트씩 cmp 하다가 다르면 그 자리에서 puts wrong 과 exit 1 로 빠져나간다. 남은 바이트는 아예 비교하지 않는다
objdump 로 본 main 의 비교 루프 — 암호화한 상태와 data 0x4010 의 정답을 한 바이트씩 cmp 하다가 다르면 그 자리에서 puts wrong 과 exit 1 로 빠져나간다. 남은 바이트는 아예 비교하지 않는다

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"
gdb 로 돌린 조기종료 오라클 공격 — 자리마다 후보 문자를 넣어 첫 불일치 위치가 밀리는지 보고 한 글자씩 확정해 25자리를 전부 복구한다. 바이너리를 1046회 실행하는 데 95초가 걸렸고 복호 코드는 전혀 쓰지 않았다
gdb 로 돌린 조기종료 오라클 공격 — 자리마다 후보 문자를 넣어 첫 불일치 위치가 밀리는지 보고 한 글자씩 확정해 25자리를 전부 복구한다. 바이너리를 1046회 실행하는 데 95초가 걸렸고 복호 코드는 전혀 쓰지 않았다

바이너리 실행 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
reproduce.sh 실행 결과 — 재구현으로 복호하고 재구성 C 소스로 암호문을 재현한 뒤 문제 바이너리에 실제로 입력해 correct 를 받고 마지막에 PASS 와 플래그를 찍는다
reproduce.sh 실행 결과 — 재구현으로 복호하고 재구성 C 소스로 암호문을 재현한 뒤 문제 바이너리에 실제로 입력해 correct 를 받고 마지막에 PASS 와 플래그를 찍는다

로컬 환경은 이렇다.

항목값
OSUbuntu 기반 Linux 6.17 x86-64
gcc9.4.0 으로 빌드된 배포본 / 재구성은 로컬 gcc -O0 -no-pie
gdbGNU gdb 16.3 (파이썬 확장 사용)
python3.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

댓글

0개

댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)

댓글 불러오는 중…

Related

관련 글

3개
[🥇 Gold 4] 안티디버깅이 스스로 무너지는 자리 — DreamHack ptrace_block 풀이
blog

[🥇 Gold 4] 안티디버깅이 스스로 무너지는 자리 — DreamHack ptrace_block 풀이

생성자가 AES-128 키를 만들고 main 은 그 키로 입력을 암호화해 out.txt 에 쓴다. 키를 흔드는 값은 ptrace(PTRACE_TRACEME) 의 반환값을 4096 번 곱해 만드는데, 32비트 곱셈이 넘쳐 디버거가 있든 없든 0 이 된다. 남은 건 하위 1바이트짜리 XOR 뿐이라 256 번 시도로 끝난다.
#dreamhack#ctf#reversing+6
2026-08-07#dreamhack +5
[🥈 Silver 4] 참/거짓 한 비트만 흘려도 AES 블록이 통째로 뜯긴다 — DreamHack Padding Miracle Attack 풀이
blog

[🥈 Silver 4] 참/거짓 한 비트만 흘려도 AES 블록이 통째로 뜯긴다 — DreamHack Padding Miracle Attack 풀이

복호 메뉴가 알려주는 건 unpad 가 성공했는지 아닌지 딱 한 비트다. 그 한 비트로 AES 블록을 바이트 단위로 뜯는 게 패딩 오라클인데, 이 문제는 IV 를 끝까지 숨겨서 오라클 한 번으로는 secret 이 아니라 secret XOR IV 까지만 나온다. 평문 0 을 암호화해 받은 블록으로 오라클을 한 번 더 돌려 IV 를 상쇄한 과정과, 로컬에서는 멀쩡한데 원격에서만 터진 버그 하나를 정리했다.
#dreamhack#ctf#crypto+5
2026-10-28#dreamhack +4
[🥈 Silver 4] add·rotate·xor 블록암호를 정적 역산 — DreamHack My ARX Cipher 풀이
blog

[🥈 Silver 4] add·rotate·xor 블록암호를 정적 역산 — DreamHack My ARX Cipher 풀이

stripped ELF 하나가 12바이트 key로 flag.enc를 만들어 놓았다. 블록 함수를 objdump로 뜯어 4바이트(16bit 워드 v0·v1)에 rotate-add-xor-swap을 3라운드 도는 ARX 구조임을 밝히고, 각 연산을 역으로 되감아 flag를 복원한다. gdb 런타임과 재암호화 라운드트립으로 이중 검증.
#dreamhack#ctf#reversing+5
2026-07-15#dreamhack +4