← Back to Writeups
CUSTOMMediumReversing

SaltCrown

XESXOR9/2/20266 min read
#reversing#custom#medium#il_decompilation#native_gdextension_reversing#semantic_clue_mapping#seed_bruteforce#flag_format_constraint

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.*.so
    • assets/native/ashvault.gdextension — entry saltcrown_library_init, registers Godot class saltcrown::AshVault with bound method admit_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
ikdasm SaltCrown.dll > SaltCrown.il

The Decoy (ashvault.dat)

ashvault.dat has strong, suspicious structure:

  • C[i] ^ C[i+128] == 0x20 for all i.
  • 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, prime 0x1000193, building a 64-dword state from the input bytes.
  • Phase 2: 4096 mixing rounds using constants 0x85ebca77, 0xc2b2ae3d, 0x9e3779b9 (murmur/xxhash/splitmix finalizer constants) with rol7/rol27/rol13.
  • Phase 3: selects an output byte via indices (7*choke+3)&0x3f and (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:

  1. StrikePlate accumulates fatigue and fires the Fractured signal.
  2. Director.OnFractured() runs: "The strike-plate splits... it could not bear repetition."
  3. It calls Director.Forge().

Forge() computes the flag:

csharp
acc = 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 = 0x811C9DC5
  • Mix(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 RVA I_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): for i in 0..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
#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
gcc -O2 -o brute_acc brute_acc.c
./brute_acc
# acc=0x75f944d2 -> HTB{...}

Verification (Python) — the exact Mix / Unseal reimplementation:

python
M = 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

  1. The real logic is in the Mono DLL. In Godot C# games, decompile SaltCrown.dll with ikdasm/ILSpy — not the .pck scenes or the native .so first.
  2. A prominent encrypted-looking data file can be a decoy. ashvault.dat mapped to a game mechanic (rhythm buckets), not the flag cipher. Verify the trust boundary before committing to a many-time-pad crack.
  3. Map the theme to the win condition. "Counterfeit fails under repetition" = the Fractured event; "read the failure" = the Forge() code path that runs on fracture. The semantic clues pointed exactly at the winning path.
  4. A small deterministic seed space beats a perfect reimplementation. When a reimplemented native hash produces wrong intermediates, a 32-bit acc seed 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