# Overstrike — HTB Cyber Apocalypse 2026 (Mobile / GamePwn) | | | |---|---| | **Challenge** | Overstrike | | **Platform** | HackTheBox — Cyber Apocalypse 2026 ("The Salt Crown") | | **Category** | Mobile / GamePwn | | **Target** | Android APK — Godot 4 engine, C#/Mono runtime | | **Techniques** | .NET metadata analysis, IL reading without a decompiler, static array-init blob extraction, splitmix64 inversion, APK repack + re-sign | | **Date** | July 2026 | --- ## TL;DR The game hands you a "Carried Mark" that must match a "World Seal" before the vow-stone bridge to the archive lights up. The seal is derived from the mark on every frame by a function that turns out to be the **splitmix64 finalizer** — and splitmix64 is fully invertible. Instead of searching a 64-bit keyspace, I ran the mixer backwards from the hardcoded target seal to the single mark that produces it, then used that mark to rebuild the SHA-256 keystream protecting the flag and decrypted it offline. No emulator required for the solve; the in-game path is a one-field patch. --- ## Recon — what engine is this? First question with any mobile game challenge: what actually holds the logic? That decides every tool afterwards. So before unpacking anything, I listed the archive and looked for engine fingerprints. ```bash $ unzip -l Overstrike.apk > /tmp/list.txt $ grep -Ei "\.so$|\.dex$|\.pck$|\.dll$" /tmp/list.txt [...] lib/x86_64/libgodot_android.so assets/.godot/mono/publish/x86_64/Overstrike.dll assets/.godot/mono/publish/x86_64/System.Private.CoreLib.dll [...] ``` No `libunity.so` and no `global-metadata.dat`, so not Unity. `libgodot_android.so` plus a full Mono runtime means **Godot 4 with C# scripting** — which is the friendly outcome, because game logic ends up in a normal managed assembly rather than compiled native code. Everything in that publish folder is the .NET standard library except one 62 KB file. That one is the game: ```bash $ unzip -o Overstrike.apk 'assets/.godot/mono/publish/*/Overstrike.dll' -d ext $ file ext/assets/.godot/mono/publish/x86_64/Overstrike.dll PE32+ executable (DLL) (console) x86-64 Mono/.Net assembly ``` ### Dead end #1: strings Reflex move, near-zero payoff: ```bash $ strings Overstrike.dll | grep -iE "score|win|seal|flag" Overstrike InputEventKey ``` Worth knowing *why* this is empty rather than concluding the binary is stripped. In a .NET assembly, identifiers live in the `#Strings` metadata heap and literals in `#US` (UTF-16), so a byte-oriented `strings` pass sees almost nothing useful. The names were all there — they just needed a metadata-aware reader. --- ## Analysis — reading the assembly Opened it in **dnSpyEx** on the Windows side for orientation. The type list immediately outlines the game: `Main`, `Player`, `CameraRig`, `Hud`, `MarkPickup`, `BridgeBuilder`, `Archive`, `GameState`. `GameState` is where the challenge lives: ```csharp public partial class GameState : Node { public ulong CarriedMark; public ulong WorldSeal; public const ulong TrueSeal = 0xD9A1BB0CABB52586; private static readonly byte[] SealedRecord; public override void _Process(double delta) => WorldSeal = Mix(CarriedMark); public bool WorldIsAligned => WorldSeal == TrueSeal; public string UnsealRegistry() { /* SHA-256 keystream XOR over SealedRecord */ } } ``` And `Archive._Process` renders whatever `UnsealRegistry()` returns, once the player has entered its trigger area: ```csharp if (this._shown && GameState.Instance != null && this.Registry != null) this.Registry.Text = "THE REGISTRY\n\n" + GameState.Instance.UnsealRegistry(); ``` So the flag is not gated behind a comparison at all. `UnsealRegistry()` runs unconditionally and decrypts with whatever mark you happen to be carrying — a wrong mark just produces a keystream that yields garbage, which the code politely renders as `▯` boxes. **The flag was on screen the whole time, XORed with the wrong key.** That reframes the whole challenge: this is not "find the win condition", it is "produce the correct key". ### Dead end #2: brute-forcing the mark The first instinct was to replace the method body with a loop over candidate marks and stop at the first fully printable result. Two things killed it: 1. `CarriedMark` is a **`ulong`**. In the metadata its field signature is `06 0B` — `FIELD`, then `ELEMENT_TYPE_U8`. A loop bounded at 10 million covers 0.00000000005% of the space. 2. `Archive._Process` is a Godot per-frame callback. Running a heavy loop inside it means 60 brute-force passes per second, so the game hangs on a black screen before the player can move. The hang was actually the useful signal — it forced a proper look at how the seal is derived rather than treating it as an opaque check. ### Reading IL without a working decompiler `dotnet tool install -g ilspycmd` failed in WSL (`snap-confine is packaged without necessary permissions`), and the challenge assembly is small enough that a decompiler is a convenience rather than a requirement. Switched to Python metadata tooling: ```python import dnfile pe = dnfile.dnPE("Overstrike.dll") for r in pe.net.mdtables.FieldRva.rows: print(hex(r.Rva), r.Field.row_index) ``` Two `FieldRVA` entries, both pointing at compiler-generated `` fields — the blobs that back C# static array initializers. The two nested types `__StaticArrayInitTypeSize=40` and `__StaticArrayInitTypeSize=56` give their exact lengths: | RVA | Length | Field | |---|---|---| | `0x2048` | 40 | `Main.BuildMarks` — five `ulong` values: `1, 2, 3, 5, 7` | | `0x2070` | 56 | `GameState.SealedRecord` — the encrypted registry text | Then the IL of `GameState.Mix`, disassembled with `dncil`: ``` ldarg.0 ldc.i8 -7046029254386353131 ; 0x9E3779B97F4A7C15 add dup ; ldc.i4.s 30 ; shr.un ; xor ldc.i8 -4658895280553007687 ; 0xBF58476D1CE4E5B9 mul dup ; ldc.i4.s 27 ; shr.un ; xor ldc.i8 -7723592293110705685 ; 0x94D049BB133111EB mul dup ; ldc.i4.s 31 ; shr.un ; xor ret ``` Those three constants are the giveaway. `0x9E3779B97F4A7C15` is the 64-bit golden-ratio increment, and the two multipliers with shifts 30/27/31 are the **splitmix64 finalizer**, verbatim. --- ## The break — splitmix64 runs backwards Splitmix64 is a bijection, and each of its five steps has a clean inverse: | Forward | Inverse | |---|---| | `x += 0x9E3779B97F4A7C15` | `x -= 0x9E3779B97F4A7C15` | | `x *= K` (K odd) | `x *= K⁻¹ mod 2⁶⁴` — odd constants are invertible | | `x ^= x >> s` | recoverable: the top `s` bits pass through untouched, and each recovered chunk reveals the next | The xor-shift is the step that looks lossy and isn't. Nothing is discarded — bits are only folded onto other bits, and the top slice arrives unmodified. Read the known top `s` bits, xor them back out of the next slice, repeat down the word. Iterating `x = y ^ (x >> s)` to a fixed point does it in a handful of rounds. So the required mark is just `Mix⁻¹(TrueSeal)`. No search: ```python MASK = (1 << 64) - 1 def unxorshift(y, s): x = y while True: c = y ^ (x >> s) if c == x: return x & MASK x = c def unmix(y): x = unxorshift(y, 31) x = (x * pow(0x94D049BB133111EB, -1, 1 << 64)) & MASK x = unxorshift(x, 27) x = (x * pow(0xBF58476D1CE4E5B9, -1, 1 << 64)) & MASK x = unxorshift(x, 30) return (x - 0x9E3779B97F4A7C15) & MASK ``` `unmix(0xD9A1BB0CABB52586)` → `0xD7CAAD24DD98B676`, and feeding it forward through `Mix` reproduces `TrueSeal` exactly. One value, instantly. ### Why the intended path is forgery `MarkPickup.OnBodyEntered` is a plain accumulator: ```csharp if (body is Player && GameState.Instance != null) { GameState.Instance.CarriedMark += this.Worth; QueueFree(); } ``` The five pickup worths are the `{1, 2, 3, 5, 7}` array from RVA `0x2048`. Maximum legitimately collectable mark: **18**. The target is ~1.55×10¹⁹. Collecting every pickup in the level cannot possibly align the seal — which confirms the challenge's own framing ("forge a seal the world cannot tell from genuine"). The mark is meant to be *forged*, not earned. --- ## Exploitation ### Path A — offline (no emulator) Everything needed is static: the ciphertext is in the assembly, the target seal is a hardcoded constant, and the keystream is deterministic. Reimplemented `UnsealRegistry` in Python, pulling the 56-byte record straight out of the PE by translating RVA `0x2070` to file offset `0x270`: ```python seed = hashlib.sha256(mark.to_bytes(8, "little")).digest() stream = b"".join(hashlib.sha256(seed + struct.pack("