[🥇 Gold 3] 게임을 켜지 않고 게임 화면을 읽는다 — DreamHack My_First_Game_v0.1 풀이

2026-08-17·1분 읽기·

[🥇 Gold 3] 게임을 켜지 않고 게임 화면을 읽는다 — DreamHack My_First_Game_v0.1 풀이

Direct3D 9 로 만든 창 하나짜리 게임. 실행해 보면 배경 텍스처만 보이는데, 사실 68개 메시가 전부 두께 없는 평면이라 카메라와 같은 평면에 놓여 옆에서 보이고 있었다. 장면 데이터가 통째로 .data 에 하드코딩돼 있어 D3DXCreateMeshFVF 루프가 읽는 네 개의 배열을 직접 파싱하면 게임을 켜지 않고 68글자를 그대로 복원할 수 있다.

문제: DreamHack — My_First_Game_v0.1 분류: reversing 난이도: 🥇 Gold 3 FLAG: DH{7d32405fb8dc2608f3dcc026398bc7ce6a944a08702a1484495cf16bfe5cedf2}

문제 설명은 딱 한 줄이다.

This is my first game. Stillll in development now. I love FPS

받은 건 WindowsProject1.exe 하나와 297×297 짜리 background.png 한 장. Visual Studio 기본 프로젝트 이름을 그대로 둔 걸 보면 정말 "첫 게임" 컨셉을 밀고 있다.

file extracted/* && sha256sum extracted/* && du -b extracted/*

file 과 sha256sum 으로 확인한 배포 파일 두 개 — WindowsProject1.exe 는 PE32+ x86-64 GUI 실행파일 221696 바이트, background.png 는 297x297 RGBA 이미지 5665 바이트다
file 과 sha256sum 으로 확인한 배포 파일 두 개 — WindowsProject1.exe 는 PE32+ x86-64 GUI 실행파일 221696 바이트, background.png 는 297x297 RGBA 이미지 5665 바이트다


문제 개요

항목내용
문제명My_First_Game_v0.1
난이도🥇 Gold 3
분류reversing
제공 파일WindowsProject1.exe (PE32+ x86-64 GUI, 221,696 B) · background.png (297×297)
스택Win32 + Direct3D 9 + D3DX9 + DirectInput 8
핵심 취약점 / 기법장면 지오메트리 전체가 실행파일에 하드코딩 — 렌더링 없이 화면 내용을 재구성

풀이 흐름은 이렇다. 임포트를 보면 D3D9 게임이라는 게 바로 나오고, 초기화 함수가 D3DXCreateMeshFVF 를 정확히 68번 부르면서 네 개의 하드코딩된 배열에서 정점과 인덱스를 잘라 쓴다. 그 배열을 직접 파싱해 보면 68개 메시가 전부 x = 0 평면 위의 다각형이고, 정점 수와 면 수의 관계가 폰트 글리프 아웃라인의 삼각분할 그 자체다. (z, y) 평면에 삼각형만 채우면 68글자가 나온다.



🧩 배경 — D3DX 메시는 그냥 정점 배열 + 인덱스 배열이다

Direct3D 9 시절의 D3DX 유틸리티 라이브러리에는 ID3DXMesh 라는 얇은 래퍼가 있다. "메시"라는 이름이 붙어 있지만 내부는 단순하다. 정점 버퍼 하나, 인덱스 버퍼 하나, 그리고 정점 하나가 몇 바이트인지를 알려 주는 FVF(Flexible Vertex Format) 비트마스크가 전부다.

HRESULT D3DXCreateMeshFVF(
    DWORD            NumFaces,      // 삼각형 개수
    DWORD            NumVertices,   // 정점 개수
    DWORD            Options,       // D3DXMESH_* 플래그
    DWORD            FVF,           // 정점 하나의 구성
    LPDIRECT3DDEVICE9 pD3DDevice,
    LPD3DXMESH      *ppMesh);

FVF 는 비트 OR 조합이다. 이 문제에 나오는 0x142 를 풀면 이렇게 된다.

비트상수크기
0x002D3DFVF_XYZ — 위치 float 3개12 B
0x040D3DFVF_DIFFUSE — 정점 색 DWORD4 B
0x100D3DFVF_TEX1 — 텍스처 좌표 float 2개8 B

합쳐서 정점 하나가 정확히 24바이트다. 이 숫자를 손에 쥐고 있으면 나중에 .data 어디부터 어디까지가 몇 번 메시인지 자로 잰 듯이 잘라낼 수 있다.

만든 메시를 채우는 방법도 정해져 있다. LockVertexBuffer 로 포인터를 받아 memcpy, UnlockVertexBuffer, 인덱스도 똑같이. 그래서 디스어셈블리에서 Lock ~ memcpy ~ Unlock 패턴만 찾으면 원본 데이터의 주소가 그대로 드러난다. 이 문제의 전부가 여기에 있다.


🔬 정찰 — 무엇으로 만든 게임인가

.text 가 0x1b1c(약 7 KB)밖에 안 되는데 .data 가 0x1b6b0(약 112 KB)이다. 코드보다 데이터가 16배 크다는 건 로직이 아니라 자료가 이 바이너리의 본체라는 뜻이다.

#!/usr/bin/env python3
"""WindowsProject1.exe 의 섹션 배치와 임포트를 훑는다. 무엇으로 만든 게임인지부터 본다."""
import sys
 
import pefile
 
pe = pefile.PE(sys.argv[1] if len(sys.argv) > 1 else "extracted/WindowsProject1.exe")
 
print(f"ImageBase {hex(pe.OPTIONAL_HEADER.ImageBase)}   "
      f"EntryPoint {hex(pe.OPTIONAL_HEADER.AddressOfEntryPoint)}   "
      f"Machine {hex(pe.FILE_HEADER.Machine)} (x86-64)")
 
print(f"\n{'섹션':10s} {'VA':>12s} {'가상크기':>10s} {'파일크기':>10s}")
for s in pe.sections:
    name = s.Name.decode(errors="replace").rstrip("\x00")
    print(f"{name:10s} {hex(pe.OPTIONAL_HEADER.ImageBase + s.VirtualAddress):>12s} "
          f"{hex(s.Misc_VirtualSize):>10s} {hex(s.SizeOfRawData):>10s}")
 
WANT = ("d3d9", "d3dx9", "dinput8", "USER32")
print("\n그래픽·입력 관련 임포트만:")
for e in pe.DIRECTORY_ENTRY_IMPORT:
    dll = e.dll.decode()
    if not any(w.lower() in dll.lower() for w in WANT):
        continue
    names = [i.name.decode() for i in e.imports if i.name]
    print(f"  {dll}")
    for n in names:
        print(f"      {n}")
python3 dump_pe.py

pefile 로 뽑은 섹션 배치와 임포트 — 코드인 .text 는 0x1b1c 인데 데이터인 .data 가 0x1b6b0 로 16배 크고 d3d9 · d3dx9_43 · DINPUT8 임포트가 그대로 보인다
pefile 로 뽑은 섹션 배치와 임포트 — 코드인 .text 는 0x1b1c 인데 데이터인 .data 가 0x1b6b0 로 16배 크고 d3d9 · d3dx9_43 · DINPUT8 임포트가 그대로 보인다

임포트 목록이 게임의 설계도를 그대로 보여 준다.

  • Direct3DCreate9 하나 — 셰이더도 이펙트도 없는 고정 파이프라인.
  • D3DXCreateMeshFVF, D3DXCreateTextureFromFileW — 메시 하나, 텍스처 하나.
  • 행렬 함수 다섯 개(Translation, Scaling, Multiply, LookAtLH, PerspectiveFovLH) — 월드·뷰·프로젝션을 손으로 조립한다.
  • DirectInput8Create — 키보드와 마우스.

RegisterClassExW → CreateWindowExW → PeekMessageW 루프까지, Win32 교과서 첫 장 그대로다. 문제 설명의 "I love FPS" 는 프레임 속도가 아니라 1인칭 시점을 말하는 것이었다.


💣 핵심 — 장면이 통째로 .data 에 박혀 있다

초기화 함수 0x140001900 을 따라가면 배경 텍스처를 하나 만들고, 배경용 정점 버퍼를 하나 채운 다음, 0x44 = 68번 도는 루프로 들어간다.

objdump -d -M intel --start-address=0x140001c49 --stop-address=0x140001ce4 \
  extracted/WindowsProject1.exe | sed -E -f annot.sed | tail -n +7 | grep -vE ":\t00 *$"

objdump 정적 분석 — 68회 도는 메시 생성 루프. r12d=0x44 가 반복 횟수, r9d=0x142 가 FVF, rsi+r13+0x3410 과 +0x3520 이 각각 정점 수와 면 수 배열이고 r13+0x68f0 이 정점 스트림이다
objdump 정적 분석 — 68회 도는 메시 생성 루프. r12d=0x44 가 반복 횟수, r9d=0x142 가 FVF, rsi+r13+0x3410 과 +0x3520 이 각각 정점 수와 면 수 배열이고 r13+0x68f0 이 정점 스트림이다

루프 한 바퀴가 하는 일은 이렇다.

  1. NumVertices[i] 를 .rdata 0x140003410 에서, NumFaces[i] 를 0x140003520 에서 읽는다. 두 배열 모두 DWORD 68개다.
  2. D3DXCreateMeshFVF(NumFaces, NumVertices, 0x220, 0x142, dev, &mesh[i]). 0x220 은 D3DXMESH_VB_SYSTEMMEM | D3DXMESH_IB_SYSTEMMEM — 버퍼를 시스템 메모리에 잡는다.
  3. LockVertexBuffer → memcpy(dst, 0x1400068f0 + 정점커서 × 24, NumVertices × 24) → Unlock.
  4. LockIndexBuffer → memcpy(dst, 0x14001ae10 + 면커서 × 6, NumFaces × 6) → Unlock.

shl r8, 0x3 과 lea r8,[rax+rax*2] 가 붙어 NumVertices × 3 × 8 = NumVertices × 24 를 만드는 게 보인다. 인덱스 쪽은 lea r8,[rax+rax*2] 뒤에 add r8,r8 이라 × 6, 즉 면 하나당 16비트 인덱스 3개다.

루프 꼬리가 나머지 절반을 말해 준다.

objdump -d -M intel --start-address=0x140001ce4 --stop-address=0x140001d43 \
  extracted/WindowsProject1.exe | sed -E -f annot.sed | tail -n +7 | grep -vE ":\t00 *$"

objdump 로 확인한 루프 꼬리 — ebx 에 NumVertices 를, r14d 에 NumFaces 를 누적해 다음 메시가 읽을 스트림 위치를 앞으로 밀고 r12 를 하나 줄여 0x140001c50 으로 되돌아간다
objdump 로 확인한 루프 꼬리 — ebx 에 NumVertices 를, r14d 에 NumFaces 를 누적해 다음 메시가 읽을 스트림 위치를 앞으로 밀고 r12 를 하나 줄여 0x140001c50 으로 되돌아간다

ebx += NumVertices[i], r14d += NumFaces[i]. 두 커서가 앞에서부터 스트림을 잘라먹는 구조다. 즉 정점 스트림과 인덱스 스트림은 각각 하나로 이어진 통짜 배열이고, 68개 메시는 그걸 순서대로 나눠 가진다. 커서를 그대로 흉내내면 어느 바이트가 몇 번 메시 것인지 정확히 복원된다.

한편 그리는 쪽(0x140001520)의 월드 행렬은 한 줄로 요약된다.

; 렌더 루프 (0x140001780 ~ 0x1400017fd)
movd    xmm3, ebx                 ; xmm3 = (float)i
cvtdq2ps xmm3, xmm3
xorps   xmm2, xmm2                ; y = 0
xorps   xmm1, xmm1                ; x = 0
call    D3DXMatrixTranslation     ; T = Translate(0, 0, i)
call    D3DXMatrixMultiply        ; world = T * S      (S = Scaling(10,10,10))
call    SetTransform              ; D3DTS_WORLD
call    mesh[i]->DrawSubset(0)

메시 i 는 z 로 i 만큼 밀린 뒤 10배 커진다. 68개가 z = 0, 10, 20, … 670 에 한 줄로 늘어선다는 뜻이다.



🧱 배경 — 정점 수와 면 수만 봐도 "이건 글자다"

배열 두 개를 뽑아 보면 재미있는 규칙이 보인다. 삼각분할된 단순 다각형은 정점이 V 개면 삼각형이 반드시 V - 2 개다. 그런데 구멍이 하나 있는 도형(도넛)은 삼각형이 V 개, 구멍이 둘이면 V + 2 개가 된다. 구멍 하나마다 삼각형이 둘씩 늘어난다.

구멍 개수 = (F - (V - 2)) / 2

68개 메시를 이 식에 넣으면 이렇게 갈린다.

VF구멍이 모양일 수 있는 것
750획이 두세 개뿐인 단순한 글자
34341속이 뚫린 글자 — D, 0, a, e …
1081102구멍이 둘 — 8, B

구멍이 0, 1, 2 로 딱 떨어지고 그 이상은 없다. 3차원 물체라면 나올 수 없는 분포다. 폰트 글리프의 아웃라인을 삼각분할해 넣었다고 보는 게 자연스럽다.

결정적인 건 좌표다. 3468개 정점의 x 가 하나도 빠짐없이 0.0 이고, y 는 -0.163 ~ 0.760, z 는 0.023 ~ 0.711 범위에 있다. 폰트 좌표계를 아는 사람이라면 y 의 아래쪽 -0.163 은 디센더, 위쪽 0.760 은 라운드 글자의 오버슛이라는 게 바로 읽힌다. y 가 세로(베이스라인이 0), z 가 글자 진행 방향이다.


🐛 삽질

▶🐛 삽질 1 — 68개를 '복도에 늘어선 오브젝트' 로 읽었다

렌더 루프에서 Translation(0, 0, i) 를 보자마자 "메시가 깊이 방향으로 68개 쌓여 있다"고 읽었다. 1인칭 게임이니 복도를 걸어가며 뭔가를 보는 구조겠거니 한 것이다. 그 가정을 들고 정점 좌표를 보니 이상했다. 복도라면 각 오브젝트가 자기 자리에서 x, y, z 를 다 쓸 텐데 x 가 전부 0 이었다.

정점 수와 면 수의 관계를 세어 보고 나서야 방향이 잡혔다. V - 2 / V / V + 2 라는 세 종류밖에 없다는 건 이 도형들이 평면이라는 뜻이고, 평면 도형 68개가 한 축을 따라 같은 간격으로 늘어서 있다면 그건 복도가 아니라 한 줄의 글자다.

교훈이라면, 3D 라고 해서 좌표 세 축이 다 살아 있다고 넘겨짚지 말 것. 축 하나가 상수면 그 순간부터 2D 문제다.

▶🐛 삽질 2 — 정점에서 좌표를 뽑는 오프셋을 틀려 전부 0 으로 읽혔다

24바이트 정점에서 (z, y) 만 필요하니 한 줄로 줄여 쓴다고 이렇게 썼다.

pts = [struct.unpack_from("<ff", vbuf, (vcur + j) * VSTRIDE + 8)[::-1]  # (z, y)
       for j in range(nv)]

+8 에서 float 둘을 읽고 뒤집으면 (z, y) 가 되겠지 싶었는데, +8 은 z 이고 그다음 +12 는 y 가 아니라 diffuse 색깔이다. 0xffffffff 를 float 로 읽으면 NaN 이 나오고, 그걸 세로 좌표로 쓰면 모든 글리프가 똑같이 뭉개진다.

증상이 아주 그럴듯했다는 게 문제였다. 크래시도 예외도 없이 68글자가 전부 0 으로 판독됐고 평균 IoU 만 0.000 이었다. 값 자체를 안 찍어 봤으면 매칭 알고리즘을 붙잡고 한참 헤맸을 것이다.

[*] DejaVuSans.ttf   평균 IoU 0.000  00000000000000000000000000000000000000000000000000000000000000000000

고친 건 결국 세 줄이다. 위치는 앞 12바이트의 x, y, z 이고, 그중 z 와 y 를 쓴다.

_x, y, z = struct.unpack_from("<fff", vbuf, (vcur + j) * VSTRIDE)
pts.append((z, y))

IoU 가 0.000 이면 "매칭이 안 된다"가 아니라 "입력이 비었다"를 먼저 의심해야 했다.

▶🐛 삽질 3 — wine 프리픽스가 덜 만들어진 채로 게임을 띄웠다

판독 결과를 게임 화면으로 확인해 보려고 wine 을 붙였는데 첫 실행이 이렇게 죽었다.

wine: could not load kernel32.dll, status c0000135

DLL 이 없다는 말이라 d3dx9_43 이 빠졌나 싶어 wine 의 x86_64-windows 디렉토리를 뒤졌다. d3d9.dll 도 d3dx9_43.dll 도 dinput8.dll 도 멀쩡히 있었다.

원인은 엉뚱한 데 있었다. wineboot -i 를 2분 타임아웃으로 돌렸는데 프리픽스 생성이 그 안에 안 끝나서, drive_c/windows 디렉토리만 만들어진 반쪽짜리 프리픽스가 남아 있었다. kernel32.dll 은 아직 복사되기 전이었다. 프리픽스를 지우고 wineboot -u 를 넉넉한 시간으로 다시 돌리니 그냥 떴다.

새 프리픽스 첫 초기화는 시간이 꽤 걸린다. 실패했으면 반쯤 만들어진 상태를 지우고 처음부터 다시 하는 게 빠르다.


🎯 글자 꺼내기

이제 할 일은 명확하다. 초기화 루프가 하는 커서 계산을 그대로 흉내내 68개 메시를 잘라낸다.

#!/usr/bin/env python3
"""My_First_Game_v0.1 (DreamHack Gold 3, reversing)
 
InitScene(0x140001900) 이 하드코딩된 배열로 68개 D3DX 메시를 만든다.
그 원본 배열을 PE 에서 그대로 꺼내 meshes.json 으로 떨군다.
 
  NumVertices[68] : .rdata 0x140003410
  NumFaces[68]    : .rdata 0x140003520
  정점 스트림      : .data  0x1400068f0  (FVF 0x142 = XYZ|DIFFUSE|TEX1 → 24B/정점)
  인덱스 스트림    : .data  0x14001ae10  (uint16 × 3 × 면)
 
메시 i 는 두 커서를 앞에서부터 잘라 쓴다(루프 0x140001c50 의 ebx/r14d 누적).
"""
import json
import struct
import sys
 
import pefile
 
EXE = sys.argv[1] if len(sys.argv) > 1 else "extracted/WindowsProject1.exe"
OUT = sys.argv[2] if len(sys.argv) > 2 else "meshes.json"
 
NUM_MESH = 0x44          # 0x140001c49: mov r12d, 0x44
VA_NUMVERT = 0x140003410  # 0x140001c5d: mov edx,[rsi+r13+0x3410]
VA_NUMFACE = 0x140003520  # 0x140001c6b: mov ecx,[rsi+r13+0x3520]
VA_VERTS = 0x1400068F0    # 0x140001c9d: lea rdx,[r13+0x68f0]
VA_INDEX = 0x14001AE10    # 0x140001cec: lea rdx,[r13+0x1ae10]
VSTRIDE = 24              # FVF 0x142 → pos(12) + diffuse(4) + uv(8)
 
pe = pefile.PE(EXE)
base = pe.OPTIONAL_HEADER.ImageBase
read = lambda va, n: pe.get_data(va - base, n)
 
num_vert = struct.unpack(f"<{NUM_MESH}I", read(VA_NUMVERT, NUM_MESH * 4))
num_face = struct.unpack(f"<{NUM_MESH}I", read(VA_NUMFACE, NUM_MESH * 4))
vbuf = read(VA_VERTS, sum(num_vert) * VSTRIDE)
ibuf = read(VA_INDEX, sum(num_face) * 6)
 
meshes = []
vcur = fcur = 0
for i in range(NUM_MESH):
    nv, nf = num_vert[i], num_face[i]
    verts = []
    for j in range(nv):
        x, y, z, diffuse, u, v = struct.unpack_from("<fffIff", vbuf, (vcur + j) * VSTRIDE)
        verts.append([x, y, z, diffuse, u, v])
    tris = list(struct.unpack_from(f"<{nf * 3}H", ibuf, fcur * 6))
    tris = [tris[k:k + 3] for k in range(0, len(tris), 3)]
    meshes.append({"index": i, "num_vert": nv, "num_face": nf, "verts": verts, "tris": tris})
    vcur += nv
    fcur += nf
 
with open(OUT, "w") as fp:
    json.dump(meshes, fp)
 
print(f"메시 {len(meshes)}개, 정점 {vcur}, 면 {fcur} → {OUT}")
print(f"정점 스트림 끝 = {hex(VA_VERTS + vcur * VSTRIDE)} (인덱스 시작 {hex(VA_INDEX)} 와 일치해야 정상)")
for m in meshes[:6]:
    ys = [v[1] for v in m["verts"]]
    zs = [v[2] for v in m["verts"]]
    genus = m["num_face"] - (m["num_vert"] - 2)
    print(f"  mesh {m['index']:2d}  V={m['num_vert']:3d} F={m['num_face']:3d} "
          f"구멍={genus // 2}  y[{min(ys):+.3f},{max(ys):+.3f}] z[{min(zs):+.3f},{max(zs):+.3f}]")
python3 extract_meshes.py

extract_meshes.py 실행 결과 — 메시 68개 정점 3468 면 3416 을 뽑았고 정점 스트림 끝 주소가 인덱스 스트림 시작 0x14001ae10 과 정확히 맞아떨어져 파싱이 옳다는 게 확인된다
extract_meshes.py 실행 결과 — 메시 68개 정점 3468 면 3416 을 뽑았고 정점 스트림 끝 주소가 인덱스 스트림 시작 0x14001ae10 과 정확히 맞아떨어져 파싱이 옳다는 게 확인된다

주소 계산이 맞는지 확인할 방법이 하나 있다. 정점 스트림의 끝(0x1400068f0 + 3468 × 24)이 인덱스 스트림의 시작(0x14001ae10)과 정확히 일치한다. 두 배열이 .data 에 빈틈없이 붙어 있다는 뜻이고, 커서 계산이 한 바이트도 어긋나지 않았다는 뜻이기도 하다.

이제 그리기만 하면 된다. 게임이 하는 배치(메시 i 를 z 로 i 만큼 이동)를 그대로 두고, (z, y) 평면에 삼각형을 채운다. 크기 10배는 모양을 안 바꾸니 뺐다.

#!/usr/bin/env python3
"""meshes.json 의 68개 메시를 게임이 배치하는 그대로 한 줄로 펼쳐 그린다.
 
Render(0x140001520) 의 월드행렬은  Translation(0, 0, i) * Scaling(10, 10, 10) 이라
메시 i 는 z 로 i 만큼 밀린 뒤 10배 된다. 여기서는 배율을 빼고 z += i 만 적용한다
(배율은 모양을 안 바꾼다). 정점의 x 는 전부 0 이므로 (z, y) 평면에 삼각형을 채우면
그게 곧 게임 안에서 보이는 글자다.
"""
import json
import sys
 
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Polygon
 
SRC = sys.argv[1] if len(sys.argv) > 1 else "meshes.json"
OUT = sys.argv[2] if len(sys.argv) > 2 else "flag_strip.png"
PER_ROW = 17  # 68글자를 4줄로 접어 글자가 뭉개지지 않게 한다
 
meshes = json.load(open(SRC))
rows = [meshes[i:i + PER_ROW] for i in range(0, len(meshes), PER_ROW)]
 
fig, axes = plt.subplots(len(rows), 1, figsize=(22, 3.0 * len(rows)))
for ax, row in zip(axes, rows):
    z0 = row[0]["index"]
    for m in row:
        # 게임과 동일한 배치: 메시 i 는 z 축으로 i 만큼 이동한다
        pts = [(v[2] + m["index"] - z0, v[1]) for v in m["verts"]]
        for a, b, c in m["tris"]:
            ax.add_patch(Polygon([pts[a], pts[b], pts[c]],
                                 closed=True, facecolor="black", edgecolor="none"))
        ax.text(m["index"] - z0 + 0.5, -0.45, str(m["index"]),
                ha="center", va="top", fontsize=7, color="#c04040")
    ax.set_xlim(-0.3, PER_ROW + 0.3)
    ax.set_ylim(-0.55, 0.95)
    ax.set_aspect("equal")
    ax.axis("off")
 
fig.tight_layout()
fig.savefig(OUT, dpi=130, facecolor="white")
print(f"{len(meshes)}개 글리프 → {OUT}")
python3 render_glyphs.py meshes.json 06_glyph_strip.png

68개 메시를 z 순서대로 (z, y) 평면에 채워 그린 결과 — 17글자씩 네 줄로 접었고 아래 붉은 숫자가 메시 번호다. DH 로 시작해 64자리 16진수가 이어지고 닫는 중괄호로 끝나는 문자열이 그대로 읽힌다
68개 메시를 z 순서대로 (z, y) 평면에 채워 그린 결과 — 17글자씩 네 줄로 접었고 아래 붉은 숫자가 메시 번호다. DH 로 시작해 64자리 16진수가 이어지고 닫는 중괄호로 끝나는 문자열이 그대로 읽힌다

68글자가 한 번에 나왔다. DH + { + 64자리 16진수 + } 구성이고, 16진수 64자리는 SHA-256 다이제스트 길이 그대로다.



🎮 그래서 게임을 켜면 뭐가 보이나

데이터를 다 읽고 나서야 이 문제의 장난이 이해됐다. 68글자가 전부 x = 0 평면 위에 있는데, 카메라의 시작 위치도 x = 0 이다. 관찰자가 글자판과 같은 평면 안에 서 있다.

두께가 0인 평면을 정확히 옆에서 보면 아무것도 안 보인다. 그래서 게임을 실행하면 배경 텍스처만 덩그러니 뜬다.

장면을 위에서 내려다본 도식 — 68글자가 전부 x=0 평면에 늘어서 있고 카메라 시작 위치도 그 평면 위라 시선이 평면과 나란하다. A/D 로 x 를 ±299 까지 옮겨야 비스듬히 보이기 시작한다
장면을 위에서 내려다본 도식 — 68글자가 전부 x=0 평면에 늘어서 있고 카메라 시작 위치도 그 평면 위라 시선이 평면과 나란하다. A/D 로 x 를 ±299 까지 옮겨야 비스듬히 보이기 시작한다

입력 처리를 보면 이 게임이 할 수 있는 조작은 딱 셋이다. ESC(DIK 0x01)로 종료, A(DIK 0x1e)와 D(DIK 0x20)로 카메라 X 를 프레임당 ∓1 이동, 마우스로 시선 회전. 앞뒤로 걷는 W/S 는 아예 없다. 누적값이 ±300 을 넘으면 ±299 로 되돌리는 클램프가 걸려 있는데, 그 300 이 곧 배경 상자의 반지름이다.

; WinMain 입력 처리 (0x1400013bb ~ 0x1400013e8)
test  BYTE PTR [rbp+0x4e], 0x80    ; 키보드 버퍼 +0x1e = DIK_A
je    .no_a
addss xmm0, xmm7                   ; xmm7 = -1.0   → x -= 1
.no_a:
test  BYTE PTR [rbp+0x50], 0x80    ; 키보드 버퍼 +0x20 = DIK_D
je    .no_d
addss xmm0, xmm8                   ; xmm8 = +1.0   → x += 1

말로만 하기는 아까워서 실제로 띄워 봤다. Windows PE 라 gdb 로는 못 붙지만, wine 을 Xvfb 위에 얹으면 d3d9·d3dx9_43·dinput8 이 전부 구현돼 있어 그대로 돈다.

#!/usr/bin/env bash
# My_First_Game_v0.1 — wine + Xvfb 로 게임을 실제로 띄우고 화면을 찍는다.
#   ./ingame_capture.sh <출력PNG> <D키를 누르고 있을 초>
# 두 번째 인자가 0 이면 시작 화면(카메라 x=0) 그대로 찍는다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
 
OUT="${1:?출력 PNG 경로}"
HOLD="${2:-0}"
DISP=":95"
export WINEPREFIX="$HOME/.wine_myfirstgame" WINEDEBUG=-all DISPLAY="$DISP"
 
pgrep -f "[X]vfb $DISP " >/dev/null || { Xvfb "$DISP" -screen 0 1280x900x24 >/tmp/xvfb95.log 2>&1 & sleep 2; }
[ -f "$WINEPREFIX/drive_c/windows/system32/kernel32.dll" ] || wine wineboot -u >/dev/null 2>&1
 
( cd extracted && setsid wine WindowsProject1.exe >/tmp/game.log 2>&1 </dev/null & )
sleep 20
 
WID=$(xdotool search --name '^My_First_Game_v0\.1$' | head -1)
[ -n "$WID" ] || { echo "게임 창을 못 찾았다"; exit 1; }
xdotool windowactivate "$WID" 2>/dev/null || true
xdotool windowfocus "$WID" 2>/dev/null || true
sleep 1
 
# A/D 는 프레임마다 카메라 X 를 ∓1 씩 옮긴다(0x140020694, ±300 로 클램프).
if [ "$HOLD" != "0" ]; then
  xdotool keydown --window "$WID" d
  sleep "$HOLD"
  xdotool keyup --window "$WID" d
  sleep 1
fi
 
import -window "$WID" "$OUT"
echo "저장: $OUT ($(identify -format '%wx%h' "$OUT"))"
 
# 이 실행이 띄운 게임만 종료한다(PID 지정 — 광범위 pkill 금지)
GPID=$(pgrep -f '[W]indowsProject1.exe' | head -1 || true)
[ -n "$GPID" ] && kill "$GPID" 2>/dev/null || true
./ingame_capture.sh 08_ingame_start.png 0

wine 과 Xvfb 로 실제 실행한 게임의 시작 화면 — 배경 상자에 입혀진 background.png 텍스처와 상자 모서리만 보이고 68개 글자는 어디에도 나타나지 않는다
wine 과 Xvfb 로 실제 실행한 게임의 시작 화면 — 배경 상자에 입혀진 background.png 텍스처와 상자 모서리만 보이고 68개 글자는 어디에도 나타나지 않는다

예상대로 배경만 보인다. 처음엔 wine 이 메시를 못 그린 줄 알았는데, 도식대로라면 이게 맞다. 이제 D 를 12초 정도 누르고 있으면 카메라가 x = +299 클램프까지 밀린다.

./ingame_capture.sh 09_ingame_strafe.png 12

D 키를 12초간 눌러 카메라를 x=299 까지 옮긴 뒤의 게임 화면 — 배경 상자 왼쪽 아래에 아주 작은 글자열이 비스듬히 나타나 68글자 중 일부만 시야에 들어온다
D 키를 12초간 눌러 카메라를 x=299 까지 옮긴 뒤의 게임 화면 — 배경 상자 왼쪽 아래에 아주 작은 글자열이 비스듬히 나타나 68글자 중 일부만 시야에 들어온다

왼쪽 아래에 뭔가 생겼다. 4배로 확대해 보면 글자다.

convert 09_ingame_strafe.png -crop 200x40+0+270 +repage \
  -filter point -resize 400% -bordercolor '#202020' -border 12 10_ingame_zoom.png

앞 게임 화면의 글자 영역만 4배로 확대한 것 — f 3 d c c 0 2 6 3 9 8 이 읽히고 이는 오프라인으로 뽑은 19번부터 29번 메시와 정확히 같다
앞 게임 화면의 글자 영역만 4배로 확대한 것 — f 3 d c c 0 2 6 3 9 8 이 읽히고 이는 오프라인으로 뽑은 19번부터 29번 메시와 정확히 같다

f3dcc026398. 오프라인으로 뽑은 띠에서 19번~29번 메시가 정확히 이 순서다. 게임 화면과 계산 결과가 맞는다는 걸 실물로 확인한 셈이다.

동시에 이 문제를 왜 "게임으로 풀면 안 되는지"도 분명해진다. 최대로 옆걸음쳐도 시야각 100° 안에 들어오는 건 68자 중 열몇 자뿐이고, 그마저 몇 픽셀 높이다. 남은 글자를 보려면 마우스로 시선을 돌려야 하는데 그러면 앞쪽 글자가 화면 밖으로 나간다. 손으로 다 읽는 건 사실상 불가능하다.


✅ 판독 교차검증 — 눈을 믿지 않는다

64자리 16진수를 한 글자라도 잘못 읽으면 오답이다. 특히 b/d, 6/9 처럼 서로 거울상인 글자가 위험하다. 그래서 판독 결과가 데이터와 모순되지 않는지 기하학으로 다시 확인했다.

착안점은 단순하다. 같은 글자는 같은 폰트 아웃라인을 복사해 넣은 것이므로 정점 수와 면 수가 완전히 같아야 한다. 그리고 거울쌍은 정점 수가 같아서 이 검사로는 안 갈리니, 채워진 글리프의 면적가중 무게중심을 본다.

#!/usr/bin/env python3
"""판독한 68글자가 메시 데이터와 모순이 없는지 기하로만 재확인한다.
 
1) 같은 글자면 (정점수, 면수) 가 완전히 같아야 한다 — 같은 폰트 아웃라인을 복사해 넣었으니까.
2) 거울쌍(b/d, 6/9, 어쩌면 {/}) 은 1) 로는 안 갈린다. 면적가중 무게중심으로 가른다.
     b/d : 어센더(y>0.55) 의 가로 위치 — 세로 획이 왼쪽이면 b
     6/9 : 전체 무게중심의 높이 — 배가 아래면 6
     {/} : 전체 무게중심의 가로 위치 — 팔이 오른쪽으로 뻗으면 {
"""
import json
import sys
from collections import defaultdict
 
SRC = sys.argv[1] if len(sys.argv) > 1 else "meshes.json"
GUESS = sys.argv[2] if len(sys.argv) > 2 else \
    "DH{7d32405fb8dc2608f3dcc026398bc7ce6a944a08702a1484495cf16bfe5cedf2}"
 
meshes = json.load(open(SRC))
assert len(meshes) == len(GUESS), f"메시 {len(meshes)}개 vs 글자 {len(GUESS)}개"
 
 
def props(m):
    """삼각형 면적으로 가중한 무게중심. 채워진 글리프의 실제 중심이다."""
    A = cz = cy = ztop = wtop = 0.0
    V = m["verts"]
    for a, b, c in m["tris"]:
        (za, ya), (zb, yb), (zc, yc) = ((V[i][2], V[i][1]) for i in (a, b, c))
        ar = abs((zb - za) * (yc - ya) - (zc - za) * (yb - ya)) / 2
        mz, my = (za + zb + zc) / 3, (ya + yb + yc) / 3
        A += ar
        cz += ar * mz
        cy += ar * my
        if my > 0.55:
            wtop += ar
            ztop += ar * mz
    zs = [v[2] for v in V]
    ys = [v[1] for v in V]
    z0, z1, y0, y1 = min(zs), max(zs), min(ys), max(ys)
    nz = lambda v: (v - z0) / (z1 - z0)
    return nz(cz / A), (cy / A - y0) / (y1 - y0), (nz(ztop / wtop) if wtop else None)
 
 
print("== 1) (정점수, 면수) 서명 ==")
sig = defaultdict(set)
for ch, m in zip(GUESS, meshes):
    sig[(m["num_vert"], m["num_face"])].add(ch)
clash = []
for k in sorted(sig):
    chars = sorted(sig[k])
    hole = (k[1] - (k[0] - 2)) // 2
    note = "" if len(chars) == 1 else "   <- 거울쌍 후보"
    if len(chars) > 1:
        clash.append(chars)
    print(f"   V={k[0]:3d} F={k[1]:3d} 구멍={hole}  ->  {chars}{note}")
 
print("\n== 2) 거울쌍 가르기 (면적가중 무게중심) ==")
RULE = {("b", "d"): ("ztop", "작으면 b"), ("6", "9"): ("cy", "작으면 6"),
        ("{", "}"): ("cz", "크면 {")}
for chars in clash:
    key = tuple(chars)
    rule = RULE.get(key) or RULE.get(key[::-1])
    print(f"   {key} — 기준 {rule[0] if rule else '?'} ({rule[1] if rule else ''})")
    seen, where = defaultdict(set), defaultdict(list)
    for i, (ch, m) in enumerate(zip(GUESS, meshes)):
        if ch in key:
            cz, cy, ztop = props(m)
            seen[ch].add((round(cz, 3), round(cy, 3), None if ztop is None else round(ztop, 3)))
            where[ch].append(i)
    for ch in key:
        vals = seen[ch]
        assert len(vals) == 1, f"'{ch}' 가 서로 다른 모양으로 판독됨: {vals}"
        cz, cy, ztop = next(iter(vals))
        print(f"      '{ch}' ×{len(where[ch])}  cz={cz} cy={cy} ztop={ztop}   "
              f"idx {where[ch]}")
    assert len(set().union(*seen.values())) == len(seen), f"{key} 가 서로 구분되지 않는다"
    print(f"      -> 같은 글자는 값이 완전히 같고 두 글자는 서로 다르다. OK")
 
body = GUESS[3:-1]
print(f"\n== 3) 형식 ==")
print(f"   전체 {len(GUESS)}자, DH{{ }} 껍데기 제외 {len(body)}자")
print(f"   전부 16진수인가: {all(c in '0123456789abcdef' for c in body)}  (64자 = SHA-256 자리수)")
print(f"\n{GUESS}")
python3 verify_glyphs.py

verify_glyphs.py 실행 결과 — 정점수와 면수 서명이 17가지로 갈리고 충돌하는 건 b 와 d, 여는 중괄호와 닫는 중괄호, 6 과 9 세 쌍뿐이며 세 쌍 모두 면적가중 무게중심으로 깨끗이 구분된다
verify_glyphs.py 실행 결과 — 정점수와 면수 서명이 17가지로 갈리고 충돌하는 건 b 와 d, 여는 중괄호와 닫는 중괄호, 6 과 9 세 쌍뿐이며 세 쌍 모두 면적가중 무게중심으로 깨끗이 구분된다

서명이 겹치는 건 정확히 세 쌍뿐이다. b/d, 6/9, 그리고 여닫는 중괄호. 전부 서로 거울상인 글자들이라 정점 수가 같은 게 당연하고, 오히려 판독이 맞다는 방증이다.

무게중심 값은 더 분명하다.

쌍판별값앞 글자뒤 글자
b / d어센더 가로 위치 ztop0.123 (세로 획이 왼쪽)0.877 (오른쪽)
6 / 9무게중심 높이 cy0.484 (배가 아래)0.516 (위)
여는 / 닫는 중괄호무게중심 가로 cz0.5320.467

같은 글자로 판독한 것끼리는 값이 소수점 셋째 자리까지 완전히 동일하고, 쌍끼리는 대칭적으로 갈린다. 같은 아웃라인을 복사·미러링해 넣었다는 증거다.



🚀 Full Exploit

위 과정을 스크립트 하나로 합쳤다. 눈으로 읽는 단계까지 없애기 위해, 각 글리프를 베이스라인 기준 비트맵으로 굽고 시스템 폰트로 같은 규격으로 구운 후보 글자들과 IoU 를 비교해 문자열을 복원한다. 후보는 영숫자 62자에 기호까지 88자를 다 넣었다 — 16진수만 나온다고 미리 가정하지 않기 위해서다.

세로는 베이스라인을 원점으로 하는 절대 좌표로 굽는다. 이렇게 해야 c 와 C 처럼 모양은 같고 크기만 다른 글자가 갈린다. 가로는 잉크 폭으로 정규화해 폰트마다 다른 자간· 좌측 여백의 영향을 없앤다.

#!/usr/bin/env python3
"""My_First_Game_v0.1 (DreamHack Gold 3, reversing) — 자체 완결 solve
 
게임을 한 번도 실행하지 않는다. 배포된 PE 안에 장면 데이터가 통째로 박혀 있어서
그것만 꺼내면 화면에 뭐가 그려질지 계산으로 알 수 있다.
 
  InitScene(0x140001900)  D3DXCreateMeshFVF 를 0x44=68회 돌려 메시를 만든다
      NumVertices[68]  .rdata 0x140003410
      NumFaces[68]     .rdata 0x140003520
      정점 스트림       .data  0x1400068f0   FVF 0x142 → 24B/정점 (pos12 + diffuse4 + uv8)
      인덱스 스트림     .data  0x14001ae10   uint16 × 3 × 면
 
  Render(0x140001520)   메시 i 의 월드행렬 = Translation(0,0,i) · Scaling(10,10,10)
 
정점의 x 는 전부 0 이다. 즉 68개 메시는 평면 도형이고, z 로 한 칸씩 밀려 한 줄로 늘어선다.
(z, y) 평면에 삼각형을 채우면 그게 곧 글자다 — 폰트 아웃라인을 삼각분할해 넣어 둔 것이다.
 
판독은 눈으로 하지 않는다. 각 글리프를 베이스라인 기준 비트맵으로 굽고, 시스템 폰트로
같은 규격으로 구운 후보 글자들과 IoU 로 맞춰 문자열을 복원한다.
 
  python3 solve.py                       # flag 출력
  python3 solve.py --strip flag_strip.png  # 사람이 눈으로 볼 수 있게 글자 띠도 저장
"""
import argparse
import struct
import sys
 
import numpy as np
import pefile
from PIL import Image, ImageDraw, ImageFont
 
# ── InitScene(0x140001900) 이 참조하는 주소들 ────────────────────────────────
NUM_MESH = 0x44
VA_NUMVERT = 0x140003410
VA_NUMFACE = 0x140003520
VA_VERTS = 0x1400068F0
VA_INDEX = 0x14001AE10
VSTRIDE = 24                    # FVF 0x142 = D3DFVF_XYZ | D3DFVF_DIFFUSE | D3DFVF_TEX1
 
# 글리프를 구울 격자. 세로는 베이스라인(y=0) 기준 절대 좌표라 'c' 와 'C' 가 구분된다.
CELL_H, CELL_W = 96, 64
Y_LO, Y_HI = -0.28, 0.88
 
CANDIDATES = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ{}[]()_-+=!?.,:;#@$%&*/<>"
FONTS = [
    "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
    "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
    "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
    "/usr/share/fonts/opentype/noto/NotoSans-Regular.ttf",
    "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
]
 
 
def load_meshes(exe):
    """PE 에서 68개 메시의 정점·인덱스를 잘라 낸다."""
    pe = pefile.PE(exe)
    base = pe.OPTIONAL_HEADER.ImageBase
    rd = lambda va, n: pe.get_data(va - base, n)
 
    nvert = struct.unpack(f"<{NUM_MESH}I", rd(VA_NUMVERT, NUM_MESH * 4))
    nface = struct.unpack(f"<{NUM_MESH}I", rd(VA_NUMFACE, NUM_MESH * 4))
    vbuf = rd(VA_VERTS, sum(nvert) * VSTRIDE)
    ibuf = rd(VA_INDEX, sum(nface) * 6)
 
    out, vcur, fcur = [], 0, 0
    for i in range(NUM_MESH):
        nv, nf = nvert[i], nface[i]
        # 정점 앞 12바이트가 pos(x, y, z). x 는 전부 0 이라 (z, y) 두 축만 쓴다.
        pts = []
        for j in range(nv):
            _x, y, z = struct.unpack_from("<fff", vbuf, (vcur + j) * VSTRIDE)
            pts.append((z, y))
        raw = struct.unpack_from(f"<{nf * 3}H", ibuf, fcur * 6)
        out.append((pts, [raw[k:k + 3] for k in range(0, len(raw), 3)]))
        vcur, fcur = vcur + nv, fcur + nf
    return out
 
 
def rasterize(pts, tris, w=CELL_W, h=CELL_H):
    """글리프를 비트맵으로 굽는다. 세로는 베이스라인 절대, 가로는 잉크 폭으로 정규화."""
    zs = [p[0] for p in pts]
    z0, z1 = min(zs), max(zs)
    sz = (w - 1) / (z1 - z0) if z1 > z0 else 1.0
    sy = (h - 1) / (Y_HI - Y_LO)
 
    img = Image.new("1", (w, h), 0)
    dr = ImageDraw.Draw(img)
    for a, b, c in tris:
        dr.polygon([((p[0] - z0) * sz, (Y_HI - p[1]) * sy) for p in (pts[a], pts[b], pts[c])],
                   fill=1)
    return np.array(img, dtype=bool)
 
 
def font_glyph(font, ch, w=CELL_W, h=CELL_H):
    """후보 글자를 같은 규격으로 굽는다. em 크기를 글리프 좌표계(1.0 em)에 맞춘다."""
    em = font.size
    pad = em * 2
    img = Image.new("1", (pad * 2, pad * 2), 0)
    ImageDraw.Draw(img).text((pad, pad), ch, font=font, fill=1, anchor="ls")  # 왼쪽·베이스라인
    arr = np.array(img, dtype=bool)
    ys, xs = np.nonzero(arr)
    if len(xs) == 0:
        return None
 
    # 세로는 베이스라인(row=pad)이 y=0 이 되도록 절대 매핑, 가로는 잉크 폭으로 정규화
    gy = (pad - ys) / em                       # 폰트 좌표 → em 단위 y
    gx = (xs - xs.min()) / max(xs.max() - xs.min(), 1)
 
    out = np.zeros((h, w), dtype=bool)
    rr = ((Y_HI - gy) * (h - 1) / (Y_HI - Y_LO)).round().astype(int)
    cc = (gx * (w - 1)).round().astype(int)
    ok = (rr >= 0) & (rr < h)
    out[rr[ok], cc[ok]] = True
    # 폰트 쪽은 점으로 찍혀 구멍이 생기니 한 번 메워 준다
    from scipy.ndimage import binary_closing  # noqa: F401  (없으면 아래 except 로)
    return binary_closing(out, np.ones((3, 3)))
 
 
def classify(masks, fontpath, size=180):
    font = ImageFont.truetype(fontpath, size)
    refs = {}
    for ch in CANDIDATES:
        g = font_glyph(font, ch)
        if g is not None and g.any():
            refs[ch] = g
 
    text, score_sum = "", 0.0
    for m in masks:
        best, bs = "?", -1.0
        for ch, g in refs.items():
            inter = np.count_nonzero(m & g)
            union = np.count_nonzero(m | g)
            iou = inter / union if union else 0.0
            if iou > bs:
                bs, best = iou, ch
        text += best
        score_sum += bs
    return text, score_sum / len(masks)
 
 
def save_strip(meshes, path, per_row=17):
    """게임이 배치하는 그대로(메시 i 를 z 로 i 만큼 이동) 한 줄로 펼쳐 그린다."""
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    from matplotlib.patches import Polygon
 
    rows = [list(range(i, min(i + per_row, len(meshes)))) for i in range(0, len(meshes), per_row)]
    fig, axes = plt.subplots(len(rows), 1, figsize=(22, 3.0 * len(rows)))
    for ax, row in zip(np.atleast_1d(axes), rows):
        for i in row:
            pts, tris = meshes[i]
            off = i - row[0]
            for a, b, c in tris:
                ax.add_patch(Polygon([(pts[k][0] + off, pts[k][1]) for k in (a, b, c)],
                                     closed=True, facecolor="black", edgecolor="none"))
            ax.text(off + 0.5, -0.45, str(i), ha="center", va="top", fontsize=7, color="#c04040")
        ax.set_xlim(-0.3, per_row + 0.3)
        ax.set_ylim(-0.55, 0.95)
        ax.set_aspect("equal")
        ax.axis("off")
    fig.tight_layout()
    fig.savefig(path, dpi=130, facecolor="white")
    print(f"[*] 글자 띠 저장: {path}")
 
 
def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("exe", nargs="?", default="extracted/WindowsProject1.exe")
    ap.add_argument("--strip", metavar="PNG", help="사람이 볼 수 있게 글자 띠 PNG 도 저장")
    args = ap.parse_args()
 
    meshes = load_meshes(args.exe)
    print(f"[*] 메시 {len(meshes)}개, 정점 {sum(len(m[0]) for m in meshes)}, "
          f"면 {sum(len(m[1]) for m in meshes)}")
 
    masks = [rasterize(pts, tris) for pts, tris in meshes]
 
    best_text, best_font, best_score = "", "", -1.0
    for fp in FONTS:
        try:
            text, score = classify(masks, fp)
        except OSError:
            continue
        print(f"[*] {fp.split('/')[-1]:32s} 평균 IoU {score:.3f}  {text}")
        if score > best_score:
            best_text, best_font, best_score = text, fp, score
 
    if not best_text:
        sys.exit("사용할 수 있는 폰트를 못 찾았다")
 
    print(f"[*] 채택: {best_font.split('/')[-1]} (평균 IoU {best_score:.3f})")
    print(f"\nFLAG: {best_text}")
 
    if args.strip:
        save_strip(meshes, args.strip)
 
 
if __name__ == "__main__":
    main()
python3 solve.py --strip flag_strip.png

solve.py 실행 결과 — 다섯 개 시스템 폰트로 각각 템플릿 매칭을 돌려 DejaVu Sans 가 평균 IoU 0.899 로 가장 높고 그 결과 문자열이 눈으로 읽은 68글자와 완전히 같다
solve.py 실행 결과 — 다섯 개 시스템 폰트로 각각 템플릿 매칭을 돌려 DejaVu Sans 가 평균 IoU 0.899 로 가장 높고 그 결과 문자열이 눈으로 읽은 68글자와 완전히 같다

DejaVu Sans 기준 평균 IoU 0.899 로 68글자를 전부 맞혔다. 눈으로 읽은 것과 한 글자도 다르지 않다. Liberation Sans 는 a 를 u 로, Noto Sans 는 1 을 I 로 몇 번 놓치는데, IoU 평균이 각각 0.657 / 0.773 으로 낮게 나와 자동으로 걸러진다.

FLAG: DH{7d32405fb8dc2608f3dcc026398bc7ce6a944a08702a1484495cf16bfe5cedf2}

FLAG: DH{7d32405fb8dc2608f3dcc026398bc7ce6a944a08702a1484495cf16bfe5cedf2}

로컬 환경은 Ubuntu 24.04 / Python 3.12 이고 pefile, numpy, Pillow, scipy 가 필요하다. 글자 띠 PNG 를 같이 뽑으려면 matplotlib 도 있어야 한다. 게임을 실제로 띄워 본 쪽은 wine 10.0 + Xvfb 조합이다.



📝 결론

클라이언트가 가진 데이터는 이미 공개된 데이터다

이 문제의 flag 는 암호화도 난독화도 되어 있지 않았다. 그냥 .data 에 글자 모양 그대로 누워 있었다. 숨긴 건 데이터가 아니라 보는 각도였다 — 관찰자를 글자판과 같은 평면에 세워 두께 0 인 도형을 보이지 않게 만든 것이다.

게임 클라이언트라면 흔히 있는 상황이다. 서버가 판정하지 않는 값을 클라이언트에 내려보내 놓고 UI 에서 안 보이게 가리면 숨긴 것 같지만, 파일을 받은 쪽에서는 UI 를 거칠 이유가 없다.

렌더링 파이프라인은 역추적하기 쉬운 API 다

D3DX 든 OpenGL 이든, "버퍼를 잠그고 → 복사하고 → 푼다"는 절차가 규격으로 정해져 있다. Lock 계열 호출 사이의 memcpy 인자만 따라가면 원본 데이터의 주소와 크기가 그대로 나온다. 이 문제도 정점 24바이트와 인덱스 6바이트라는 두 숫자만 알면 나머지는 산수였다.

추측한 글자는 검증하고 넘어간다

64자리 16진수를 눈으로 읽는 건 위험하다. 여기서는 같은 글자끼리 정점 수·면 수가 일치하는지, 거울쌍이 무게중심으로 갈리는지, 그리고 폰트 템플릿 매칭이 같은 답을 주는지 — 서로 독립적인 세 방법이 전부 같은 문자열을 가리켰다. 하나만으로 제출했으면 b 와 d 어딘가에서 틀렸을 수도 있다.

만든 사람이 의도한 난이도와 실제 난이도는 다를 수 있다

출제자는 아마 "게임 안을 돌아다니며 글자를 찾는" 그림을 그렸을 것이다. 하지만 데이터가 평문으로 들어 있는 이상, 화면을 통해 읽는 것보다 데이터를 직접 읽는 게 훨씬 빠르고 정확하다. Gold 3 이라는 난이도의 대부분은 "게임을 켜지 않아도 된다"는 사실을 알아채는 데 있었다.

이 글이 도움이 됐나요?

Comments

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥈 Silver 2] 시리얼 검증 루프를 거꾸로 돌리기 — DreamHack babycmp 풀이
blog

[🥈 Silver 2] 시리얼 검증 루프를 거꾸로 돌리기 — DreamHack babycmp 풀이

SERIAL 한 줄만 받는 MFC 다이얼로그 앱. 입력 24글자를 int32 배열로 부풀린 뒤 .data 에 박아 둔 값 24개와 통째로 비교한다. 변환이 ror32 → XOR → ADD → +30000 넉 줄뿐이라 자리마다 후보가 하나로 떨어지고, 키는 .rdata 가 아니라 코드 즉치값으로 숨어 있다. 역산본은 바이너리의 루프 기계어를 Unicorn 으로 그대로 돌려 교차검증했다.
#dreamhack#ctf#reversing+6
2026-08-17#dreamhack +4
[🥉 Bronze 2] 암호도 플래그도 .data 에 누워 있다 — DreamHack Gyul Brix Calculator 풀이
blog

[🥉 Bronze 2] 암호도 플래그도 .data 에 누워 있다 — DreamHack Gyul Brix Calculator 풀이

당도를 입력받는 척하는 32비트 PE 안에, 관리자 암호와 플래그가 각각 단일바이트 XOR 한 겹만 쓴 채 .data 에 나란히 누워 있다. 키가 코드 안 즉치값이라 strings 로는 아무것도 안 잡히고, 두 블롭을 키·암호문 쌍으로 넘겨짚으면 더 헤맨다. 디스어셈블리에서 0x55 와 0x77 을 읽어내면 끝난다.
#dreamhack#ctf#reversing+6
2026-08-17#dreamhack +4
[🥈 Silver 1] 상쇄되는 연산 14개와 미끼 키 2개 — DreamHack Ransom 풀이
blog

[🥈 Silver 1] 상쇄되는 연산 14개와 미끼 키 2개 — DreamHack Ransom 풀이

UPX 로 포장된 32비트 PE 가 flag.txt 를 두 번 암호화한다. 진짜 main 은 암호화 함수를 둘 부르는데, 하나만 보고 뒤집으면 아무것도 안 나온다. 2단계는 고정 인덱스 연산이 15개나 붙어 있지만 14개가 역연산 짝이라 상쇄되고 실질은 하나뿐이고, .rdata 의 키처럼 생긴 문자열 셋 중 둘은 스택에 복사만 되고 읽히지 않는 미끼다. 키가 파일보다 한 바이트 짧아 마지막 바이트는 초기화 안 된 스택을 읽는다.
#dreamhack#ctf#reversing+7
2026-07-28#dreamhack +4