SaltCrown
SaltCrown
Platform: CUSTOM | Category: Reversing | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: il_decompilation, native_gdextension_reversing, semantic_clue_mapping, seed_bruteforce, flag_format_constraint
Summary
Walkthrough of the CUSTOM Reversing SaltCrown. Task: ~200MB Godot 4.7 C#/Mono Android APK; reverse the game to recover a flag guarded by a native GDExtension custom hash and a decoy ciphertext file. Solution: skip the ashvault.dat red herring, rea
Description
Alyss has threaded the dead of Crownspire to the city bell. It strikes, they step, all together. You play Elric Ashspar, an engineer with no weapon and no way to fight them. The bell's strike-plate is a counterfeit, and counterfeits fail under repetition. Break the march, force the bell to work, and read the failure.
Distributed as a large (~200 MB) Android APK: SaltCrown.apk. The goal is to reverse the game and recover the HTB{...} flag. The theme wording is a deliberate map to the win condition: a counterfeit strike-plate fails under repetition, and we must read the failure.
Overview
The APK is a Godot 4.7 game built with C#/Mono. The flag is produced by C# game logic in SaltCrown.dll, not by any scene or .pck asset. A prominent 256-byte data file (ashvault.dat) looks like an encrypted flag with keystream reuse, but it is a red herring tied to the in-game rhythm mechanic. The real flag logic lives in the Forge() code path that runs when the "counterfeit" strike-plate fractures.
Recon
Key components inside the APK:
classes.dex— Godot Android launcher.assets/.godot/mono/publish/{x86_64,arm64}/SaltCrown.dll— the C# game logic (flag logic lives here).- Native GDExtension:
lib/{x86_64,arm64-v8a}/libashvault.android.template_release.*.soassets/native/ashvault.gdextension— entrysaltcrown_library_init, registers Godot classsaltcrown::AshVaultwith bound methodadmit_bucket.
assets/rubbings/ashvault.dat— a 256-byte data file (the decoy).
Decompile the C# DLL to IL (no ilspycmd available, but ikdasm ships with Mono):
bash read-only • copy allowedikdasm SaltCrown.dll > SaltCrown.il
The Decoy (ashvault.dat)
ashvault.dat has strong, suspicious structure:
C[i] ^ C[i+128] == 0x20for alli.C[i] ^ C[i+64]takes only 8 distinct values (block-structured).- Visible ASCII fragments:
AB{,B{folQ,[VUT,Lab[.
This screams keystream reuse / many-time pad, and it matches the theme phrase "counterfeits fail under repetition." The obvious front-door reading is flag = ashvault.dat XOR keystream.
The native admit_bucket was fully reversed to understand the "keystream":
- Phase 1 (FNV-1a style): seed
0x811c9dc5, prime0x1000193, building a 64-dword state from the input bytes. - Phase 2: 4096 mixing rounds using constants
0x85ebca77,0xc2b2ae3d,0x9e3779b9(murmur/xxhash/splitmix finalizer constants) withrol7/rol27/rol13. - Phase 3: selects an output byte via indices
(7*choke+3)&0x3fand(23*choke+41)&0x3f,rol11 ^, then(x>>5) ^ (x>>13). Yields a period-64 keystream.
Every XOR reconstruction attempt failed:
- period 16/32/64 repeating XOR
- 2-time / many-time pad
- keystream regenerated from the ciphertext
- fixed-seed keystream
- fixed-point iteration
Conclusion: admit_bucket on the file is the in-game rhythm mechanic (PhaseBucket vs BeatBucket, EngagesCleanly tolerance 8), not the flag cipher. The visible 256-byte file is bait — the classic front-door fixation trap. Verify the trust boundary before committing to a many-time-pad crack.
Finding the Real Flag Logic (Forge / Unseal / Mix / SealedSpec)
The theme says to read the failure. In SaltCrown.dll, the "failure" flow is:
StrikePlateaccumulates fatigue and fires theFracturedsignal.Director.OnFractured()runs: "The strike-plate splits... it could not bear repetition."- It calls
Director.Forge().
Forge() computes the flag:
csharp read-only • copy allowedacc = 0x811c9dc5; foreach (ShardSeat seat in seats ordered by ChokeIndex) { // 8 seats, choke 0..7 acc = SaltCrownSpec.Measure(acc, seat.ChokeIndex, PhaseBucket(seat.ChokeIndex)); } Crown = SaltCrownSpec.Unseal(acc); // the flag string
SaltCrownSpec (IL lines ~19667–19800):
Frame = "HTB",Unmeasured = 0x811C9DC5Mix(h,v):h^=v; h*=0x1000193; h^=h>>15; h*=0x85ebca77; h^=h>>13(murmur-ish 32-bit)Measure(acc,choke,bucket) = Mix(Mix(acc,choke), bucket)SealedSpec— 24-byte static array at RVAI_0001C290:
75 C9 AB 6B 9A 53 CF BF 1F E9 7E 4A 93 94 25 E0 29 CF 87 A9 C2 80 DE DC
Unseal(measured): foriin0..23:measured = Mix(measured, i);out[i] = SealedSpec[i] ^ (byte)(measured >> 24); returns"HTB{" + ASCII(out) + "}".
So the entire flag is a deterministic function of a single 32-bit accumulator acc. The only unknown is the final acc, which depends on the eight PhaseBucket / admit_bucket values.
Recovering acc via brute force
A from-scratch Python reimplementation of native admit_bucket phase 2 had a subtle discrepancy → wrong buckets → wrong acc → garbled flag. Rather than perfectly reproduce the native code, exploit the structure:
Unseal(acc) is a deterministic 32-bit-seeded stream, and the flag body must be printable ASCII in the flag charset. So brute-force the entire 2^32 acc space in C, keeping candidates whose 24 Unseal bytes are all printable. Exactly one hit:
acc = 0x75f944d2→HTB{REDACTED}- Runtime ~30 s single-threaded.
brute_acc.c:
c read-only • copy allowed#include <stdio.h> #include <stdint.h> static const uint8_t Sealed[24] = { 0x75,0xC9,0xAB,0x6B,0x9A,0x53,0xCF,0xBF, 0x1F,0xE9,0x7E,0x4A,0x93,0x94,0x25,0xE0, 0x29,0xCF,0x87,0xA9,0xC2,0x80,0xDE,0xDC }; static inline uint32_t Mix(uint32_t h, uint32_t v) { h ^= v; h *= 0x1000193u; h ^= h >> 15; h *= 0x85ebca77u; h ^= h >> 13; return h; } int main(void) { for (uint64_t a = 0; a <= 0xffffffffull; a++) { uint32_t m = (uint32_t)a; uint8_t out[24]; int ok = 1; for (int i = 0; i < 24; i++) { m = Mix(m, (uint32_t)i); uint8_t c = Sealed[i] ^ (uint8_t)(m >> 24); /* printable flag-charset constraint */ if (c < 0x20 || c > 0x7e) { ok = 0; break; } out[i] = c; } if (ok) { printf("acc=0x%08x -> HTB{", (uint32_t)a); fwrite(out, 1, 24, stdout); printf("}\n"); } } return 0; }
Build and run:
bash read-only • copy allowedgcc -O2 -o brute_acc brute_acc.c ./brute_acc # acc=0x75f944d2 -> HTB{...}
Verification (Python) — the exact Mix / Unseal reimplementation:
python read-only • copy allowedM = 0xffffffff def Mix(h, v): h &= M; v &= M h = (h ^ v) & M; h = (h * 0x1000193) & M; h = (h ^ (h >> 15)) & M h = (h * 0x85ebca77) & M; h = (h ^ (h >> 13)) & M return h Sealed = bytes.fromhex("75C9AB6B9A53CFBF1FE97E4A939425E029CF87A9C280DEDC") def Unseal(m): out = bytearray(24) for i in range(24): m = Mix(m, i) out[i] = Sealed[i] ^ ((m >> 24) & 0xff) return "HTB{" + out.decode() + "}" print(Unseal(0x75f944d2)) # -> the flag
Lessons
- The real logic is in the Mono DLL. In Godot C# games, decompile
SaltCrown.dllwithikdasm/ILSpy — not the.pckscenes or the native.sofirst. - A prominent encrypted-looking data file can be a decoy.
ashvault.datmapped to a game mechanic (rhythm buckets), not the flag cipher. Verify the trust boundary before committing to a many-time-pad crack. - Map the theme to the win condition. "Counterfeit fails under repetition" = the
Fracturedevent; "read the failure" = theForge()code path that runs on fracture. The semantic clues pointed exactly at the winning path. - A small deterministic seed space beats a perfect reimplementation. When a reimplemented native hash produces wrong intermediates, a 32-bit
accseed plus a printable flag-format constraint is trivially brute-forceable — no need to reproduce the native code byte-for-byte.
Key Takeaways / Lessons
- Mastering il_decompilation, native_gdextension_reversing, semantic_clue_mapping, seed_bruteforce, flag_format_constraint was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-07-27 • CUSTOM • SaltCrown • Difficulty: Medium