HTB: NoMap3D Challenge

NoMap3D - HackTheBox Challenge Writeup

Challenge Information

FieldValue
Challenge NameNoMap3D
CategoryMisc
DifficultyMedium
Authord3vn0mi

Description

I’m lost in this green cube hell. I have to find a way out of it.

The challenge ships a small SDL2 raycasting “game” together with a binary asset dump. Nothing about the description hints at reversing — the real obstacle turned out to be getting a working copy of the binary in the first place.

Solution

The staged challenge directory contained a nomap3d binary that was exactly 0 bytes. Rather than assume the artifact was broken, I went back to the original source zip and listed its contents instead of just extracting it blindly:

Terminal window
unzip -l /out/<challenge>.zip
# Length Date Time Name
# --------- ---------- ----- ----
# 22816 ... ... gamepwn_nomap3d/nomap3d
# 11904444 ... ... gamepwn_nomap3d/assets.dmp

Both entries had non-zero lengths in the zip’s own index — the previous “corrupted” extraction was actually the result of the archive being password-protected and the staging step silently writing out empty placeholder files instead of failing loudly. Trying the well-known HTB default password immediately unlocked it:

Terminal window
unzip -P hackthebox gamepwn_nomap3d.zip

This recovered a real, non-empty nomap3d (22,816 bytes) and assets.dmp (11,904,444 bytes) — the actual challenge could finally begin.

With working files in hand, file and nm -C confirmed nomap3d was an unstripped x86-64 PIE binary compiled from a colf.c source, built on SDL2 — a first-person raycaster in the style of Wolfenstein 3D. assets.dmp was clearly a custom binary asset container feeding the renderer at startup, so the plan became: reverse load_assets() to understand the container format, extract the level map from it, and render that map as an image to see whatever the game world was actually spelling out.

Key Steps

1. Recover the real binary from the password-protected zip

Terminal window
file gamepwn_nomap3d/nomap3d
# nomap3d: ELF 64-bit LSB pie executable, x86-64, ... not stripped
nm -C gamepwn_nomap3d/nomap3d | grep -v ' U ' | sort -k3

2. Reverse load_assets() to learn the TLV container format

Terminal window
objdump -d --no-show-raw-insn -M intel \
--start-address=0x15d0 --stop-address=0x18f0 nomap3d

assets.dmp turned out to be a simple TLV (tag-length-value) chunk stream, keyed by a uint32 tag read in a loop:

TagPayload
12× double — player start position, (103.0, 7.0)
2uint32 w, uint32 h, then w*h*3 bytes — the level map, 3 bytes per cell
3one texture record — skybox, 1024×906
4uint32 n, then n texture records — htb, wall_circuit, floor_circuit

Each texture record (from load_assets_texture @ 0x1540) is: a 64-byte name field, uint32 w, uint32 h, followed by w*h*4 bytes of raw RGBA pixel data.

3. Parse the TLV stream in Python

parse.py
import struct
d = open('gamepwn_nomap3d/assets.dmp', 'rb').read()
o = 0
info = {}
while o < len(d):
tag, = struct.unpack_from('<I', d, o); o += 4
if tag == 1:
px, py = struct.unpack_from('<dd', d, o); o += 16
info['pos'] = (px, py)
elif tag == 2:
w, h = struct.unpack_from('<II', d, o); o += 8
mp = d[o:o + w * h * 3]; o += w * h * 3
info['map'] = (w, h, mp)
elif tag == 3 or tag == 4:
# texture record(s): 64-byte name + w + h + w*h*4 RGBA bytes
...

The map dimensions came out to 259×12 — clearly not a “square room”, but a long, thin strip. This shape was the first hint that the map itself was the payload, not just a maze.

4. Fix the row-major vs. column-major bug using the disassembly

My first render (naively indexing map[(y*w + x)*3], i.e. row-major) produced visual garbage. Rather than guess again, I pulled up the actual raycasting lookup at raycast+0x2cb:

231b: mov eax,ecx ; x
231d: imul eax,edi ; * h
2320: add eax,esi ; + y
2322: lea rax,[rax+rax*2] ; * 3
2326: movzx eax,BYTE PTR [rdx+rax*1]

This confirms the map is stored column-major: cell(x, y) = map[(x*h + y) * 3]. Reworking the parser with the correct indexing:

# glyph.py (final, corrected renderer)
import pickle
info = pickle.load(open('info.pkl', 'rb'))
w, h, mp = info['map']
def cell(x, y):
off = (x * h + y) * 3
return mp[off:off + 3]

5. Render the corrected map as a bitmap

# map3.py — build a scaled RGB image from the column-major cell grid
from PIL import Image
S = 4 # pixel scale per cell
img = Image.new('RGB', (w * S, h * S))
px = img.load()
for x in range(w):
for y in range(h):
r, g, b = cell(x, y)
for dx in range(S):
for dy in range(S):
px[x * S + dx, y * S + dy] = (r, g, b)
img.save('map.png')

Rendering the 259×12 grid at correct column-major indexing revealed the maze/wall layout was not a random level — it had been deliberately laid out to spell text when viewed as a bitmap, exposing the flag directly in the image.

Tools Used

ToolPurpose
unzipExtracted the password-protected challenge archive (-P hackthebox)
fileIdentified nomap3d as an unstripped x86-64 PIE ELF
nm -CEnumerated demangled symbols to locate asset-loading and raycasting functions
objdump -dDisassembled load_assets, load_assets_texture, and raycast to recover the TLV format and array indexing order
Python (struct, pickle, PIL)Parsed the TLV asset stream and rendered the level map as a bitmap image

Key Learnings

  1. Don’t trust a “corrupted” or 0-byte artifact at face value. Always cross-check the source archive’s own index (unzip -l) before concluding a file is broken — a silent password-protection failure during staging can masquerade as a corrupted binary. HTB archives commonly use the password hackthebox, worth trying immediately in this situation.
  2. Never assume array layout — verify it in the disassembly. The first render attempt (row-major) produced garbage; only pulling the actual indexing instructions (imul, add, lea [rax+rax*2]) from the raycast routine confirmed the map was stored column-major. Guessing memory layout from “how it looks like it should work” wastes time that reading three lines of disassembly avoids.
  3. Custom binary asset formats in small game-engine style challenges are usually simple, hand-rolled TLV streams — reversing a single loader function (load_assets) is often enough to fully describe the format and unlock all embedded data (position, map, textures) without needing to run the binary itself.

Flag

HTB{REDACTED}