# SaltCrown | | | |---|---| | **Platform** | HackTheBox | | **Category** | Reverse engineering / Mobile | | **Flag** | `HTB{p3rf3ct_f4c3_wr0ng_sp1n3}` | | **Target** | `SaltCrown.apk` — Godot 4 game, Mono/C# runtime, ARM64 GDExtension (202 MB) | | **Techniques** | APK triage, ARM64 static analysis, custom hash reimplementation, .NET IL decompilation, dead-code reachability reasoning | | **Date** | July 2026 | > Cyber Apocalypse 2026 has concluded — flag included below. --- ## TL;DR A rhythm game ships its flag as a 24-byte XOR-sealed blob. The key is derived from five "shard" placements the player is meant to earn through timing-accurate gameplay. Reversing the native GDExtension recovered a custom 4096-round hash; decompiling the managed assembly recovered the key-folding routine and, critically, showed that **the set and order of the five inputs are fixed by static level geometry, not by the player**. The gameplay is a lock on a door whose contents are already determined by a 256-byte asset shipped inside the APK. The flag falls out of a script that never launches the game, never touches an emulator, and runs in under two seconds. --- ## Recon The APK is 202 MB, which is almost entirely engine. The interesting surface is small. ``` $ unzip -q SaltCrown.apk -d apk && find apk -type f | wc -l 520 ``` Filtering the managed assemblies down to anything that isn't BCL or engine leaves exactly one candidate: ``` $ find apk -name '*.dll' | grep -viE 'System\.|Microsoft\.|mscorlib|netstandard|Mono\.' apk/assets/.godot/mono/publish/arm64/GodotSharp.dll apk/assets/.godot/mono/publish/arm64/SaltCrown.dll <-- 109 KB, game logic ``` The asset tree is more informative than the binary count suggests: ``` assets/scripts/Mechanism/SaltCrownSpec.cs 1 byte assets/scripts/Mechanism/StrikePlate.cs 1 byte assets/scripts/Mechanism/Tolerances.cs 1 byte assets/scripts/Mechanism/WearLattice.cs 1 byte assets/rubbings/ashvault.dat 256 bytes assets/native/ashvault.gdextension lib/arm64-v8a/libashvault.android.template_release.arm64.so 428 KB ``` The `.cs` files are 1-byte placeholders — Godot compiles C# into the assembly and ships stubs. But the *filenames survive*, and they sketch the mechanism before a single instruction is read: a spec, a strike plate, a tolerance model, a wear lattice, and a 256-byte "rubbing" (the term for an impression taken from an engraving). There is also a native GDExtension, which is where a challenge author hides anything they don't want found in IL. ### String triage Extracting the UTF-16 `#US` heap from the managed assembly gives the narrative and the win condition: ``` 'Place your 5 shards at the exact beat.' 'It seats clean and does nothing. Perfect face -- wrong spine. Wrong beat.' 'The strike-plate splits. New metal, old geometry -- it could not bear repetition.' 'From the break, the failure mode. From the failure mode, a constraint.' 'Open ground. Nothing here to pinch -- the march just flows around it.' ``` No flag string, so it is computed at runtime. Names like `AshVault`, `admit_bucket`, `choke`, and `rubbing` appear in *both* binaries, which pins the managed/native boundary. ### The rubbing has structure ``` $ python3 -c "d=open('apk/assets/rubbings/ashvault.dat','rb').read(); print(all(a^b==0x20 for a,b in zip(d[:128], d[128:])))" True ``` The upper 128 bytes are the lower 128 XOR `0x20` — 256 bytes of file holding 128 bytes of entropy. Two impressions of the same engraving, which is a nice touch. It turned out to be flavour rather than a lever, but confirming it early ruled out a whole family of guesses about the file being a ciphertext. --- ## Reversing the native extension Only one symbol survives stripping: ``` $ readelf -sW libashvault.android.template_release.arm64.so | grep -v godot 0x000222ac 96 saltcrown_library_init ``` The C++ mangled type name `N9saltcrown8AshVaultE` confirms the class. To find its methods, cross-reference `.text` against the registration strings using ADRP/ADD page-relative pair resolution: ``` admit_bucket 0x005e30 refs: [] choke 0x002ae6 refs: ['0x2132c'] rubbing 0x00797b refs: ['0x21318'] ``` Those references sit at the very start of `.text` (which begins at `0x210d0`) — the class registration. Disassembling it reveals the binding: ```asm 0x021304 adr x1, #0x5e30 ; "admit_bucket" <- method name 0x021318 add x1, x1, #0x97b ; "rubbing" <- arg 0 0x02132c add x2, x2, #0xae6 ; "choke" <- arg 1 0x021350 adr x0, #0x21130 ; <- the implementation ``` Signature recovered: `admit_bucket(rubbing: PackedByteArray, choke: int) -> int`. ### The hash at 0x21130 Two constants give the first phase away immediately: ```asm 0x02116c mov w10, #0x193 0x021174 movk w10, #0x100, lsl #16 ; 0x01000193 FNV-1a prime 0x02117c mov w12, #0x9dc5 0x021188 movk w12, #0x811c, lsl #16 ; 0x811C9DC5 FNV-1a basis ``` Phase 1 builds a 64-word state. Slot `i` is an FNV-1a pass over the entire rubbing with `i` added to every byte, plus an extra `h ^= h >> 11` per byte: ```asm 0x02118c ldrb w15, [x13], #1 0x021194 add w15, w15, w8 ; byte + slot index 0x021198 eor w12, w15, w12 0x02119c mul w12, w12, w10 0x0211a0 eor w12, w12, w12, lsr #11 ``` Phase 2 is the expensive part, and the one the scenario text is describing when it says counterfeits fail under repetition: ```asm 0x021240 cmp w9, #1, lsl #12 ; 4096 rounds ``` Each round rewrites all 64 words from their neighbours using murmur3's finalisation constants (`0x85EBCA77`, `0xC2B2AE3D`) with the golden-ratio constant `0x9E3779B9` as a per-round counter. Phase 3 picks two slots by index and folds them to a byte: ```asm sub w8, w8, w19 ; choke*8 - choke = choke*7 add w8, w8, #3 ; slot A = (choke*7 + 3) & 63 madd w9, w19, w9, w10 ; slot B = (choke*23 + 41) & 63 eor w8, w8, w9, ror #21 eor w8, w9, w8, lsr #5 and w0, w8, #0xff ``` **The structural observation that makes this cheap:** phases 1 and 2 depend only on the rubbing. `choke` enters at phase 3. So the 262,144-operation grind runs *once*, and every subsequent byte is two array reads. What was designed as a per-call cost becomes a one-time setup. ### Dead end The obvious first move — run the reimplemented hash over the rubbing for `choke = 0..127` and look for a flag — produces noise with period 64: ``` 955468b21a0665ea59a832f9bf8c437f8fabc622b5290645... .Th...e.Y.2...C....".).E.G[M,5..f#....|.?O.....Q< ``` Correct implementation, wrong question. The hash is a *primitive*, not the answer. Which meant the managed side had to be read properly. --- ## Decompiling the managed assembly No ILSpy or .NET SDK in the analysis environment, so the disassembler was built on `dnfile` (metadata tables) plus `dncil` (CIL decoding), with token resolution against the `#US` and `#Strings` heaps — about 100 lines, and reusable against any .NET target. Four methods carry the entire derivation. **`WearLattice.AdmitBucket(i)`** — instantiates the native `AshVault`, loads `res://rubbings/ashvault.dat`, and calls straight through. Notably it copies the rubbing into a fresh array of identical length and passes it unmodified, which kills the theory that gameplay mutates the input. **`Tolerances.SeatsAtChoke`** — iterates 8 slots and rejects any where the corridor is too wide to pinch: ``` ChokeZ(i) = 14.0 - i * 6.0 HalfWidthAt(z) = Lerp(9.5, 5.2, Clamp(InverseLerp(14, -40, z), 0, 1)) accept iff HalfWidthAt(ChokeZ(i)) <= 8.1 ``` | slot | z | half-width | | |---|---|---|---| | 0 | 14.0 | 9.500 | rejected | | 1 | 8.0 | 9.022 | rejected | | 2 | 2.0 | 8.544 | rejected | | 3 | −4.0 | 8.067 | **accepted** | | 4 | −10.0 | 7.589 | **accepted** | | 5 | −16.0 | 7.111 | **accepted** | | 6 | −22.0 | 6.633 | **accepted** | | 7 | −28.0 | 6.156 | **accepted** | Exactly five, which is why the HUD says five shards. `'Open ground. Nothing here to pinch'` is the rejection branch, verbatim. **`Director.Forge()`** — folds the biting seats into a 32-bit key: ```csharp uint h = 0x811C9DC5; foreach (var s in _seats.Where(s => s.Bites).OrderBy(s => s.ChokeIndex)) h = Measure(h, s.ChokeIndex, Tolerances.PhaseBucket(s.ChokeIndex)); Crown = SaltCrownSpec.Unseal(h); ``` **`SaltCrownSpec.Unseal(key)`** — keystream XOR over a 24-byte static array (RVA `0x1c290`, recovered from the `FieldRVA` table): ```csharp uint h = key; for (int i = 0; i < SealedSpec.Length; i++) { h = Mix(h, i); out[i] = (byte)(SealedSpec[i] ^ (byte)(h >> 24)); } return "HTB{" + Encoding.ASCII.GetString(out) + "}"; ``` ### The reachability argument `Forge` iterates seats where `Bites` is true, ordered by `ChokeIndex`. Winning the game means all five bite. The five placeable indices are fixed by geometry. Therefore the iteration is always exactly `[3, 4, 5, 6, 7]` in that order, in every successful run. The rhythm mechanic (`Tolerances.EngagesCleanly`, which requires `BucketDistance(BeatBucket, PhaseBucket) <= 8`) decides *whether you reach `Forge`*. It contributes nothing to `h`. It is a gate, not an input. Everything feeding the flag is therefore static, and the solver is arithmetic: ``` PhaseBucket(3) = 0xb2 key = 0x811c9dc5 (initial) PhaseBucket(4) = 0x1a key = 0x28ec5230 after seat 3 PhaseBucket(5) = 0x06 key = 0x20ef93a6 after seat 4 PhaseBucket(6) = 0x65 key = 0x342108d7 after seat 5 PhaseBucket(7) = 0xea key = 0x9fc192c3 after seat 6 key = 0x75f944d2 after seat 7 HTB{p3rf3ct_f4c3_wr0ng_sp1n3} ``` All 24 bytes decode to clean printable ASCII — that is the correctness check. A wrong key yields noise, and the odds of 24 accidental printable characters are negligible. The plaintext also matches a string already sitting in the assembly's `#US` heap, describing a part that seats perfectly and does nothing. --- ## Lessons & defenses **A native extension is obfuscation, not protection.** Moving `admit_bucket` into a stripped ARM64 `.so` cost one symbol-recovery step. The class name survived in the C++ mangling, the method and argument names survived as registration strings in `.rodata`, and the algorithm was identifiable from two well-known constants. Anything that must run on the client is recoverable from the client; the only real defense is server-side validation, where the secret never ships. **Client-side gates protect nothing when the secret is local.** The rhythm mechanic is a genuinely well-built challenge, and it guards a value that was already in the APK. If a check gates access to a locally-derivable secret, the check is bypassable by construction — an attacker computes the secret and ignores the gate. This is the same class of error as hiding an API key behind a login screen in a mobile app. **Expensive-by-design hashes need the cost on the attacker's critical path.** The 4096 rounds were presumably meant to make brute force painful. But the round function is independent of the selector, so the cost amortises to zero across outputs. A stretching construction only works when the expensive part depends on the thing being varied — the reason PBKDF2 iterates over the password and salt together rather than deriving a reusable state first. **Shipping unused metadata leaks structure.** The 1-byte `.cs` stubs carry no code but preserve `StrikePlate`, `WearLattice`, `Tolerances`, and `SaltCrownSpec` as filenames, and the in-game narration strings map one-to-one onto code branches. Both gave a reliable map of the mechanism before any disassembly. Stripping build artefacts and moving player-facing text into a localisation table would have cost an attacker real time. --- ## Tools used | Tool | Purpose | |---|---| | `unzip` / `pyelftools` | APK triage and ELF section/symbol enumeration | | `capstone` | ARM64 disassembly and ADRP/ADD cross-reference resolution | | `dnfile` + `dncil` | .NET metadata parsing and CIL decoding — a pure-Python stand-in for ILSpy | | `ildump.py` | Custom IL disassembler with `#US`/`#Strings` token resolution | | `ashvault.py` | Reimplementation of the native hash, with the phase-1/2 grind hoisted out | | `solve_saltcrown.py` | End-to-end solver: geometry filter, key fold, keystream XOR |