HTB: NoMap3D Challenge
NoMap3D - HackTheBox Challenge Writeup
Challenge Information
| Field | Value |
|---|---|
| Challenge Name | NoMap3D |
| Category | Misc |
| Difficulty | Medium |
| Author | d3vn0mi |
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:
unzip -l /out/<challenge>.zip# Length Date Time Name# --------- ---------- ----- ----# 22816 ... ... gamepwn_nomap3d/nomap3d# 11904444 ... ... gamepwn_nomap3d/assets.dmpBoth 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:
unzip -P hackthebox gamepwn_nomap3d.zipThis 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
file gamepwn_nomap3d/nomap3d# nomap3d: ELF 64-bit LSB pie executable, x86-64, ... not stripped
nm -C gamepwn_nomap3d/nomap3d | grep -v ' U ' | sort -k32. Reverse load_assets() to learn the TLV container format
objdump -d --no-show-raw-insn -M intel \ --start-address=0x15d0 --stop-address=0x18f0 nomap3dassets.dmp turned out to be a simple TLV (tag-length-value) chunk stream, keyed by a uint32 tag read in a loop:
| Tag | Payload |
|---|---|
1 | 2× double — player start position, (103.0, 7.0) |
2 | uint32 w, uint32 h, then w*h*3 bytes — the level map, 3 bytes per cell |
3 | one texture record — skybox, 1024×906 |
4 | uint32 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
import struct
d = open('gamepwn_nomap3d/assets.dmp', 'rb').read()o = 0info = {}
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 ; x231d: imul eax,edi ; * h2320: add eax,esi ; + y2322: lea rax,[rax+rax*2] ; * 32326: 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 gridfrom PIL import Image
S = 4 # pixel scale per cellimg = 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
| Tool | Purpose |
|---|---|
unzip | Extracted the password-protected challenge archive (-P hackthebox) |
file | Identified nomap3d as an unstripped x86-64 PIE ELF |
nm -C | Enumerated demangled symbols to locate asset-loading and raycasting functions |
objdump -d | Disassembled 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
- 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 passwordhackthebox, worth trying immediately in this situation. - 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 theraycastroutine 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. - 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}