# 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 ACCLAMATION`) is a **requirement list, not progress** — it reads `0/3` throughout, even while conditions are being satisfied. - Five "witness memories" (`MOTHERS_FACE`, `THE_BROTHEL_ROOM`, `FIRST_KINDNESS`, `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. - The full sequence — all five memories consumed, all three covenant flags satisfied, then a final kill that fires `SEVER` — has to complete inside one continuous authoritative session. - **Cheat Engine 7.7** (installed after routing around a Defender PUA block) froze the 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. - With survivability solved, the legitimate 7-step sequence completed in one session, and `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 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. - **A custom container format (`SCX1` instead of standard `GDPC`) raises the cost of casual asset extraction but doesn't change the fundamental exposure** once an attacker is willing to emulate the unpacking stub — obscurity bought time, not security. - **Anti-debug protections on the native module make static analysis (Unicorn emulation of 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 |