With the Quiet March broken, Aeron's path to the Salt Crown should be clear. Instead, Cassian waits at the Registry Altar, protected by Maelor's last covenant and the testimony of the living. Before the crown can be carried into open sky, one final claim must be answered - and the dead do not surrender their laws easily.
A 70 MB Godot game for Windows. The flag is never stored as a string anywhere: it is drawn, one horizontal span at a time, from 1024 individually AES-256-GCM encrypted records. The whole challenge is recovering the key that decrypts them.
$ file a25422a4-819f-46c2-9d46-2536deb0cca9-1784822421.zip
a25422a4-819f-46c2-9d46-2536deb0cca9-1784822421.zip: Zip archive data, at least v2.0
to extract, compression method=deflate
$ unzip -l a25422a4-819f-46c2-9d46-2536deb0cca9-1784822421.zip
Length Date Time Name
--------- ---------- ----- ----
208384 07-21-2026 15:57 The Salt Crown/challenge_core.windows.template_release.x86_64.dll
70319920 07-21-2026 15:57 The Salt Crown/The Salt Crown.exe
--------- -------
70528304 2 files
Two files: a 70 MB executable and a 208 KB DLL beside it. The size ratio alone says the exe is an engine plus assets, and the DLL is the part somebody actually wrote for this challenge.
Neither. There is no service to connect to - the handout is a Windows game you are meant to play. The host here is macOS, so the game was never run at all; everything below is static analysis of the two files.
$ strings "The Salt Crown.exe" | grep -i godot | head
... many _godot symbols ...
Conclusion: It is a Godot 4 export. Godot games ship their assets in a PCK archive and their native code in a GDExtension DLL - so there are two places to look.
The lazy first move: let an online sandbox run the Windows binary and watch what it does.
70 MB is over any.run's upload limit. Dead before it started - and running the game would not have helped much anyway, since the interesting code is in the DLL and the flag is rendered rather than printed.
Conclusion: Static analysis it is. Start with the container formats.
Godot appends its PCK archive after the last PE section, with a trailer ending in the
magic GDPC. Standard extractors find it by that magic.
No extractor found anything. The trailer is there, but the magic is not Godot's:
[u64 size][u64 tag][u32 0]["SCX1"] <- custom, 24 bytes
[u64 size]["GDPC"] <- what Godot writes
The engine was patched to accept it - try_open_pack compares against
0x31584353 ("SCX1") at va 0x142a1c132. pckx.py in
the work directory extracts the blob, and exe_pack.asm holds the
disassembly of the patched check.
Conclusion: A deliberate speed bump. The PCK contains game assets, not the flag - this whole branch turned out to be unnecessary. The DLL is the real target.
208 KB of DLL, and strings shows almost nothing readable - a packer.
Same trick as with the PCK: it is UPX with the markers renamed. Sections
.slt0/.slt1/.slt2 instead of UPX0/1/2, magic SCN!
instead of UPX!. Rename them back and stock UPX handles it:
$ upx -d -o core_unpacked.dll core_upx.dll
Conclusion: 430 KB of unpacked C++. Load it into Ghidra and export the whole
decompilation (decomp.c, 995 functions) to grep through.
Most of the 995 functions are Godot runtime. The challenge-specific ones are few, and
strings pointed at Windows BCrypt* calls - so there is real
crypto in here.
The GDExtension class ChallengeCore registers four methods:
reset_session
submit_event(opcode, arg0, arg1)
get_public_state
render_reward_step(target: Image)
render_reward_step (FUN_1800072b0, calling
FUN_180006ab0) is the one that matters. Near it sits a data block headed
SCAR followed by 1024 records of 48 bytes at rva 0x41100, with
an entropy close to 8 - encrypted.
Conclusion: The flag is not a string in the binary. It is drawn into an
Image from decrypted drawing commands, which means the entire problem is
reconstructing the AES key.
Three inputs feed the key, and each is hidden differently.
master = (D[0x4d110] ^ D[0x4d130]) ^ (D[0x4d150] ^ D[0x4d170])
^ (D[0x4d190] ^ D[0x4d1b0])
32 bytes, three XOR shares. Four of them are further XORed with
FUN_180001280(), an anti-debug word that is 0 on a clean run.
session = SHA256( "SCTR\x01\x00\x00\x00" || 7 x 8-byte event records )
key = HKDF-SHA256(salt = D[0x41090] (32 B),
ikm = master || session,
info = "stormbound/reward/v2\0" || D[0x41080] (16 B),
L = 32)
The seven events are fixed constants in FUN_180003b50, validated against the
opcode table at 0x4d108 (19 2d 43 58 6e 81 b7). So the "session"
is not session-specific at all - a correctly played game always produces the same seven
records, hence always the same key:
| # | opcode | arg0 | arg1 |
|---|---|---|---|
| 0 | 0x19 | 1 | 1 |
| 1 | 0x2d | 3 | 0 |
| 2 | 0x43 | 7 | 0 |
| 3 | 0x58 | 0x0f | 0 |
| 4 | 0x6e | 0x1f | 0 |
| 5 | 0x81 | 0x3f | 0 |
| 6 | 0xb7 | 0x7f | 0 |
Record layout is <B opcode><B index+1><H arg0><I arg1>.
Conclusion: Everything the key needs is in the DLL. The game never has to be played - the seven "accepted events" can just be written down.
With the key derived, decryption should be mechanical. Record 0 authenticated - and record 1 did not.
The per-record parameters use different byte orders for the same index, which is invisible as long as you only test index 0 (where big-endian and little-endian agree):
nonce = D[0x410b0] (8 B) || big-endian u32 index
AAD = D[0x410b8] (58 B, the SCAR header)
|| little-endian u32 index || 00 04 || 00 00
ct = D[0x41100 + i*0x30], 48 B (32 B data + 16 B tag) -> 32 B plaintext
Conclusion: Always verify a decryption on an index where the encodings differ. Index 0 authenticating proves nothing about byte order.
1024 decrypted records of 32 bytes each, first byte an opcode.
| byte | meaning |
|---|---|
0x39 |
span: x0 = i16(pt[2:4]) + x, y = i16(pt[4:6]) + y,
x1 = i16(pt[6:8]) + x0; draw x0..x1 on row
y, then x,y = x1,y |
0x5d | no-op |
0xa6 | frame marker |
0xe3 | terminator (only valid at index 1023) |
Start state is x = 0x2d, y = 0x3d on a 344x192 canvas. The commands draw 532
spans, ink bounding box x 49..295, y 72..93.
Conclusion: Rendering it offline needs no Godot and no Windows - a few hundred
lines of Python reproduce render_reward_step exactly.
The image renders, the flag is legible, done - except it is not.
The rendered reward image. Read by eye, the last word says "w0rld".
It does not. In this bitmap font l is 2 px wide with a foot, while
1 is 3 px wide with a flag. The last word is w0r1d with a
digit one - the brain supplies "world" and quietly corrects the glyph. The first
transcription in the notes has it wrong for exactly this reason.
Conclusion: Do not transcribe rendered text by eye. solve.py carries a
glyph table and OCRs the bitmap against it, so the ambiguity cannot survive.
solve.py - standalone, needs only core_unpacked.dll and the
cryptography package. It derives the key, decrypts all 1024 records, replays
the drawing commands, writes PBM and PNG output and OCRs the flag back to text.
#!/usr/bin/env python3
"""Reproduce ChallengeCore::render_reward_step offline and draw the reward image."""
import hashlib
import hmac
import struct
import sys
import zlib
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
DLL = "core_unpacked.dll"
d = open(DLL, "rb").read()
def rd(rva, n):
"""Read n bytes at a virtual RVA (.rdata: rva 0x40000 -> raw 0x3e600)."""
off = 0x3E600 + (rva - 0x40000)
return d[off:off + n]
def xor(a, b):
return bytes(x ^ y for x, y in zip(a, b))
# --- the three XOR shares of the embedded master secret -----------------
share_a = xor(rd(0x4D110, 32), rd(0x4D130, 32))
share_b = xor(rd(0x4D150, 32), rd(0x4D170, 32))
share_c = xor(rd(0x4D190, 32), rd(0x4D1B0, 32))
master = bytearray(xor(xor(share_a, share_b), share_c))
# FUN_180001280() -- anti-debug/tamper word, 0 on a clean run
tamper = int(sys.argv[1], 0) if len(sys.argv) > 1 else 0
for pos, sh in ((3, 0), (10, 8), (0x11, 16), (0x18, 24)):
master[pos] ^= (tamper >> sh) & 0xFF
master = bytes(master)
# --- session secret: SHA256 over the 7 accepted game events ------------
EVENTS = [ # (opcode, arg0 u16, arg1 u32)
(0x19, 1, 1),
(0x2D, 3, 0),
(0x43, 7, 0),
(0x58, 0x0F, 0),
(0x6E, 0x1F, 0),
(0x81, 0x3F, 0),
(0xB7, 0x7F, 0),
]
assert bytes(e[0] for e in EVENTS) == rd(0x4D108, 7), "opcode order mismatch"
stream = bytearray(rd(0x4D100, 8)) # "SCTR" + 01 00 00 00
for i, (op, a0, a1) in enumerate(EVENTS):
stream += struct.pack("<BBHI", op, i + 1, a0, a1)
session = hashlib.sha256(bytes(stream)).digest()
# --- HKDF-SHA256 --------------------------------------------------------
def hkdf(salt, ikm, info, length):
prk = hmac.new(salt, ikm, hashlib.sha256).digest()
out, t = b"", b""
for i in range(1, -(-length // 32) + 1):
t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
out += t
return out[:length]
salt = rd(0x41090, 32)
info = rd(0x41068, 21) + rd(0x41080, 16) # "stormbound/reward/v2\0" + 16 B
key = hkdf(salt, master + session, info, 32)
nonce_prefix = rd(0x410B0, 8)
aad_prefix = rd(0x410B8, 58)
aes = AESGCM(key)
# --- replay the 1024 reward steps --------------------------------------
W, H = 0x158, 0xC0
canvas = [[0] * W for _ in range(H)]
x, y = 0x2D, 0x3D
spans = 0
for idx in range(0x400):
ct = rd(0x41100 + idx * 0x30, 0x30)
nonce = nonce_prefix + struct.pack(">I", idx)
aad = aad_prefix + struct.pack("<I", idx) + b"\x00\x04" + b"\x00\x00"
try:
pt = aes.decrypt(nonce, ct, aad)
except Exception as e:
print(f"step {idx}: auth FAILED ({e})")
sys.exit(1)
op = pt[0]
if op == 0x39: # draw span
dx, dy, dx2 = struct.unpack("<hhh", pt[2:8])
x0 = dx + x
ny = dy + y
x1 = dx2 + x0
if not (0 <= x0 - 0x2D < 0xFE and 0 <= ny - 0x3D < 0x2B
and 0 <= x1 - 0x2D < 0xFE):
print(f"step {idx}: out of bounds x0={x0} x1={x1} y={ny}")
break
step = 1 if x0 <= x1 else -1
for px in range(x0, x1 + step, step):
canvas[ny][px] = 1
spans += 1
x, y = x1, ny
elif op == 0x5D: # no-op
pass
elif op == 0xA6: # frame marker
pass
elif op == 0xE3: # terminator
print(f"step {idx}: terminator reached ({spans} spans drawn)")
break
else:
print(f"step {idx}: unknown opcode 0x{op:02x}")
break
print("master :", master.hex())
print("session :", session.hex())
print("key :", key.hex())
# --- render -------------------------------------------------------------
rows = [r for r in range(H) if any(canvas[r])]
cols = [c for c in range(W) if any(canvas[r][c] for r in range(H))]
r0, r1 = min(rows), max(rows)
c0, c1 = min(cols), max(cols)
print(f"ink bbox: x {c0}..{c1} y {r0}..{r1}")
art = ["".join("#" if canvas[r][c] else "." for c in range(c0, c1 + 1))
for r in range(r0, r1 + 1)]
with open("reward.pbm", "w") as f:
f.write(f"P1\n{c1 - c0 + 1} {r1 - r0 + 1}\n")
for row in art:
f.write(row.replace("#", "1").replace(".", "0") + "\n")
def write_png(path, art_rows, scale=6):
"""Minimal RGB PNG writer -- no external imaging library needed."""
fg, bg = b"\xff\xff\xff", b"\x10\x14\x20"
raw = b""
for row in art_rows:
line = b"\x00" + b"".join((fg if ch == "#" else bg) * scale
for ch in row)
raw += line * scale
def chunk(tag, data):
body = tag + data
return (struct.pack(">I", len(data)) + body
+ struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF))
hdr = struct.pack(">IIBBBBB",
len(art_rows[0]) * scale, len(art_rows) * scale,
8, 2, 0, 0, 0)
with open(path, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", hdr)
+ chunk(b"IDAT", zlib.compress(raw, 9))
+ chunk(b"IEND", b""))
def split_lines(art_rows):
"""Split the artwork into text lines on fully blank rows."""
out, cur = [], []
for row in art_rows:
if "#" in row:
cur.append(row)
elif cur:
out.append(cur)
cur = []
if cur:
out.append(cur)
return out
def split_glyphs(line):
"""Split one text line into glyphs on blank columns."""
width = len(line[0])
used = [c for c in range(width) if any(r[c] == "#" for r in line)]
groups, start, last = [], None, None
for c in used:
if start is None:
start = last = c
continue
if c - last > 1:
groups.append((start, last))
start = c
last = c
groups.append((start, last))
return [(a, b, tuple(r[a:b + 1] for r in line)) for a, b in groups]
text_lines = split_lines(art)
write_png("flag_full.png", art)
for i, line in enumerate(text_lines):
used = [c for c in range(len(line[0])) if any(r[c] == "#" for r in line)]
write_png(f"flag_line{i}.png", [r[min(used):max(used) + 1] for r in line])
print(f"wrote reward.pbm, flag_full.png, "
f"{', '.join(f'flag_line{i}.png' for i in range(len(text_lines)))}")
# --- OCR ----------------------------------------------------------------
# Glyph table lifted from this very rendering. Note that 'l' (2 px wide,
# with a foot) and '1' (3 px wide, with a flag) are distinct -- reading the
# image by eye gets that wrong, which is exactly why this table exists.
FONT = {
'.....|.###.|##.##|#...#|#...#|#...#|#...#|##.##|.###.|.....': '0',
'...|.##|#.#|..#|..#|..#|..#|..#|..#|...': '1',
'.....|.###.|#...#|....#|..##.|....#|#...#|#...#|.###.|.....': '3',
'.....|.####|#....|#....|#.##.|##..#|....#|#...#|.###.|.....': '5',
'.....|####.|#...#|#...#|#..#.|#####|#...#|#...#|####.|.....': 'B',
'......|#....#|#....#|#....#|#....#|######|#....#|#....#|#....#|......': 'H',
'.....|#####|..#..|..#..|..#..|..#..|..#..|..#..|..#..|.....': 'T',
'.....|.....|.....|.....|.....|.....|.....|.....|.....|#####': '_',
'....|....|....|.###|#...|#...|#...|#...|.###|....': 'c',
'.....|....#|....#|.####|#...#|#...#|#...#|#...#|.####|.....': 'd',
'..#|.#.|.#.|###|.#.|.#.|.#.|.#.|.#.|...': 'f',
'.....|#....|#....|####.|##..#|#...#|#...#|#...#|#...#|.....': 'h',
'..|#.|#.|#.|#.|#.|#.|#.|##|..': 'l',
'.....|.....|.....|####.|##..#|#...#|#...#|#...#|#...#|.....': 'n',
'...|...|...|###|#..|#..|#..|#..|#..|...': 'r',
'...|...|...|###|#..|##.|..#|#..|###|...': 's',
'...|.#.|.#.|###|.#.|.#.|.#.|.#.|.##|...': 't',
'.....|.....|.....|#...#|#...#|#...#|#...#|#..##|.####|.....': 'u',
'.....|.....|.....|#...#|#...#|.#.#.|.#.#.|.#.#.|..#..|.....': 'v',
'........|........|........|#..##..#|#..##.#.|.#.##.#.|.##.#.#.|'
'.##..##.|..#..#..|........': 'w',
'##|#.|#.|#.|#.|#.|#.|#.|#.|##': '{',
'##|.#|.#|.#|.#|.#|.#|.#|.#|##': '}',
}
flag, unknown = "", []
for line in text_lines:
for a, _b, glyph in split_glyphs(line):
ch = FONT.get("|".join(glyph))
if ch is None:
unknown.append((a, glyph))
ch = "?"
flag += ch
print()
print("FLAG:")
print(flag)
if unknown:
print(f"\n{len(unknown)} unknown glyph(s) -- font table incomplete:")
for a, glyph in unknown:
print(f" bei x={a}")
for r in glyph:
print(" " + r)
# --- ASCII preview, wrapped so it survives an 80 column terminal --------
CHUNK = 72
print()
for lo in range(0, len(art[0]), CHUNK):
print(f"--- columns {lo}..{min(lo + CHUNK, len(art[0])) - 1} ---")
for row in art:
print(row[lo:lo + CHUNK])
print()
$ upx -d -o core_unpacked.dll core_upx.dll
$ python3 solve.py
step 1023: terminator reached (532 spans drawn)
master : 2c4e84f58c3ccf653bfe7606ce11e5990736e9f9307da527bc1774a41e374ae3
session : 58c1fd695c617b299de3cf4608fcc910e581b0334ff8ddc9726623862c427386
key : 5bc4a0b3edffe8cdd55b47d6f00d4455473dd63ffd90f2dc9b3e70ae98ad61bf
ink bbox: x 49..295 y 72..93
wrote reward.pbm, flag_full.png, flag_line0.png, flag_line1.png
FLAG:
HTB{wh03v3r_h0ld5_th3_cr0wn_s3t5_th3_rul35_f0r_th15_w0r1d}
Followed by an ASCII preview of the rendered image, wrapped to 72 columns:
--- columns 0..71 ---
........................##..............................................
#....#...#####...####...#............#........###...###...........###...
#....#.....#.....#...#..#............#.......##.##.#...#.........#...#..
#....#.....#.....#...#..#..#..##..#..####....#...#.....#..#...#......#..
#....#.....#.....#..#...#..#..##.#...##..#...#...#...##...#...#....##...
######.....#.....#####..#...#.##.#...#...#...#...#.....#...#.#.......#..
#....#.....#.....#...#..#...##.#.#...#...#...#...#.#...#...#.#...#...#..
#....#.....#.....#...#..#...##..##...#...#...##.##.#...#...#.#...#...#..
#....#.....#.....####...#....#..#....#...#....###...###.....#.....###...
........................##..............................................
The solver is deterministic - the anti-debug word FUN_180001280() is 0 on a
clean run, so no game process and no debugger evasion is involved. It can be passed as
argv[1] if the value were ever non-zero.
HTB{wh03v3r_h0ld5_th3_cr0wn_s3t5_th3_rul35_f0r_th15_w0r1d}
| # | Stage | Mechanism |
|---|---|---|
| 1 | Strip the packaging | Godot export with a renamed PCK magic (SCX1 for GDPC) and
a UPX-packed GDExtension with renamed markers (SCN! for
UPX!, .slt0-2 for UPX0-2). Rename back,
upx -d, decompile. |
| 2 | Locate the reward path | ChallengeCore::render_reward_step decrypts 1024 x 48-byte records with
AES-256-GCM and replays them as spans into an Image. The flag exists only as
drawing commands. |
| 3 | Rebuild the key | master = three XOR shares from .rdata; session = SHA256 over the seven
fixed game events; key = HMAC-based Key Derivation Function (HKDF) over both. Every input is in the DLL, so the
game never has to be played. |
| 4 | Decrypt correctly | Nonce counts big-endian, AAD counts little-endian. Index 0 authenticates under either reading - index 1 is where a wrong guess shows up. |
| 5 | Render and OCR | Replay 532 spans onto a 344x192 canvas, then match the glyphs against a table
instead of reading them by eye - l and 1 are genuinely
different here. |
What makes this one a GamePwn challenge rather than plain reversing is the premise: the reward is gated behind playing the game correctly. But the seven "accepted events" are hardcoded constants, so the session secret is a constant too. Once that is clear, the game is irrelevant - the key can be built from the DLL alone, on a machine that cannot even run the executable.