[THUGS(red)]

Overstrike

Recovered a forged 64-bit game-state value by inverting the splitmix64 finalizer used to validate it, then patched the value directly into the compiled .NET assembly.

overstrike-writeup.md 12 KiB

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.


$ 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:


$ 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:


$ 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:


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:


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 0BFIELD, 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:


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 <PrivateImplementationDetails> 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:


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:


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:


seed   = hashlib.sha256(mark.to_bytes(8, "little")).digest()
stream = b"".join(hashlib.sha256(seed + struct.pack("<i", c)).digest()
                  for c in range(2))
flag   = bytes(a ^ b for a, b in zip(record, stream))

$ python3 unseal.py Overstrike.dll -v
target WorldSeal : 0xd9a1bb0cabb52586
required mark    : 0xd7caad24dd98b676  (15549431037298259574)
decrypted (clean ASCII): HTB{INSERT_YOUR_CAPTURED_FLAG}

Byte-order detail worth noting: BitConverter.GetBytes on a ulong is little-endian 8 bytes on this platform, and the counter appended per keystream block is a 4-byte little-endian int. Get either wrong and the output stays garbage with no hint as to which half was wrong.

Path B — in-game (patch and run)

To see it render in the app rather than in a terminal, the whole patch is one assignment. In dnSpyEx, edit GameState._EnterTree:


public override void _EnterTree()
{
    GameState.Instance = this;
    this.CarriedMark = 0xD7CAAD24DD98B676UL;   // forged
}

_Process then mixes it into a seal matching TrueSeal, WorldIsAligned flips true, the bridge tiles get collision, and walking into the archive prints the decrypted registry. File → Save Module, then swap the single asset back into the APK. apktool is the wrong tool here — nothing about resources changed, and a full rebuild risks mangling Godot's assets. A plain zip update is cleaner:


$ cp Overstrike.apk patched.apk
$ cd ext && zip ../patched.apk 'assets/.godot/mono/publish/x86_64/Overstrike.dll' && cd ..
$ zipalign -p -f 4 patched.apk aligned.apk
$ keytool -genkey -v -keystore mine.jks -keyalg RSA -keysize 2048 -validity 10000 -alias dev
$ apksigner sign --ks mine.jks aligned.apk
$ apksigner verify --print-certs aligned.apk
Signer #1 certificate DN: [...]

The zip path must match the original exactly or Mono won't find the assembly. Uninstall the original before installing — Android refuses an update signed by a different key. If testing on real hardware rather than an x86_64 emulator, patch the arm64 copy too.

---

Lessons & defenses

  • A client-side comparison protects nothing. WorldIsAligned gates rendering, not the decryption. Because UnsealRegistry() runs regardless of state, the ciphertext and the routine that opens it both shipped to the client; only the key was withheld — and the key was derivable from a constant sitting next to it. Any secret that must not reach the user has to be gated server-side.
  • Don't derive a secret with a bijection. Splitmix64 is a fast, high-quality mixer, not a one-way function. Using it to relate a public target to a required input means the input is one modular inverse away. A keyed hash — or simply comparing SHA-256(mark) against a stored digest — would have forced a real search.
  • Managed assemblies are source code with extra steps. Godot's C# builds ship IL with full names and metadata intact. Where the logic is genuinely sensitive, put it in a GDExtension/native module, or at minimum obfuscate names — and understand that this raises cost, not impossibility.
  • Integrity checking would have raised the bar on Path B. Nothing in the APK validated its own signature or the assembly's hash, so a resign-and-run patch worked unimpeded. Checking the installer signature at startup wouldn't stop the offline solve, but it would break naive repacks.

---

Tools used

| Tool | Purpose |

|---|---|

| unzip / zip | APK triage and single-asset repack without a full rebuild |

| dnSpyEx | Read and edit the managed assembly; recompile a method and save the module |

| dnfile (Python) | Parse .NET metadata tables — field signatures, FieldRVA, array-init blobs |

| dncil (Python) | Disassemble method bodies to IL when a decompiler wasn't available |

| zipalign / apksigner / keytool | Align, key, and re-sign the patched APK |

| Python 3 (hashlib, struct) | Invert splitmix64 and reimplement the keystream offline |

| BlueStacks | x86_64 Android target for running the patched build |

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.