The Salt Crown
Series finale: peeled three nested encryption layers off a custom PE-overlay container, then used a safe client-side HP freeze to survive the legitimate 7-step win sequence.
The Salt Crown — Cyber Apocalypse 2026 (Game Pwn, series finale)
| | |
|---|---|
| Challenge | The Salt Crown |
| Platform | HackTheBox — Cyber Apocalypse 2026 ("The Salt Crown") |
| Category | Game Pwn — series finale (Cassian / Registry Altar chapter) |
| Target | Windows Godot 4.7 build — The_Salt_Crown.exe (~70 MB) + challenge_core.windows.template_release.x86_64.dll (208 KB GDExtension) |
| Techniques | Custom PE-overlay container reversing, Unicorn-emulated unpacking of a modified UPX stub, Godot FileAccessEncrypted (AES-256-CFB) decryption, .gdc bytecode decompilation, HKDF→AES-256-GCM key recovery bound to a hashed game-state transcript, Cheat Engine survivability assist |
| Date | July 2026 |
> Cyber Apocalypse 2026 has concluded. The real flag string wasn't captured in any session record on my end — drop it in from your own notes before publishing.
---
TL;DR
The finale ships two sealed artifacts: the exe itself, wrapped in a custom SCX1 PE-overlay
container instead of Godot's usual GDPC pack header, and a GDExtension DLL that's UPX-packed
with its magic scrubbed and sections renamed (.slt0/.slt1/.slt2). Three nested encryption
layers stand between "downloaded exe" and "readable assets" — all three fully broken offline.
The flag itself is computed at runtime by a native render_reward_step routine once a strict
7-opcode in-game sequence completes; the shipped GDScript can only ever emit one of those
opcodes, making the game unwinnable by normal play. Freezing the player character's
client-side HP in Cheat Engine supplies enough survivability to complete the legitimate
sequence anyway, without touching anything the DLL treats as authoritative.
---
Recon — two sealed artifacts
The_Salt_Crown.exe ~70 MB, patched Godot 4.7 template
challenge_core.windows.template_release.x86_64.dll 208 KB, GDExtension
Neither unpacks with stock tooling. The DLL's UPX magic (UPX!) has been scrubbed and its
sections renamed away from the standard UPX0/UPX1, so upx -d refuses it outright. The
exe's asset pack isn't Godot's usual GDPC-headed structure at all — it's wrapped in a
custom SCX1 footer appended as a PE overlay.
Layer 1 — the SCX1 PE overlay
AES-256-CTR. Key = SHA-256 of a 32-byte blob sitting at .data offset 0x3ecb6f0; counter
= nonce || BE64(block_index). Peeling this off the overlay recovers the raw Godot pack
bytes.
Layer 2 — the Godot pack directory
AES-256-CFB, but keyed with the raw 32-byte blob this time (not its SHA-256), and called
with p_with_magic=false — so the usual GDEC header Godot normally prepends is absent.
Fields: md5 | length | iv.
Layer 3 — per-file FileAccessEncrypted
Same key/mode as layer 2. Stored size is 40 + round_up(size, 16). Peeling all three layers
recovered and MD5-verified all 81 shipped assets, and decompiled all 10 GDScript .gdc
files (GDSC v101, zstd-compressed token buffer, identifiers XOR'd against 0xB6B6B6B6,
Variant type 21 = StringName).
Unpacking the native extension
The DLL's unpacking stub is an NRV2-family decompressor, emulated directly under Unicorn
to dump the unpacked module (.slt0, valid DllMain at RVA 0x22c44). The unpacked module
has essentially no readable strings — everything meaningful is built or decrypted at
runtime, confirmed by the extension importing BCryptDecrypt.
Why the shipped game is unwinnable by normal play
Decompiling the recovered .gdc bytecode gives the strict opcode chain the win condition
requires:
6563 (damage, >=1 restoration)
-> 11377 (storm_circuit = BOUND_TO_GATE)
-> 3906 (tide_sluice = OPEN)
-> 25528 (brine = FLOODED, sets current_epoch_invalid)
-> 20958 (voluntary_quorum_lost, zeroes witness_count)
-> 14245 (alternative_acclamation -- needs shared_breath_covenant set)
-> 29380 (SEVER -- requires all three covenant flags, sets
claim_severed / gate_passage_unlocked / finale_reached)
Opcode 17649 is a decoy that only sets a cosmetic gate_visual_open flag. Critically, the
shipped GDScript's submit_event only ever emits opcode 6563 (_OP_DAMAGE_CLAIMANT) — the
rest of the chain is structurally unreachable through legitimate play as shipped.
The intended (in-game) solution
Playing the build reveals what the bytecode implies:
- The HUD's "COVENANT CONDITIONS" line (`EPOCH INVALID / QUORUM LOST / ALTERNATIVE
- Five "witness memories" (
MOTHERS_FACE,THE_BROTHEL_ROOM,FIRST_KINDNESS, - The full sequence — all five memories consumed, all three covenant flags satisfied, then
- Cheat Engine 7.7 (installed after routing around a Defender PUA block) froze the
- With survivability solved, the legitimate 7-step sequence completed in one session, and
ACCLAMATION) is a requirement list, not progress — it reads 0/3` throughout, even
while conditions are being satisfied.
BIRTH_NAME, REASON_FOR_POWER) are consumed one at a time by repeatedly defeating the
boss, Cassian. Each time his HP hits 0000/5000, a scripted "death disputed / THE
WITNESSED CANNOT DIE" rollback fires: one memory is consumed and Cassian is restored to
full HP. A diamond HUD counter tracks memories remaining, and that progress survives
the rollback even though HP does not.
a final kill that fires SEVER — has to complete inside one continuous authoritative
session.
player character's own HP. Safe specifically because the player's HP is client-side
GDScript state, never part of the DLL's authoritative public-state fields — freezing it
can't corrupt the transcript or trigger REWARD_AUTH_FAILED. The boss's mirrored HP
field (which does map into claimant_hp inside the DLL) and CE's debugger-attached
features (breakpoints, "find what writes to address") were both deliberately avoided,
given the extension's known anti-debug protections.
render_reward_step rasterized the reward records (a 1024x48-byte buffer folded down to a
344x192-byte constraint record) into the flag.
Native state struct (recovered offsets, for reference)
0x00 storm_circuit 0x1c claim_severed 0x40 gate_visual
0x04 tide_sluice 0x2c witness_count 0x44 gate_passage
0x08 brine 0x30 hp 0x48 finale
0x0c shared_breath 0x34 restoration_count 0x4c decoy
0x10 epoch_invalid 0x38 memory_erasures
0x14 quorum_lost 0x3c severance_attempts
0x18 acclamation
Lessons & defenses
- **Client-authoritative and server(-equivalent)-authoritative state need to be clearly
- **A custom container format (
SCX1instead of standardGDPC) raises the cost of casual - **Anti-debug protections on the native module make static analysis (Unicorn emulation of
separated in memory, not just in intent.** The entire safe Cheat Engine path here existed
because player HP happened to live outside the fields the native extension treats as
authoritative. If it hadn't, this exact "safe" cheat would have corrupted the win
transcript instead of merely aiding survivability.
asset extraction but doesn't change the fundamental exposure** once an attacker is willing
to emulate the unpacking stub — obscurity bought time, not security.
just the unpacker) the efficient path**, not attaching a live debugger.
Tools used
| Tool | Purpose |
|---|---|
| Unicorn | Emulate the modified UPX/NRV2 unpacking stub to dump the real module |
| Ghidra | Analyze the unpacked ChallengeCore extension |
| Python (hashlib, struct, AES) | Reimplement all three encryption layers offline |
| Custom .gdc decompiler | Recover readable GDScript source from the zstd token-buffer bytecode format |
| Cheat Engine 7.7 | Freeze client-side player HP for survivability during the intended in-game route |
Something wrong with this page?
Wrong details, a stolen writeup, or something that should not be published here — tell a moderator. This does not go to the author.