Switch는 작동한다, 다만 모든 분기가 진실을 말하지 않을 뿐 — DreamHack Control-Flow: mirage 풀이

2026-04-30·1분 읽기·

Switch는 작동한다, 다만 모든 분기가 진실을 말하지 않을 뿐 — DreamHack Control-Flow: mirage 풀이

44개 상태를 dispatch table로 돌리는 state machine, 그리고 PRNG 조건처럼 보이지만 실행 결과에 아무런 영향을 주지 않는 opaque predicate. 정적 분석만으로 jump table을 추출하고 변환 순서를 역추적해 플래그를 구하는 과정을 단계별로 정리한다.

문제: DreamHack — Control-Flow: mirage 분류: Reversing 난이도: ⭐ Level 1 FLAG: DH{0p4qu3_pr3d_1s_4_l13_4_y0u!!}


문제 개요

항목내용
문제명Control-Flow: mirage
난이도⭐ Level 1
분류Reverse Engineering
제공 파일ELF 64-bit 바이너리 (mirage)
핵심 기법Dispatch Table 기반 State Machine + Opaque Predicate
힌트"The switch still works, but not every branch tells the truth."

제목 그대로 "신기루"다.

흐름을 따라가면 따라갈수록 진짜 로직이 어디 있는지 헷갈리게 설계되어 있다. 근데 결국 state machine을 처음부터 끝까지 추적하고 나면 변환 로직은 단순하다.

풀이 흐름:

  1. 바이너리 기본 분석 → 보호 기법, 심볼, import 확인
  2. check 함수 구조 파악 → dispatch table 추출
  3. 전체 44개 상태 추적 → 변환 순서 확정
  4. opaque predicate 식별
  5. 역산 코드 작성 → 플래그 획득

🔬 바이너리 기본 분석

Step 1 — 파일 형식 및 보호 기법

먼저 파일 형식과 ELF 타입을 확인한다.

code
file mirage
readelf -h mirage | grep Type

file mirage, readelf -h mirage | grep Type 출력
file mirage, readelf -h mirage | grep Type 출력

항목상태
PIE❌ 없음 (EXEC 타입, 고정 주소 0x401110)
Stack Canary❌ 없음 (__stack_chk_fail import 없음)
Stripped❌ not stripped (check, main 심볼 노출)
의존 라이브러리libc.so.6

PIE도 없고 심볼도 살아있다. 정적 분석에 최적화된 환경이다.

Step 2 — 심볼 및 import 확인

다음으로 심볼과 import를 확인한다.

code
nm mirage
objdump -d --section=.plt mirage | grep '@plt'

nm mirage 출력 — check, main 심볼 확인
nm mirage 출력 — check, main 심볼 확인

심볼이 두 개뿐이다. maincheck.

import는 fgets, nanosleep, puts, strlen, strcspn, malloc, printf. nanosleep이 눈에 띈다 — 동적 분석 방해용이다.

Step 3 — 실행 및 주요 문자열 확인

어떤 입력을 받고 무슨 메시지를 출력하는지 먼저 확인한다.

code
strings -a mirage | grep -E "Flag|Correct|Wrong"
./mirage   # 아무 문자열 입력해보기

strings 출력 및 바이너리 실행 화면
strings 출력 및 바이너리 실행 화면

Flag: 프롬프트가 뜨고 입력을 받아서 Correct! 또는 Wrong.을 출력한다. 전형적인 crackme 구조.

main의 핵심 흐름만 요약하면:

code
// main (0x401695) 의사코드
int main() {
    // 전역 변수 초기화
    // PRNG 시드 = 0x48f3b59043397068
    // malloc으로 연결 리스트 구조체 3개 생성
 
    printf("Flag: ");
    fgets(buf, 0x40, stdin);
    buf[strcspn(buf, "\n")] = '\0';  // 개행 제거
 
    int result = check(buf);
    puts(result ? "Correct!" : "Wrong.");
}

main 뒤에 또 다른 함수(0x4019f8)가 존재하는데, fini_array에 등록되어 프로세스 종료 시 자동 실행된다.

내부는 nanosleep + LCG PRNG 반복 루프 — 동적 분석 타이밍 교란용이다. 무시해도 된다.


🔬 check 함수 분석

Step 1 — 함수 진입부 확인

check 함수 시작 지점부터 본다. -M intel로 Intel 문법으로 출력한다.

code
objdump -d -M intel mirage | grep -A 20 '<check>:'

check 함수 진입부 — push rbp부터 state 초기화까지
check 함수 진입부 — push rbp부터 state 초기화까지

진입 직후 핵심 두 줄:

code
401202: mov [rbp-0x78], rdi      ; 입력 포인터 저장
401206: movq $0x14, [rbp-0x28]   ; state = 20 (초기값)

rbp-0x28에 상태값 0x14(=20)를 세팅하고 바로 dispatch loop로 진입한다.

Step 2 — Dispatch Loop 확인

dispatch loop 전체를 본다.

code
objdump -d -M intel mirage | grep -A 35 '<check>:'

check 함수 dispatch loop — cmp/ja/jmp *rax 패턴
check 함수 dispatch loop — cmp/ja/jmp *rax 패턴

code
40120e: cmp qword [rbp-0x28], 0x2b   ; state > 43?
401213: ja  40167b                    ; 범위 초과 → 루프백
401219: mov rax, [rbp-0x28]           ; state
40121d: lea rdx, [rax*4]
401225: lea rax, [rip+0xdd8]          ; = 0x402004 (jump table)
40122c: mov eax, [rdx+rax]            ; 테이블에서 오프셋 로드
40122f: cltq
401231: lea rdx, [rip+0xdcc]          ; = 0x402004
401238: add rax, rdx                   ; 절대 주소 계산
40123b: notrack jmp rax               ; 상태 분기

전형적인 switch-case → dispatch table 패턴이다.

[rbp-0x28]이 상태 변수, 0x402004가 jump table. 상태는 0x000x2b (043), 총 44개.

notrack이 붙어있어 CFI 분석 도구를 방해한다.

Step 3 — Jump Table 추출

0x402004에 있는 jump table을 Python으로 직접 파싱해 상태별 점프 주소를 뽑는다.

code
import struct
 
with open("mirage", "rb") as f:
    data = f.read()
 
# ELF PT_LOAD 세그먼트 파싱 → 가상주소-파일오프셋 매핑
e_phoff = struct.unpack_from("<Q", data, 32)[0]
e_phnum = struct.unpack_from("<H", data, 56)[0]
segments = []
for i in range(e_phnum):
    off = e_phoff + i * 56
    if struct.unpack_from("<I", data, off)[0] == 1:  # PT_LOAD
        p_vaddr  = struct.unpack_from("<Q", data, off+16)[0]
        p_filesz = struct.unpack_from("<Q", data, off+32)[0]
        p_offset = struct.unpack_from("<Q", data, off+8)[0]
        segments.append((p_vaddr, p_vaddr + p_filesz, p_offset))
 
def vaddr_to_file(va):
    for (s, e, o) in segments:
        if s <= va < e:
            return o + (va - s)
 
jt_foff = vaddr_to_file(0x402004)
for state in range(0x2c):
    rel = struct.unpack_from("<i", data, jt_foff + state * 4)[0]
    target = 0x402004 + rel
    print(f"State 0x{state:02x} ({state:2d}) -> 0x{target:06x}")
code
python3 parse_jumptable.py

jump table 파싱 스크립트 실행 결과 — 44개 상태별 주소
jump table 파싱 스크립트 실행 결과 — 44개 상태별 주소

상태주소상태주소
0x000x4014d50x0f0x40159b
0x020x40135a0x100x40140b
0x030x4013ad0x110x401349
0x050x4014a30x130x401626
0x060x4014f50x140x4012fd
0x070x4016150x180x40137a
0x090x40152c0x1b0x40162d
0x0a0x4015720x1d0x401650
0x0b0x4015c50x220x40123e
0x0c0x40131a0x250x40141f
0x01,0x04,0x0e...0x40167b (루프백)0x2a0x40166a
0x0d0x4015bb0x2b0x4013e0

0x40167bnop; jmp 0x40120e — 즉 루프백. 여기로 점프하는 상태는 전부 실질적으로 죽은 상태다.


🗺️ 전체 상태 추적

로컬 변수 맵을 먼저 정리하면 이후 분석이 훨씬 편하다.

각 오프셋은 objdump 출력에서 [rbp-0xNN] 형태로 반복 등장하므로 패턴을 잡아두면 된다.

code
objdump -d -M intel mirage | grep -A 600 '<check>:' | grep 'rbp'
오프셋변수역할
rbp-0x78input입력 문자열 포인터
rbp-0x28state현재 상태
rbp-0x08lenstrlen(input) 결과
rbp-0x70working_buf변환 버퍼 (28바이트)
rbp-0x50target_buf정답 바이트 (28바이트)
rbp-0x10iXOR 루프 카운터
rbp-0x0cj복사 루프 카운터
rbp-0x14kmul17 루프 카운터
rbp-0x18mXOR-neighbor 카운터
rbp-0x1cn비교 루프 카운터

각 상태 주소를 jump table에서 확인한 뒤 해당 주소를 objdump 출력에서 찾아가며 추적한다.

code
# 특정 주소 주변 코드 확인 예시 (state 0x14 = 0x4012fd)
objdump -d -M intel mirage | grep -A 10 '4012fd:'

state 0x14 (0x4012fd) — strlen 호출 후 state 0x1a로 전환
state 0x14 (0x4012fd) — strlen 호출 후 state 0x1a로 전환

상태를 처음부터 끝까지 따라가면 아래 흐름이 나온다:

code
[0x14] strlen(input) → len,  다음: 0x1a
[0x1a] len == 32?            → FAIL(0x08)  |  OK: 0x28
[0x28] input[0] == 'D'?      → FAIL(0x19)  |  OK: 0x18
[0x18] input[1] == 'H'?      → FAIL(0x0d)  |  OK: 0x1b
[0x1b] input[2] == '{'?      → FAIL(0x1c)  |  OK: 0x0a
[0x0a] input[31] == '}'?     → FAIL(0x09)  |  OK: 0x25
[0x25] target 28바이트 초기화, j=0,  다음: 0x06
[0x06/0x0c] ──┐  Copy 루프: working_buf[j] = input[j+3]; j++
              └── j > 27 → [0x07]
[0x07] i=0,  다음: 0x1d
[0x1d/0x22] ──┐  XOR 루프: buf[i] ^= (i*0x37 + 0x5a); i++
              └── i > 27 → [0x2a]
[0x2a] k=0,  다음: 0x0f
[0x0f/0x2b] ──┐  Mul17 루프: buf[k] = (buf[k] * 17) % 256; k++
              └── k > 27 → [0x05]
[0x05] m=1,  다음: 0x00
[0x00/0x03] ──┐  XOR-neighbor 루프: buf[m] ^= buf[m-1]; m++
              └── m > 27 → [0x10]
[0x10] n=0,  다음: 0x02
[0x02/0x0b/0x11] ──┐  비교 루프: buf[n] == target[n]?; n++
                   └── n > 27 → [0x13] return 1 ✅
                   실패 → [0x23] return 0 ❌

💣 Opaque Predicate — 핵심 트릭

state 0x22 (0x40123e)는 구간이 두 개다. 따로 잘라서 보는 게 낫다.

구간 ① — XOR 변환부 (40123e ~ 401261)

code
objdump -d -M intel mirage | grep -A 15 '40123e:'

state 0x22 XOR 변환부 — buf[i] ^= (i*0x37+0x5a), i++ 확인
state 0x22 XOR 변환부 — buf[i] ^= (i*0x37+0x5a), i++ 확인

code
40123e: mov eax, [rbp-0x10]       ; i
401243: movzx edx, [rbp+rax-0x70] ; buf[i]
40124b: mov ecx, 0x37
401250: imul eax, ecx              ; i * 0x37
401253: add  eax, 0x5a             ; + 0x5a
401256: xor  edx, eax              ; buf[i] ^= key
40125d: mov  [rbp+rax-0x70], dl   ; 저장
401261: add  [rbp-0x10], 1         ; i++

여기까지는 평범한 XOR 변환이다. 그런데 i++ 직후부터 전혀 관계없는 연산이 시작된다.

구간 ② — PRNG 조건 + 분기 (401265 ~ 4012f8)

code
objdump -d -M intel mirage | grep -A 10 '4012dc:'

state 0x22 분기부 — div esi 후 cmp/jne/jmp 모두 401681로
state 0x22 분기부 — div esi 후 cmp/jne/jmp 모두 401681로

code
; 401265 ~ 4012db: PRNG state를 읽어 % 7 계산 (컴파일러 최적화로 30줄)
4012dc: div  esi                   ; edx = PRNG % 7
4012e2: mov  eax, [0x4040d4]       ; = 5
4012e8: cmp  edx, eax              ; 나머지 == 5?
4012ea: jne  401681                ; ❌ 불일치 → state 변경 없이 루프백
4012f0: mov  [rbp-0x28], 0x1d     ; ✅ 일치   → state = 0x1d
4012f8: jmp  401681                ; (jne와 같은 주소로)

처음 보면 PRNG 조건에 따라 어떤 바이트는 XOR되고 어떤 바이트는 건너뛰는 것처럼 읽힌다.

실제로는 전혀 그렇지 않다. 두 분기 목적지를 직접 따라가면 증명된다.


jne / jmp 공통 목적지 0x401681 확인

code
objdump -d -M intel mirage | grep -A 5 '401681:'

0x401681 — nop + jmp 0x40120e, dispatch loop 입구로 복귀
0x401681 — nop + jmp 0x40120e, dispatch loop 입구로 복귀

jne 401681jmp 401681 모두 이 한 줄짜리 루프백으로 빠진다. state 레지스터를 바꾸지 않은 채로 dispatch loop(0x40120e)로 돌아간다.


je 분기가 세팅하는 state 0x1d (0x401650) 확인

code
objdump -d -M intel mirage | grep -A 15 '401650:'

state 0x1d (0x401650) — i > 27 경계 체크 후 state 0x2a 또는 0x22로 분기
state 0x1d (0x401650) — i > 27 경계 체크 후 state 0x2a 또는 0x22로 분기

state 0x1d는 cmp [rbp-0x10], 0x1b (i > 27?) 한 줄이 전부다.

  • i ≤ 27 → state = 0x22 → 다시 XOR 루프로
  • i > 27 → state = 0x2a → 루프 종료

두 분기 경로를 정리하면:

code
PRNG % 7 ≠ 5  →  jne 401681  →  state 변경 없음(0x22)  →  dispatch  →  XOR buf[i+1]
PRNG % 7 == 5  →  state=0x1d, jmp 401681  →  dispatch  →  state 0x1d(경계체크)  →  state=0x22  →  dispatch  →  XOR buf[i+1]

어느 분기를 타도 결과는 같다.

i = 0부터 27까지 모든 바이트에 XOR이 빠짐없이 적용된다.

PRNG 조건은 단지 state 0x1d(종료 확인 상태)를 몇 번 더 거치느냐를 바꿀 뿐이다. 이것이 Opaque Predicate다. 복잡한 연산으로 중요한 분기처럼 보이지만 어느 쪽으로 가든 결과가 동일하다.

문제 힌트 "not every branch tells the truth" 가 정확히 이걸 가리킨다.

🐛 삽질 — PRNG 결과에 따라 다른 변환이 적용되는 줄 알았다

state 0x22 안에 보조 jump table(0x4040c0)을 참조하는 코드가 있어서, PRNG % 7 값에 따라 서로 다른 변환이 적용되는 구조인 줄 알았다.

code
objdump -d -M intel mirage | grep -A 80 '<check>:' | grep -B2 -A5 '4040c0'

4040c0
4040c0

4012ce
4012ce

code
4012c7: lea rax, [0x4040c0]  ; 보조 테이블
4012ce: mov eax, [rdx+rax]   ; 테이블에서 값 로드

보조 테이블 값을 전부 뽑아보니 결국 모두 같은 흐름으로 합쳐졌다. PRNG 결과가 무엇이든 XOR이 적용된다는 사실은 불변.

nanosleep 타이밍 노이즈와 함께 동적 분석·정적 분석 모두를 헷갈리게 만드는 이중 장치였다.


🔬 변환 로직 정리

state 추적으로 확인한 변환 순서:

code
① Copy:          buf[j]  = input[j+3]                (j = 0..27)
② XOR key:       buf[i] ^= (i * 0x37 + 0x5a) & 0xff  (i = 0..27)
③ Mul17:         buf[k]  = (buf[k] * 17) % 256        (k = 0..27)
④ XOR-neighbor:  buf[m] ^= buf[m-1]                   (m = 1..27, in-place 좌→우)
⑤ Compare:       buf[n] == target[n]?                 (n = 0..27)

target 바이트 28개는 state 0x25(0x40141f)에서 하드코딩으로 초기화된다.

code
# target 초기화 구간 확인
objdump -d -M intel mirage | grep -A 35 '40141f:'

state 0x25 — target 바이트 28개 movb 초기화 구간
state 0x25 — target 바이트 28개 movb 초기화 구간

code
target = [
    0x0a, 0xfb, 0x47, 0x29, 0x5a, 0x64, 0xcf, 0x94,
    0xf4, 0xee, 0xca, 0xa2, 0x6d, 0xdb, 0xe8, 0xff,
    0x1a, 0x27, 0xbe, 0xa2, 0x2b, 0x52, 0xa9, 0xfb,
    0x29, 0xa5, 0x44, 0x22
]

🎯 역산

target → input 방향으로 변환을 역순으로 뒤집는다.

역산 ④ — XOR-neighbor

변환 ④는 in-place 누적 XOR (prefix XOR)이다:

code
final[0] = orig[0]
final[1] = orig[1] ^ orig[0]
final[2] = orig[2] ^ orig[1] ^ orig[0]
...

역연산: orig[m] = final[m] ^ final[m-1] (m = 1..27)

뒤에서부터 차례로 XOR하면 된다.

code
for m in range(27, 0, -1):
    buf[m] ^= buf[m-1]

역산 ③ — Mul17

(x * 17) % 256의 역원을 구한다. gcd(17, 256) = 1이므로 역원이 존재한다.

code
# 17 * 241 = 4097 = 16*256 + 1  →  17 * 241 ≡ 1 (mod 256)
buf = [(b * 241) % 256 for b in buf]

역산 ② — XOR key

XOR은 자기 역연산이다.

code
buf = [buf[i] ^ ((i * 0x37 + 0x5a) & 0xff) for i in range(28)]

역산 ① — Copy

input[j+3] = buf[j] → buf가 곧 flag 안쪽 28바이트.


🚀 Full Exploit

code
# solve.py
target = [
    0x0a, 0xfb, 0x47, 0x29, 0x5a, 0x64, 0xcf, 0x94,
    0xf4, 0xee, 0xca, 0xa2, 0x6d, 0xdb, 0xe8, 0xff,
    0x1a, 0x27, 0xbe, 0xa2, 0x2b, 0x52, 0xa9, 0xfb,
    0x29, 0xa5, 0x44, 0x22
]
 
# 역산 ④: prefix XOR inverse
buf = target[:]
for m in range(27, 0, -1):
    buf[m] ^= buf[m-1]
 
# 역산 ③: mul17 inverse  (17 * 241 ≡ 1 mod 256)
buf = [(b * 241) % 256 for b in buf]
 
# 역산 ②: XOR key
buf = [buf[i] ^ ((i * 0x37 + 0x5a) & 0xff) for i in range(28)]
 
inner = bytes(buf)
flag = b"DH{" + inner + b"}"
print(flag.decode())
code
python3 solve.py

python3 solve.py 실행 — 플래그 출력
python3 solve.py 실행 — 플래그 출력

바이너리에 직접 입력해서 검증한다:

code
# verify.py
import subprocess
 
flag = "DH{0p4qu3_pr3d_1s_4_l13_4_y0u!!}"
result = subprocess.run(["./mirage"], input=flag + "\n", capture_output=True, text=True)
print(f"Input : {flag}")
print(f"Output: {result.stdout.strip()}")
code
python3 verify.py

python3 verify.py 실행 — Correct! 확인
python3 verify.py 실행 — Correct! 확인


📝 결론

State Machine 난독화 — 44개 상태와 dispatch table로 실제 로직(copy → xor → mul17 → xor-neighbor → compare)을 흩뿌려 놓았다. jump table을 파싱하고 각 상태 주소를 objdump에서 직접 추적하면 구조가 드러난다.

Opaque Predicate — PRNG % 7 == 5 조건이 XOR 루프를 제어하는 것처럼 보이지만, 어느 분기로 가든 모든 28바이트에 XOR이 적용된다. 문제 힌트 그대로 "분기는 작동하지만 진실을 말하지 않는다."

역산 — 변환 순서 확인 후 prefix XOR inverse → mul 역원(241) → XOR key를 역순으로 적용하면 플래그가 나온다. 동적 분석 없이 순수 정적 분석으로 해결.

ShareX

이 글이 도움이 됐나요?