HTB Cyber Apocalypse 2026 - The Salt Crown: GamePwn / The Salt Crown (Hard)

Author: Hubert Feyrer / hubertf - 31.07.2026

Challenge description

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.

1. Download

$ 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.

2. Docker/nc - what we get

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.

3. Analysis steps

3.1 Upload it to a sandbox (failed)

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.

3.2 Open the PCK archive (failed, and not needed)

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.

3.3 Unpack the DLL (success)

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.

3.4 Find the reward mechanism (success)

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.

3.5 Reconstruct the key derivation (success)

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:

#opcodearg0arg1
00x1911
10x2d30
20x4370
30x580x0f0
40x6e0x1f0
50x810x3f0
60xb70x7f0

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.

3.6 The endianness trap (failed, then fixed)

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.

3.7 Replay the drawing commands (success)

1024 decrypted records of 32 bytes each, first byte an opcode.

bytemeaning
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
0x5dno-op
0xa6frame marker
0xe3terminator (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.

3.8 Read the flag off the image (failed, then fixed)

The image renders, the flag is legible, done - except it is not.

The rendered reward image showing the flag in a bitmap font

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.

4. Solution

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()

5. Run it

$ 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.

Flag

HTB{wh03v3r_h0ld5_th3_cr0wn_s3t5_th3_rul35_f0r_th15_w0r1d}

6. Summary of how the exploit works

#StageMechanism
1Strip 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.
2Locate 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.
3Rebuild 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.
4Decrypt correctly Nonce counts big-endian, AAD counts little-endian. Index 0 authenticates under either reading - index 1 is where a wrong guess shows up.
5Render 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.