[THUGS(red)]

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-writeup.md 12 KiB

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.