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 — 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 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:
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.
WorldIsAlignedgates rendering, not the decryption. BecauseUnsealRegistry()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.