[🥇 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 패턴만 찾으면 원본 데이터의 주소가 그대로 드러난다. 이 문제의 전부가 여기에 있다.


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

.text0x1b1c(약 7 KB)밖에 안 되는데 .data0x1b6b0(약 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 — 키보드와 마우스.

RegisterClassExWCreateWindowExWPeekMessageW 루프까지, 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]). 0x220D3DXMESH_VB_SYSTEMMEM | D3DXMESH_IB_SYSTEMMEM — 버퍼를 시스템 메모리에 잡는다.
  3. LockVertexBuffermemcpy(dst, 0x1400068f0 + 정점커서 × 24, NumVertices × 24)Unlock.
  4. LockIndexBuffermemcpy(dst, 0x14001ae10 + 면커서 × 6, NumFaces × 6)Unlock.

shl r8, 0x3lea 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)

메시 izi 만큼 밀린 뒤 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, z0.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) 가 되겠지 싶었는데, +8z 이고 그다음 +12y 가 아니라 diffuse 색깔이다. 0xffffffff 를 float 로 읽으면 NaN 이 나오고, 그걸 세로 좌표로 쓰면 모든 글리프가 똑같이 뭉개진다.

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

[*] DejaVuSans.ttf   평균 IoU 0.000  00000000000000000000000000000000000000000000000000000000000000000000

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

_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.dlld3dx9_43.dlldinput8.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 에 빈틈없이 붙어 있다는 뜻이고, 커서 계산이 한 바이트도 어긋나지 않았다는 뜻이기도 하다.

이제 그리기만 하면 된다. 게임이 하는 배치(메시 izi 만큼 이동)를 그대로 두고, (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진수만 나온다고 미리 가정하지 않기 위해서다.

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

#!/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 는 au 로, Noto Sans 는 1I 로 몇 번 놓치는데, 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진수를 눈으로 읽는 건 위험하다. 여기서는 같은 글자끼리 정점 수·면 수가 일치하는지, 거울쌍이 무게중심으로 갈리는지, 그리고 폰트 템플릿 매칭이 같은 답을 주는지 — 서로 독립적인 세 방법이 전부 같은 문자열을 가리켰다. 하나만으로 제출했으면 bd 어딘가에서 틀렸을 수도 있다.

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

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

이 글이 도움이 됐나요?

Comments

댓글

0

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

댓글 불러오는 중…

Related

관련 글

3
[🥉 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
[🥈 Silver 4] 프로그램이 정말로 자기 이름에게 물었다 — DreamHack What is your name? 풀이
blog

[🥈 Silver 4] 프로그램이 정말로 자기 이름에게 물었다 — DreamHack What is your name? 풀이

zip 안에 exe가 11개, 전부 파일명이 무작위 3글자로 뒤섞여 있다. 실행해도 콘솔에 아무것도 안 뜨고, sha256도 전부 다르다. cmp -l로 두 파일을 겹쳐보니 123392바이트 중 딱 2바이트만 다르다는 걸 발견하면서 실마리가 풀렸다. 각 exe는 GetModuleFileNameA로 자기 자신의 파일명을 읽어 한 글자씩 밀어서 출력할 뿐이었고, 그 결과를 순서대로 이어붙이면 그대로 flag가 된다 — "What is your name?"이 은유가 아니라, 프로그램이 진짜로 자기 자신의 이름을 묻는 질문이었다.
#dreamhack#ctf#reversing+4
2026-07-21#dreamhack +4