문제: 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/*
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | 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 를 풀면 이렇게 된다.
| 비트 | 상수 | 크기 |
|---|---|---|
0x002 | D3DFVF_XYZ — 위치 float 3개 | 12 B |
0x040 | D3DFVF_DIFFUSE — 정점 색 DWORD | 4 B |
0x100 | D3DFVF_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
임포트 목록이 게임의 설계도를 그대로 보여 준다.
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 *$"
루프 한 바퀴가 하는 일은 이렇다.
NumVertices[i]를.rdata 0x140003410에서,NumFaces[i]를0x140003520에서 읽는다. 두 배열 모두 DWORD 68개다.D3DXCreateMeshFVF(NumFaces, NumVertices, 0x220, 0x142, dev, &mesh[i]).0x220은D3DXMESH_VB_SYSTEMMEM | D3DXMESH_IB_SYSTEMMEM— 버퍼를 시스템 메모리에 잡는다.LockVertexBuffer→memcpy(dst, 0x1400068f0 + 정점커서 × 24, NumVertices × 24)→Unlock.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 *$"
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)) / 268개 메시를 이 식에 넣으면 이렇게 갈린다.
| V | F | 구멍 | 이 모양일 수 있는 것 |
|---|---|---|---|
| 7 | 5 | 0 | 획이 두세 개뿐인 단순한 글자 |
| 34 | 34 | 1 | 속이 뚫린 글자 — D, 0, a, e … |
| 108 | 110 | 2 | 구멍이 둘 — 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 c0000135DLL 이 없다는 말이라 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
주소 계산이 맞는지 확인할 방법이 하나 있다. 정점 스트림의 끝(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글자가 한 번에 나왔다. DH + { + 64자리 16진수 + } 구성이고, 16진수 64자리는
SHA-256 다이제스트 길이 그대로다.
🎮 그래서 게임을 켜면 뭐가 보이나
데이터를 다 읽고 나서야 이 문제의 장난이 이해됐다. 68글자가 전부 x = 0 평면 위에 있는데,
카메라의 시작 위치도 x = 0 이다. 관찰자가 글자판과 같은 평면 안에 서 있다.
두께가 0인 평면을 정확히 옆에서 보면 아무것도 안 보인다. 그래서 게임을 실행하면 배경 텍스처만 덩그러니 뜬다.

입력 처리를 보면 이 게임이 할 수 있는 조작은 딱 셋이다. 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 이 메시를 못 그린 줄 알았는데, 도식대로라면 이게 맞다.
이제 D 를 12초 정도 누르고 있으면 카메라가 x = +299 클램프까지 밀린다.
./ingame_capture.sh 09_ingame_strafe.png 12
왼쪽 아래에 뭔가 생겼다. 4배로 확대해 보면 글자다.
convert 09_ingame_strafe.png -crop 200x40+0+270 +repage \
-filter point -resize 400% -bordercolor '#202020' -border 12 10_ingame_zoom.png
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
서명이 겹치는 건 정확히 세 쌍뿐이다. b/d, 6/9, 그리고 여닫는 중괄호.
전부 서로 거울상인 글자들이라 정점 수가 같은 게 당연하고, 오히려 판독이 맞다는 방증이다.
무게중심 값은 더 분명하다.
| 쌍 | 판별값 | 앞 글자 | 뒤 글자 |
|---|---|---|---|
b / d | 어센더 가로 위치 ztop | 0.123 (세로 획이 왼쪽) | 0.877 (오른쪽) |
6 / 9 | 무게중심 높이 cy | 0.484 (배가 아래) | 0.516 (위) |
| 여는 / 닫는 중괄호 | 무게중심 가로 cz | 0.532 | 0.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
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
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…