SaltCrown
The flag's derivation depends only on static level geometry, not player input, so it was computed entirely offline without ever running the game.
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:
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:
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:
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:
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:
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:
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):
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 |
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.