Post

Mobile Category - HTB CyberApocalypse 2026

Detailed writeup for the whole Mobile Category of Cyber Apocalypse 2026.

Mobile Category - HTB CyberApocalypse 2026

HTB Cyber Apocalypse - Mobile Track Writeup

Challenges: Overstrike (easy) · Proofmark (medium) · SaltCrown (hard)

Mobile category

All three are Godot-engine Android games. None of them are really “games” - each one hides a cryptographic check that decides whether you win, and the intended solve is to find that check, understand its math, and then either invert it or compute the answer directly.

The same three-step shape applies to all three:

  1. Find where the win condition is actually computed.
  2. Read the exact math.
  3. Invert it, or brute-force the small unknown.

Overstrike

Summary

You play a character carrying a “Mark.” The world has a “True Seal.” The game constantly takes your Mark, runs it through a scrambler, and checks whether the scrambled result equals the True Seal.

Registry HUD

The trick: the scrambler the game uses is a well-known public algorithm that can be run backwards. So instead of hunting for the right Mark, you take the answer the game wants, run the scrambler in reverse, and it hands you the exact Mark you need. Then a second function uses that Mark as a decryption key to reveal the flag - and since we now know the Mark, we can just run that decryption ourselves on our own machine. The game never needs to be played.


Proof of Concept (Technical)

Step 1 - Identify the runtime

apktool d Overstrike.apk shows this is a Godot Mono (C#) build. The assets/scripts/*.cs files on disk are empty stubs, but the compiled managed assembly is shipped intact:

1
./Overstrike/assets/.godot/mono/publish/arm64/Overstrike.dll

So no native reversing needed - this opens cleanly in ILSpy.

ILSpy class tree

The class names match the empty stubs exactly: Archive, BridgeBuilder, GameState, MarkPickup, etc.

Step 2 - Read GameState

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public ulong CarriedMark;
public ulong WorldSeal;
public const ulong TrueSeal = 15682021040575554950uL;

public bool WorldIsAligned => WorldSeal == 15682021040575554950uL;

public override void _Process(double delta)
{
    WorldSeal = Mix(CarriedMark);          // recomputed every frame
}

public static ulong Mix(ulong x)
{
    long num  = (long)x + -7046029254386353131L;
    long num2 = (num  ^ (num  >>> 30)) * -4658895280553007687L;
    long num3 = (num2 ^ (num2 >>> 27)) * -7723592293110705685L;
    return (ulong)(num3 ^ (num3 >>> 31));
}

Converting those signed decimals to unsigned hex:

Signed decimal Unsigned hex
-7046029254386353131 0x9E3779B97F4A7C15
-4658895280553007687 0xBF58476D1CE4E5B9
-7723592293110705685 0x94D049BB133111EB

Those are the splitmix64 constants verbatim. splitmix64 is a bijection on 64-bit integers, so it has an exact inverse - no search required.

Step 3 - Confirm the intended path is impossible

MarkPickup.cs is how the Mark is supposed to grow:

1
2
3
4
5
6
7
8
9
10
[Export] public ulong Worth = 1uL;

private void OnBodyEntered(Node3D body)
{
    if (body is Player && GameState.Instance != null)
    {
        GameState.Instance.CarriedMark += Worth;   // small increments only
        QueueFree();
    }
}

Small additive pickups can never reach a ~1.5×10¹⁹ target. The value must be reached by computation, not by play.

Step 4 - Invert splitmix64

Each stage is individually invertible: multiplications are odd (so invertible mod 2⁶⁴ via modular inverse), and x ^= x >> k unwinds by iterating.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
MASK = (1 << 64) - 1

def inv_xorshift(z, shift):
    x = z
    for _ in range(64 // shift + 1):
        x = z ^ (x >> shift)
    return x & MASK

C1 = 0x9e3779b97f4a7c15
C2 = 0xbf58476d1ce4e5b9
C3 = 0x94d049bb133111eb
MOD = 1 << 64
inv_C2, inv_C3 = pow(C2, -1, MOD), pow(C3, -1, MOD)

TrueSeal = 15682021040575554950

def invert_splitmix64(out):
    z = out
    z = inv_xorshift(z, 31)
    z = (z * inv_C3) & MASK
    z = inv_xorshift(z, 27)
    z = (z * inv_C2) & MASK
    z = inv_xorshift(z, 30)
    return (z - C1) & MASK

print(invert_splitmix64(TrueSeal))
1
2
3
Recovered CarriedMark: 15549431037298259574
Hex: 0xd7caad24dd98b676
Forward check: 15682021040575554950 == TrueSeal? True

Step 5 - Decrypt the flag offline

UnsealRegistry() is a pure function of CarriedMark and a hardcoded 56-byte blob - a SHA-256 counter-mode keystream XORed against SealedRecord. Nothing runtime-dependent, so it can be reimplemented directly:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import hashlib, struct

CarriedMark = 15549431037298259574
SealedRecord = bytes([13,86,51,68,18,110,68,15,54,61,236,94,135,202,213,182,4,1,182,181,
    150,228,184,126,121,224,236,220,7,82,153,251,179,104,0,87,32,34,3,60,
    166,96,124,50,253,31,124,179,220,157,120,115,19,47,96,11])

def unseal_registry(mark):
    key = hashlib.sha256(struct.pack('<Q', mark)).digest()
    out = bytearray(len(SealedRecord))
    n = ctr = 0
    while n < len(SealedRecord):
        block = hashlib.sha256(key + struct.pack('<i', ctr)).digest()
        j = 0
        while j < len(block) and n < len(SealedRecord):
            out[n] = SealedRecord[n] ^ block[j]
            j += 1; n += 1
        ctr += 1
    return ''.join(chr(b) if 32 <= b < 127 else '▯' for b in out)

print(unseal_registry(CarriedMark))

Flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
┌──(kali㉿DESKTOP-B2E2TUG)-[/mnt/c/Users/adm_consultant/Downloads/mobile_overstrike]
└─$ cat > solve_overstrike.py << 'EOF'
import hashlib, struct
MASK = (1 << 64) - 1

def inv_xorshift(z, shift):
    x = z
    for _ in range(64 // shift + 1):
        x = z ^ (x >> shift)
    return x & MASK

C1, C2, C3 = 0x9e3779b97f4a7c15, 0xbf58476d1ce4e5b9, 0x94d049bb133111eb
MOD = 1 << 64
TrueSeal = 15682021040575554950

z = inv_xorshift(TrueSeal, 31)
z = (z * pow(C3, -1, MOD)) & MASK
z = inv_xorshift(z, 27)
z = (z * pow(C2, -1, MOD)) & MASK
z = inv_xorshift(z, 30)
mark = (z - C1) & MASK
print("CarriedMark:", mark, hex(mark))

SealedRecord = bytes([13,86,51,68,18,110,68,15,54,61,236,94,135,202,213,182,4,1,182,181,
    150,228,184,126,121,224,236,220,7,82,153,251,179,104,0,87,32,34,3,60,
    166,96,124,50,253,31,124,179,220,157,120,115,19,47,96,11])

key = hashlib.sha256(struct.pack('<Q', mark)).digest()
out = bytearray(len(SealedRecord)); n = ctr = 0
while n < len(SealedRecord):
    blk = hashlib.sha256(key + struct.pack('<i', ctr)).digest()
    j = 0
    while j < len(blk) and n < len(SealedRecord):
        out[n] = SealedRecord[n] ^ blk[j]; j += 1; n += 1
    ctr += 1
print(''.join(chr(b) if 32 <= b < 127 else '?' for b in out))
EOF
python3 solve_overstrike.py
CarriedMark: 15549431037298259574 0xd7caad24dd98b676
HTB{0v3rstr1k3_r3cut_th3_w0rld_s34l_by_f0rg1ng_th3_mark}

The flag text itself confirms the intended solution - “re-cut the world seal by forging the mark.” The emulator was never needed.


Proofmark

Summary

You bring a self-made ring to an inspection anvil. The anvil is supposed to tell a genuine heirloom from a counterfeit. Your job is to make a fake it cannot distinguish.

Proofmark

This one hides its logic differently: the game scripts are readable, but they hand the actual decision off to a compiled machine-code library. Inside that library are two separate gates:

  1. Your ring’s measurements must exactly match one hardcoded combination burned into the binary.
  2. Your ring’s stamp must independently satisfy a second, much heavier check.

The measurements can simply be read out of the binary as raw bytes. The stamp is harder - but we never actually need it. The library’s decryption routine is a tiny, fast function of a single 32-bit number, and we already know the answer starts with HTB{. So we try all four billion possibilities until one produces that prefix. That takes under a minute on a laptop.


Proof of Concept (Technical)

Step 1 - Locate the logic

apktool shows this is plain GDScript (.gdc bytecode, no C# assemblies). Decompiling the pack with GDRE Tools recovers the scripts. ForgeClient.gd is the giveaway:

1
2
3
4
5
6
7
8
9
10
11
enum Verdict { REJECT_STATE, REJECT_TOKEN, ACCEPTED }

func reseal(state: PackedByteArray) -> int:
    return _native.reseal(state.decode_s32(0), state.decode_s32(4),
                          state.decode_s32(8), state.decode_s32(12)) & 4294967295

func strike(state: PackedByteArray, hallmark: int) -> Array:
    var v: int = _native.submit(state.decode_s32(0), state.decode_s32(4),
                                state.decode_s32(8), state.decode_s32(12), hallmark)
    var mark: String = _native.certificate() if v == Verdict.ACCEPTED else ""
    return [v, mark]

The real work lives in lib/x86_64/libproofmark.x86_64.so, a GDExtension declared by proofmark.gdextension with entry symbol proofmark_library_init.

And GameState.gd defines the state layout - four little-endian int32s:

1
2
3
4
5
6
7
8
9
10
11
12
13
var wards: PackedInt32Array = PackedInt32Array([0, 0, 0])
var bite: int = 0

func file_ward(index: int, delta: int) -> void:
    wards[index] = clampi(wards[index] + delta, 0, 24)
    bite = wards[0] + wards[1] * 2 + wards[2] * 3
    _resync_hallmark()

func snapshot() -> PackedByteArray:
    var buf := PackedByteArray(); buf.resize(16)
    buf.encode_s32(0,  wards[0]); buf.encode_s32(4,  wards[1])
    buf.encode_s32(8,  wards[2]); buf.encode_s32(12, bite)
    return buf

Step 2 - Find the bound methods in Ghidra

Loading the .so and tracing proofmark_library_init → the initialize callback lands on the ClassDB registration block:

Method registration

reseal, submit and certificate are registered here, each with a call-wrapper and a ptrcall-wrapper function pointer.

Step 3 - Follow reseal to its real implementation

The ptrcall wrapper for reseal (FUN_00102bb0) just re-packs the four int32 arguments back into a 16-byte buffer - the identical layout snapshot() produces - and calls into the real routine:

reseal call

FUN_00101c10(&local_28, 0x10) is the hash core. Internally it’s a custom bytecode-VM interpreter running 100,000 iterations - deliberately painful to read statically.

Step 4 - Read the acceptance condition

Skipping the VM body and reading only its tail reveals the whole game:

Genuine vs forged branch

1
2
3
4
5
6
7
8
9
10
11
12
if (param_2 == 0x10) {
    if ( (*param_1 < 0x19) &&                       // ward0 in [0,24]
         (param_1[1] within [0,24]) &&               // ward1
         (param_1[2] within [0,24]) &&               // ward2
         (param_1[3] == param_1[2]*3 + *param_1 + param_1[1]*2) ) {   // bite formula
        return uVar10;                               // "genuine" path
    }
}
// otherwise: a completely different, much simpler finalizer
uVar7 = (((local_78[0] ^ 0xa5a5a5a5) >> 0x10) ^ local_78[0] ^ 0xa5a5a5a5) * -0x7a1435cb;
uVar7 = ((uVar7 >> 0xd) ^ uVar7) * -0x3d4d51cb;
return (uVar7 >> 0x10) ^ uVar7 ^ 0xbadf00d;

That last line is bite == w0 + 2·w1 + 3·w2 - exactly the formula in GameState.gd. The binary independently re-derives what a legitimate ring should look like. Note the 0xbadf00d on the counterfeit path.

Step 5 - Extract the hardcoded target state

submit’s real implementation (FUN_001020d0) opens with a 128-bit MOVDQU/PXOR/PTEST compare against _DAT_00100530 - meaning the state buffer must match one specific hardcoded 16 bytes. Reading them straight out of the data section:

Target state bytes

1
2
00100530:  53 00 00 00   43 00 00 00   37 00 00 00   ce 01 00 00
           wards[0]=83   wards[1]=67   wards[2]=55   bite=462

Sanity check: 83 + 2·67 + 3·55 = 83 + 134 + 165 = 382… which is not 462. The hardcoded state is deliberately not internally consistent - it is the forgery. (The first three bytes also spell S C 7 in ASCII - an author nod.)

Step 6 - Read the certificate decryption

submit core

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
iVar1 = 1200000;
do {                                     // 1.2M rounds of MurmurHash3-style mixing
    uVar4 = (param_3 + 0xc2b2ae35 >> 0x10 ^ param_3 + 0xc2b2ae35) * -0x7a143595;
    ...
} while (iVar1 != 0);
...
do {                                     // keystream → XOR against embedded table
    *(byte *)((long)local_38 + lVar2) = (byte)(uVar5 >> 0x18) ^ (&DAT_001006a0)[lVar2];
    lVar2 = lVar2 + 1;
} while (lVar2 != 0x1c);                 // 28 bytes
...
if (local_38[0] == 0x7b425448) {         // little-endian ASCII "HTB{"
    memcpy(param_4, local_38, ...);      // emit certificate
    uVar3 = 2;                           // Verdict.ACCEPTED
}

0x85ebca6b / 0xc2b2ae35 are the MurmurHash3 32-bit finalizer constants (shown negated by Ghidra).

Step 7 - Confirm the state is correct, live

Rather than trust static reading, hook the real library in the running process with Frida and call the two internals directly. Addresses were located by byte-pattern scan (Ghidra’s displayed addresses carry a synthetic image base, so raw arithmetic offsets do not map to runtime):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var mod        = Process.findModuleByName("libproofmark.x86_64.so");
// offsets confirmed empirically via Memory.scanSync for the function prologues
var resealAddr = mod.base.add(0x4c10);   // 53 48 83 EC 50        (push rbx; sub rsp,0x50)
var submitAddr = mod.base.add(0x50d0);   // 31 C0 83 FE 10 75 13  (xor eax,eax; cmp esi,0x10)

var reseal = new NativeFunction(resealAddr, 'uint32', ['pointer','int32']);
var submit = new NativeFunction(submitAddr, 'uint64',
                                ['pointer','int32','uint32','pointer','int32']);

var state = Memory.alloc(16);
state.add(0).writeS32(83);  state.add(4).writeS32(67);
state.add(8).writeS32(55);  state.add(12).writeS32(462);

var hallmark = reseal(state, 0x10);
var outbuf   = Memory.alloc(256);
var verdict  = submit(state, 0x10, hallmark, outbuf, 255);
console.log("verdict:", verdict, "cert:", outbuf.readCString());

Frida hooks

1
2
3
4
5
6
[*] reseal_core @ 0x76b15acd0c10
    53 48 83 ec 50 0f 28 05      SH..P.(.        ← prologue matches
[*] submit_core @ 0x76b15acd10d0
    31 c0 83 fe 10 75 13 f3      1....u..        ← prologue matches
[+] Computed hallmark: 3695541232 (0xdc457bf0)
[+] Verdict: 1

Verdict moved from 0 (REJECT_STATE) to 1 (REJECT_TOKEN) - i.e. “perfect face, wrong spine.” The state is accepted as genuine; only the hallmark is wrong. This proves the extracted bytes are right, and proves reseal()’s output is not the hallmark submit() wants - the two gates are independent.

Step 8 - Skip the hallmark entirely

We do not actually need the hallmark. The keystream generator is a small deterministic function of a single 32-bit intermediate M, and we know the plaintext starts with HTB{. A 2³² search is trivial in C.

First dump the 28-byte table at DAT_001006a0:

Keystream table

1
2
2a 53 db 7b a3 5d 34 f5 5f 59 74 5e 00 43 88 1c
a1 13 6f b7 f8 d7 3f 79 c1 b0 af 1a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static const uint8_t table[28] = {
    0x2a,0x53,0xdb,0x7b,0xa3,0x5d,0x34,0xf5,0x5f,0x59,0x74,0x5e,0x00,0x43,
    0x88,0x1c,0xa1,0x13,0x6f,0xb7,0xf8,0xd7,0x3f,0x79,0xc1,0xb0,0xaf,0x1a };

#define K1 0x85ebca6bU
#define K2 0xc2b2ae35U
static inline uint32_t f1(uint32_t x){ return ((x >> 16) ^ x) * K1; }
static inline uint32_t f2(uint32_t x){ return ((x >> 13) ^ x) * K2; }
static inline uint32_t f3(uint32_t x){ return  (x >> 16) ^ x;      }

int try_seed(uint32_t M, uint8_t *out) {
    uint32_t s = M;
    for (int i = 0; i < 28; i++) {
        s = f1(s + K2);
        uint32_t y = f2(s);
        s = f3(y);
        out[i] = (uint8_t)(y >> 24) ^ table[i];
    }
    return out[0]=='H' && out[1]=='T' && out[2]=='B' && out[3]=='{';
}

for (uint64_t M = 0; M < (1ULL << 32); M++)
    if (try_seed((uint32_t)M, out)) printf("M=0x%08x -> %.28s\n", (uint32_t)M, out);
1
gcc -O3 -o solve solve_proofmark.c && ./solve

The matching seed prints the decrypted 28-byte certificate directly.

Flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
┌──(kali㉿DESKTOP-B2E2TUG)-[/mnt/c/Users/adm_consultant/Downloads/mobile_proofmark]
└─$ cat > solve_proofmark.c << 'EOF'
#include <stdio.h>
#include <stdint.h>

#define K1 0x85ebca6bU
#define K2 0xc2b2ae35U
#define ADD 0xc2b2ae35U

static inline uint32_t f1(uint32_t x) { return ((x >> 16) ^ x) * K1; }
static inline uint32_t f2(uint32_t x) { return ((x >> 13) ^ x) * K2; }
static inline uint32_t f3(uint32_t x) { return  (x >> 16) ^ x;      }

static const uint8_t table[28] = {
    0x2a,0x53,0xdb,0x7b,0xa3,0x5d,0x34,0xf5,
    0x5f,0x59,0x74,0x5e,0x00,0x43,0x88,0x1c,
    0xa1,0x13,0x6f,0xb7,0xf8,0xd7,0x3f,0x79,
    0xc1,0xb0,0xaf,0x1a
};

static int try_seed(uint32_t M, uint8_t *out) {
    uint32_t s = M;
    for (int i = 0; i < 28; i++) {
        s = f1(s + ADD);
        uint32_t y = f2(s);
        s = f3(y);
        out[i] = (uint8_t)(y >> 24) ^ table[i];
    }
    return out[0] == 'H' && out[1] == 'T' && out[2] == 'B' && out[3] == '{';
}

int main(void) {
    uint8_t out[29];
    uint64_t total = 1ULL << 32;
    printf("sweeping %llu seeds...\n", (unsigned long long)total);
    for (uint64_t m = 0; m < total; m++) {
        if (try_seed((uint32_t)m, out)) {
            out[28] = 0;
            printf("\n[+] M = 0x%08x\n", (uint32_t)m);
            printf("[+] plaintext: %s\n", out);
        }
        if ((m & 0x0FFFFFFF) == 0 && m)
            printf("  %.0f%%\n", (double)m / total * 100.0);
    }
    printf("done.\n");
    return 0;
}
EOF
gcc -O3 -o solve solve_proofmark.c && ./solve
sweeping 4294967296 seeds...
  6%

[+] M = 0x1c7c8990
[+] plaintext: HTB{p3rf3ct_f4c3_tru3_sp1n3}
  12%

[+] M = 0x2bd302a4
[+] plaintext: HTB{c��y�Zw护Ӗ�9�c���ſ?
  19%
  25%
  31%
  38%
  44%
  50%
  56%
  62%
  69%
  75%
  81%
  88%
  94%
done.


HTB{p3rf3ct_f4c3_tru3_sp1n3}        # emitted by solve_proofmark.c


SaltCrown

Summary

A crowd of the dead marches in lockstep to a city bell. You cannot fight them. Instead you wedge “shards” into narrow points along the street, timed to the bell’s rhythm, to break their step and overload the bell until its counterfeit strike-plate cracks.

SaltCrown

On paper this is a timing/skill challenge. In practice, reading the code reveals two shortcuts:

  • The flag depends only on which narrow points you jammed - not on how well you timed them. The game re-looks-up the “correct” timing value at the end anyway.
  • Those “correct” timing values come from a compiled function whose only input is a static 256-byte data file shipped inside the APK. Nothing random, nothing runtime.

So you extract that file, reimplement the function offline, get all eight values, and then try the handful of possible jam-combinations until one decrypts to readable text. The game is never played.


Proof of Concept (Technical)

Step 1 - Identify the hybrid architecture

SaltCrown is both: C# game logic in SaltCrown.dll, plus a native GDExtension.

1
2
./SaltCrown/assets/.godot/mono/publish/x86_64/SaltCrown.dll
./SaltCrown/lib/x86_64/libashvault.android.template_release.x86_64.so
1
2
3
[configuration]
entry_symbol = "saltcrown_library_init"
compatibility_minimum = "4.7"

Step 2 - Read the C# mechanism

SaltCrownSpec holds the sealed flag and the fold function (FNV-1a offset basis + FNV prime → this is FNV-1a with an extra avalanche):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
private static readonly byte[] SealedSpec = new byte[24]
{
    117, 201, 171, 107, 154,  83, 207, 191,  31, 233,
    126,  74, 147, 148,  37, 224,  41, 207, 135, 169,
    194, 128, 222, 220
};
public const uint Unmeasured = 2166136261u;      // FNV-1a offset basis

public static uint Measure(uint acc, int chokeIndex, uint phaseBucket)
{
    acc = Mix(acc, (uint)chokeIndex);
    acc = Mix(acc, phaseBucket);
    return acc;
}

public static string Unseal(uint measured)
{
    byte[] a = new byte[SealedSpec.Length];
    uint num = measured;
    for (int i = 0; i < SealedSpec.Length; i++) {
        num = Mix(num, (uint)i);
        a[i] = (byte)(SealedSpec[i] ^ (byte)(num >> 24));
    }
    return "HTB{" + Encoding.ASCII.GetString(a) + "}";
}

private static uint Mix(uint h, uint v)
{
    h ^= v;  h *= 16777619;          // FNV prime
    h ^= h >> 15; h *= 2246822519u;
    h ^= h >> 13;
    return h;
}

Step 3 - Find what actually feeds Measure

Director.Forge() runs the moment the strike-plate fractures:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void Forge()
{
    uint num = 2166136261u;
    foreach (ShardSeat item in from s in _seats
                               where s.Bites
                               orderby s.ChokeIndex
                               select s)
    {
        num = SaltCrownSpec.Measure(num, item.ChokeIndex,
                                    Tolerances.PhaseBucket(item.ChokeIndex));
    }
    Crown = SaltCrownSpec.Unseal(num);
    State = Phase.Forged;
}

This is the critical observation. The second argument is Tolerances.PhaseBucket(item.ChokeIndex) - freshly re-queried by index. It is not item.BeddedPhase, the phase you actually struck at. Your timing only decides whether a seat counts (s.Bites); it contributes nothing to the hash.

Therefore: the flag is a pure function of the set of choke indices you successfully seated.

Timing gating lives in Tolerances, and is irrelevant to the output:

1
2
3
4
public static bool EngagesCleanly(int index, float phaseAtBedding)
    => BucketDistance(BeatBucket(phaseAtBedding), PhaseBucket(index)) <= 8;

public static uint PhaseBucket(int index) => WearLattice.AdmitBucket(index);

And WearLattice is the bridge into native code:

1
2
3
4
5
6
7
8
9
10
public const string RubbingPath = "res://rubbings/ashvault.dat";

public static uint AdmitBucket(int choke)
{
    if (_vault   == null) _vault   = ClassDB.Instantiate("AshVault").AsGodotObject();
    if (_rubbing == null) _rubbing = LoadRubbing();
    byte[] array = new byte[_rubbing.Length];
    Array.Copy(_rubbing, array, _rubbing.Length);
    return (uint)(int)_vault.Call("admit_bucket", array, choke);
}

Its only inputs are a static asset file and an integer 0–7. Fully reproducible offline.

Step 4 - Locate admit_bucket in the native library

Tracing saltcrown_library_init shows the usual godot-cpp bootstrap:

saltcrown_library_init

Rather than walk the whole registration chain, search the binary for the method name directly and follow its cross-reference:

admit_bucket string

This leads to FUN_0011dc30 - the implementation.

Step 5 - Read the algorithm

1
2
3
4
5
6
7
8
9
10
11
12
13
ulong FUN_0011dc30(undefined8 param_1, undefined8 param_2, int param_3)
{
  // ---- Stage 1: build a 64-word table from the rubbing bytes (FNV-1a variant) ----
  do {
    uVar5 = 0x811c9dc5;                                  // FNV-1a offset basis
    ...
      uVar5 = ((uint)*(byte *)(lVar2 + lVar7)     + iVar3 ^ uVar5) * 0x1000193;
      uVar5 = (uVar5 >> 0xb ^ (uint)*(byte *)(lVar2 + 1 + lVar7) + iVar3 ^ uVar5) * 0x1000193;
      uVar5 =  uVar5 >> 0xb ^ uVar5;
    ...
    local_228[lVar4] = uVar5;
  } while (lVar4 != 0x40);                               // 64 words
  ...

Stage 2 + extraction

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  // ---- Stage 2: 4096 rounds of neighbour-coupled mixing ----
  do {
      ...
      uVar5 = ((local_228[(uint)lVar4 & 0x3f] << 0x1b | local_228[(uint)lVar4 & 0x3f] >> 5) ^
               (local_228[(int)lVar2 - 1U & 0x3f] << 7 | local_228[(int)lVar2 - 1U & 0x3f] >> 0x19)
               ^ uVar6) * -0x7a143589 + iVar8 + (int)lVar2;
      uVar6 = (uVar6 << 0xd | uVar6 >> 0x13) ^ (uVar5 >> 0xf ^ uVar5) * -0x3d4d51c3;
      local_128[lVar2] = uVar6 >> 0x10 ^ uVar6;
      ...
    memcpy(local_228, local_128, 0x100);
    iVar8 = iVar8 + -0x61c88647;                         // XXTEA golden-ratio delta
  } while (iVar3 != 0x1000);                             // 4096 rounds

  // ---- Final: two indexed words → one byte ----
  uVar6 = (local_128[param_3 * 0x17 + 0x29U & 0x3f] << 0xb |
           local_128[param_3 * 0x17 + 0x29U & 0x3f] >> 0x15) ^ local_128[param_3 * 7 + 3U & 0x3f];
  return (ulong)((uVar6 >> 5 ^ uVar6 >> 0xd) & 0xff);
}

0x61c88647 is the two’s-complement form of the XXTEA/golden-ratio delta 0x9E3779B9 - this is an XXTEA-flavoured block permutation over 64 × 32-bit words.

Step 6 - Cross-check against raw disassembly (this step matters)

A first Python port produced garbage. The decompiler’s C had folded a step away. Reading the raw loop body instead:

Round body assembly

0011dd38  ROL   param_3,0x7                              ; rol(local_228[(i-1)&63], 7)
0011dd3b  ROL   param_2+0x4,0x1b                         ; rol(local_228[(i+1)&63], 27)
0011dd3e  XOR   param_2+0x4,param_3
0011dd40  XOR   param_2+0x4,param_1+0x4                  ; ^= local_228[i]
0011dd42  IMUL  param_3,param_2+0x4,-0x7a143589
0011dd48  ADD   param_3,R12D                             ; += round delta
0011dd4b  ADD   param_3,EAX                              ; += i
0011dd4d  MOV   param_2+0x4,param_3
0011dd4f  SHR   param_2+0x4,0xf
0011dd52  XOR   param_2+0x4,param_3                      ; x ^= x >> 15
0011dd54  ROL   param_1+0x4,0xd                          ; rol(local_228[i], 13)
0011dd57  IMUL  param_3,param_2+0x4,-0x3d4d51c3
0011dd5d  XOR   param_1+0x4,param_3

and then, immediately before the store:

Hidden xorshift

0011dd5f  MOV   param_3,param_1+0x4
0011dd61  SHR   param_3,0x10
0011dd64  XOR   param_3,param_1+0x4        ; <-- x ^= x >> 16   (missed on first pass)
0011dd66  MOV   dword ptr [RSP + RAX*0x4 + 0x100],param_3

That final x ^= x >> 16 avalanche is present in the machine code but easy to drop when transcribing from decompiled C. Every one of 4096 × 64 iterations depends on it - omit it and the output is noise.

The tail extraction confirms the index math too:

0011dd9b  LEA   EAX,[RBX*0x8]      ; 8c
0011dda2  SUB   EAX,EBX            ; 7c
0011dda4  ADD   EAX,0x3            ; 7c + 3
0011dda7  LEA   ECX,[RBX + RBX*0x2]; 3c
0011ddaa  SHL   ECX,0x3            ; 24c
0011ddad  SUB   ECX,EBX            ; 23c
0011ddaf  ADD   ECX,0x29           ; 23c + 41   (== 0x17*c + 0x29)
0011ddbc  ROL   ECX,0xb
0011ddc2  XOR   ECX,[... local_128[idx2]]
0011ddcb  SHR   EAX,0xd
0011ddce  SHR   ECX,0x5
0011ddd1  XOR   ECX,EAX
0011ddd3  MOVZX EAX,CL             ; & 0xff

Step 7 - Extract the rubbing and reimplement offline

1
2
3
4
5
6
7
8
$ ls -la SaltCrown/assets/rubbings/ashvault.dat
-rwxrwxrwx 1 kali kali 256 ...  ashvault.dat

$ xxd SaltCrown/assets/rubbings/ashvault.dat | head -4
00000000: a75b 3f06 3f32 1130 ad51 1108 3514 072a  .[?.?2.0.Q..5..*
00000010: ae72 160f 3e23 3019 a458 2029 041d 0e03  .r..>#0..X )....
00000020: b559 2d24 2d30 0322 af43 331a 3736 1528  .Y-$-0.".C3.76.(
00000030: cc20 747d 6c41 427b f63a 427b 666f 6c51  . t}lAB{.:B{folQ
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def admit_bucket(rubbing: bytes, choke: int) -> int:
    size = len(rubbing)
    local_228 = [0] * 64

    # Stage 1 - FNV-1a variant, pairwise over the rubbing, seeded per word index
    for word_idx in range(64):
        h, p = 0x811c9dc5, 0
        limit = (size & MASK32) - (size & 1)
        while limit != p:
            h = ((((rubbing[p]     + word_idx) & MASK32) ^ h) * 0x1000193) & MASK32
            t = (h >> 0xb) ^ (((rubbing[p + 1] + word_idx) & MASK32) ^ h)
            h = (t * 0x1000193) & MASK32
            h = ((h >> 0xb) ^ h) & MASK32
            p += 2
        if size & 1:
            h = ((((rubbing[p] + word_idx) & MASK32) ^ h) * 0x1000193) & MASK32
            h = ((h >> 0xb) ^ h) & MASK32
        local_228[word_idx] = h

    # Stage 2 - 4096 rounds
    local_128 = [0] * 64
    delta = 0
    M1 = (0x100000000 - 0x7a143589) & MASK32
    M2 = (0x100000000 - 0x3d4d51c3) & MASK32
    for _ in range(0x1000):
        for i in range(64):
            cur = local_228[i]
            A = rol32(local_228[(i + 1) & 0x3f], 0x1b)
            B = rol32(local_228[(i - 1) & 0x3f], 0x07)
            x = (((A ^ B) ^ cur) * M1 + delta + i) & MASK32
            y = (rol32(cur, 0xd) ^ (((x >> 0xf) ^ x) * M2)) & MASK32
            local_128[i] = (y ^ (y >> 0x10)) & MASK32      # <-- the recovered step
        local_228 = local_128[:]
        delta = (delta - 0x61c88647) & MASK32

    v = rol32(local_128[(choke * 0x17 + 0x29) & 0x3f], 0xb) ^ local_128[(choke * 7 + 3) & 0x3f]
    return ((v >> 5) ^ (v >> 0xd)) & 0xff
1
2
3
4
5
6
7
8
9
Rubbing file size: 256 bytes
choke 0 -> bucket 88
choke 1 -> bucket 43
choke 2 -> bucket 121
choke 3 -> bucket 112
choke 4 -> bucket 2
choke 5 -> bucket 240
choke 6 -> bucket 62
choke 7 -> bucket 188

Step 8 - Search the choke combinations

Elric.SeatStock = 5, and Director folds biting seats ordered by ChokeIndex. That is a tiny search space, so enumerate it and keep whatever decrypts to printable ASCII:

1
2
3
4
5
6
7
8
9
10
11
12
13
from itertools import combinations_with_replacement

def try_sequence(chokes, buckets):
    acc = 2166136261
    for c in chokes:                       # already ascending
        acc = measure(acc, c, buckets[c])
    return unseal(acc)

for k in range(0, 9):
    for combo in combinations_with_replacement(range(8), k):
        out = try_sequence(combo, buckets)
        if all(32 <= b < 127 for b in out):
            print(combo, "HTB{" + out.decode() + "}")

Here is the full script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
#!/usr/bin/env python3
"""
Offline solver for SaltCrown's admit_bucket() native function.
Usage: python3 solve_saltcrown.py /path/to/ashvault.dat
"""
import sys

MASK32 = 0xFFFFFFFF

def rol32(x, n):
    x &= MASK32
    n &= 31
    if n == 0:
        return x
    return ((x << n) | (x >> (32 - n))) & MASK32

def admit_bucket(rubbing: bytes, choke: int) -> int:
    size = len(rubbing)
    if size < 1:
        return 0xFFFFFFFFFFFFFFFF

    local_228 = [0] * 64

    # Stage 1: build local_228[64] via FNV1a-like pairwise mixing over the rubbing bytes
    for word_idx in range(64):
        uVar5 = 0x811c9dc5
        lVar7 = 0
        iVar3 = word_idx
        if size != 1:
            limit = (size & MASK32) - (size & 1)
            while limit != lVar7:
                b0 = rubbing[lVar7]
                b1 = rubbing[lVar7 + 1]
                uVar5 = (((b0 + iVar3) & MASK32) ^ uVar5)
                uVar5 = (uVar5 * 0x1000193) & MASK32
                term = (uVar5 >> 0xb) ^ (((b1 + iVar3) & MASK32) ^ uVar5)
                uVar5 = (term * 0x1000193) & MASK32
                uVar5 = ((uVar5 >> 0xb) ^ uVar5) & MASK32
                lVar7 += 2
        if (size & 1) != 0:
            b0 = rubbing[lVar7]
            uVar5 = ((((b0 + iVar3) & MASK32) ^ uVar5) * 0x1000193) & MASK32
            uVar5 = ((uVar5 >> 0xb) ^ uVar5) & MASK32
        local_228[word_idx] = uVar5

    # Stage 2: 4096 rounds of mixing (XXTEA-golden-ratio-constant style)
    local_128 = [0] * 64
    iVar8 = 0
    MULT1 = (0x100000000 - 0x7a143589) & MASK32  # unsigned form of -0x7a143589
    MULT2 = (0x100000000 - 0x3d4d51c3) & MASK32  # unsigned form of -0x3d4d51c3

    for _round in range(0x1000):
        lVar2 = 0
        while True:
            lVar4 = lVar2 + 1
            uVar6 = local_228[lVar2]

            idxA = lVar4 & 0x3f
            A = (rol32(local_228[idxA], 0x1b) | (local_228[idxA] >> 5)) & MASK32

            idxB = (lVar2 - 1) & 0x3f
            B = (rol32(local_228[idxB], 7) | (local_228[idxB] >> 0x19)) & MASK32

            uVar5 = ((A ^ B) ^ uVar6) & MASK32
            uVar5 = (uVar5 * MULT1) & MASK32
            uVar5 = (uVar5 + iVar8 + lVar2) & MASK32

            rotated = rol32(uVar6, 0xd)
            mixpart = ((uVar5 >> 0xf) ^ uVar5) & MASK32
            uVar6new = (rotated ^ ((mixpart * MULT2) & MASK32)) & MASK32

            local_128[lVar2] = uVar6new

            lVar2 = lVar4
            if lVar4 == 0x40:
                break

        local_228 = local_128[:]
        iVar8 = (iVar8 - 0x61c88647) & MASK32

    idx1 = (choke * 0x17 + 0x29) & 0x3f
    idx2 = (choke * 7 + 3) & 0x3f
    val1 = local_128[idx1]
    uVar6 = (rol32(val1, 0xb) | (val1 >> 0x15)) & MASK32
    uVar6 ^= local_128[idx2]
    uVar1 = (uVar6 >> 5) ^ (uVar6 >> 0xd)
    return uVar1 & 0xff


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: python3 solve_saltcrown.py /path/to/ashvault.dat")
        sys.exit(1)

    with open(sys.argv[1], "rb") as f:
        rub = f.read()

    print(f"Rubbing file size: {len(rub)} bytes")
    buckets = []
    for choke in range(8):
        b = admit_bucket(rub, choke)
        buckets.append(b)
        print(f"choke {choke} -> bucket {b}")

    print()
    print("All buckets:", buckets)
    

Flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
┌──(kali㉿DESKTOP-B2E2TUG)-[/mnt/c/Users/adm_consultant/Downloads/mobile_salt_crown]
└─$ python3 solve_saltcrown.py "SaltCrown/assets/rubbings/ashvault.dat"
Rubbing file size: 256 bytes
choke 0 -> bucket 149
choke 1 -> bucket 84
choke 2 -> bucket 104
choke 3 -> bucket 178
choke 4 -> bucket 26
choke 5 -> bucket 6
choke 6 -> bucket 101
choke 7 -> bucket 234

Brute-forcing choke sequences (0-8 biting seats, repeats allowed)...
Checked 12870 combinations. Top 15 by printable-byte score:

score=24/24 chokes=(3, 4, 5, 6, 7) -> HTB{p3rf3ct_f4c3_wr0ng_sp1n3}
score=17/24 chokes=(0, 0, 4, 4, 6) -> HTB{yj3��r@<SP<L=dqCT>@���}
score=17/24 chokes=(5, 5, 5, 5, 5, 7) -> HTB{V�J�jf��>I~G�twI�@T/�:4X}
score=17/24 chokes=(0, 1, 2, 3, 6, 6, 6) -> HTB{,d_/a'��_��in Z+��c2u%�o}
score=17/24 chokes=(0, 1, 2, 6, 6, 6, 7) -> HTB{k0�d8(�IK��zv un�r*!@��Y}
score=17/24 chokes=(1, 1, 3, 5, 5, 6, 6) -> HTB{D3o�\��H'Lzv��2-�Frd5Ux}
score=17/24 chokes=(3, 4, 4, 4, 6, 6, 7) -> HTB{:4Y_,��>U��Y)Yv:p]�+My}
score=17/24 chokes=(0, 0, 2, 4, 5, 5, 5, 7) -> HTB{�C]�+S� I�z/UKVbZfs� 4}
score=17/24 chokes=(0, 1, 2, 4, 4, 6, 7, 7) -> HTB{�=J�dVwRu {Lwh��iw��s1n�}
score=17/24 chokes=(0, 2, 2, 2, 6, 7, 7, 7) -> HTB{<��cu[-@d[q25�db_g   %�U}
oJgREd��lA�5��biM]�.|<}2, 2, 4, 4, 7) -> HTB{�
score=16/24 chokes=(0, 0, 0, 1, 2, 4, 5) -> HTB0ou��P\H8Y��iv�d�jm[HB}
score=16/24 chokes=(0, 1, 3, 4, 4, 4, 5) -> HTB{+�ck��iV~&�d��hA.+U;{�v}
score=16/24 chokes=(0, 2, 5, 5, 6, 7, 7) -> HTB{~|\Z�(u�VHIt�q6�r�5DF}
score=16/24 chokes=(1, 1, 3, 3, 3, 4, 7) -> HTB{g��E�,v�q~�y,CGp���O.?Mw}

HTB{p3rf3ct_f4c3_wr0ng_sp1n3}       # emitted by solve_saltcrown.py

Scripts used

File Challenge Purpose
scripts/invert_splitmix64.py Overstrike Inverts splitmix64 to recover CarriedMark from TrueSeal
scripts/unseal_overstrike.py Overstrike Reimplements UnsealRegistry() offline to print the flag
scripts/frida_proofmark.js Proofmark Calls reseal/submit inside the live process to validate the extracted state
scripts/solve_proofmark.c Proofmark 2³² keystream-seed search anchored on the HTB{ prefix
scripts/solve_saltcrown.py SaltCrown Offline admit_bucket reimplementation + choke-combination search

Tooling: apktool · ILSpy · GDRE Tools · Ghidra · Frida · gcc/python3


Takeaways

  • All three were solvable without playing the games. Overstrike and SaltCrown never required the emulator at all; Proofmark used it only to validate a hypothesis before committing to an offline brute-force.
  • Find the check, not the gameplay. In every case the win condition was a pure function of a small number of inputs, and the “skill” framing was a decoy - most explicitly in SaltCrown, where Forge() discards your actual timing and re-derives the expected value by index.
  • Recognise stock constants. 0x9E3779B9, 0x85EBCA6B, 0xC2B2AE35, 0x01000193, 0x811C9DC5 instantly identify splitmix64, MurmurHash3 and FNV-1a, which collapses “unknown custom crypto” into “known, invertible, or cheaply searchable.”
  • Trust the disassembly over the decompiler. The single hardest bug in the whole set was one x ^= x >> 16 that Ghidra’s C reconstruction folded away. Decompiled C is a summary, not the program.
  • Brute-force the small unknown. Twice the winning move was refusing to reverse an expensive function (1.2M rounds; 4096 rounds) and instead searching the tiny space around it - a 32-bit seed, or a few thousand index combinations.
This post is licensed under CC BY 4.0 by the author.