Overstrike
Overstrike
Platform: CACTF2026 | Category: Reversing | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-27 | Status: Solved Techniques: ilspy_decompilation, il_fieldrva_extraction, splitmix64_inversion, modular_inverse_mod_2_64, xorshift_right_inversion_bitwise, sha256_ctr_keystream, xor_stream_decryption
Summary
Task: Godot 4.7 Android APK built with C#/Mono/.NET; game hides a flag decrypted with a SHA256-CTR keystream keyed by a CarriedMark that must satisfy an invertible splitmix64 mix equal to a hardcoded TrueSeal. Solution: decompile Overstrike.dll with ILSpy, extract the 56-byte SealedRecord from the IL FieldRVA blob, invert the splitmix64 Mix (modular inverse mod 2^64 + bitwise xorshift-right inversion) to recover CarriedMark, then rebuild the SHA256 counter-mode keystream and XOR to reveal the flag.
Overstrike — CACTF2026
Description
The Signet is shattered, and its silence has a shape now: House Vaultrune's quiet trade in re-cut marks, genuine authority lifted and struck anew, each forgery slipped into the Registry so the lie reads as record. Elric goes down under Crownspire, toward the sealed archive Chancellor Veylen Marr keeps, and finds the old vow-stones of the Ash-Vault will not bridge the fire-rift for any mark he carries. They answer only to the true seal. To reach the truth the forgers have filed as fact, Elric must do the very thing that condemns them, and cross on a seal of his own making, one the world itself cannot tell from genuine.
English summary: We are given an Android APK for a Godot game. Somewhere inside, a "Registry" record is encrypted. The story is a precise cryptographic hint: forge the mark (CarriedMark) so the derived world seal (WorldSeal = Mix(CarriedMark)) equals the hardcoded true seal (TrueSeal), which is exactly the key needed to decrypt the sealed record and read the flag.
Semantic clue — "Overstrike" literally means to re-strike / re-cut a seal (mark). The task is not to satisfy a comparison but to reproduce the exact input that produces a target output — i.e., invert the mixing function.
Analysis
1. Download and APK triage
The download URL returns a ~106 MB ZIP; unzipping yields Overstrike.apk (~201 MB uncompressed). Unpacking the APK:
bash read-only • copy allowedunzip Overstrike.apk -d apk_out
The contents identify a Godot 4.7 engine game built with C# (Mono/.NET 9):
assets/.godot/— Godot engine dataassets/scripts/*.cs— all 1-byte stubs (source stripped from the shipped build)lib/arm64-v8a/libgodot_android.so,libmonosgen-2.0.so— native Godot + Mono runtime- bundled .NET assemblies
kotlin-tooling-metadata.json— confirms the Godot Android + Kotlin wrapper
The AssemblyHasScripts attribute lists class names that map neatly onto the story: Archive, BridgeBuilder, GameState, MarkPickup, Player, etc. This tells us the real logic lives in the compiled C# assembly, not in the (stubbed) .cs files.
2. Locate and decompile the game assembly
The C# game logic compiles into Overstrike.dll:
assets/.godot/mono/publish/arm64/Overstrike.dll (also an x86_64 copy)
file reports it as a PE32+ Mono/.NET assembly. Decompile with ILSpy's CLI (ilspycmd, which needs the .NET runtime):
bash read-only • copy allowedexport DOTNET_ROOT=/opt/homebrew/Cellar/dotnet/10.0.101/libexec ilspycmd Overstrike.dll -o decompiled # C# output ilspycmd -il Overstrike.dll -o il_out # IL output (needed for the embedded byte array)
3. Understand GameState
The core is in GameState (res://scripts/GameState.cs):
csharp read-only • copy allowedpublic const ulong TrueSeal = 15682021040575554950; // each frame: WorldSeal = Mix(CarriedMark); public bool WorldIsAligned => WorldSeal == TrueSeal; 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)); }
Mix is a splitmix64-style finalizer: an additive constant, then two (x ^ (x >>> s)) * C steps, then a final xorshift. Every step is invertible.
The record decryption:
csharp read-only • copy allowed// UnsealRegistry(): key = SHA256(BitConverter.GetBytes(CarriedMark)); // little-endian ulong // for each 32-byte block: block = SHA256(key || BitConverter.GetBytes(counter)); // little-endian int32 counter, starts at 0 plaintext[i] = SealedRecord[i] ^ keystream[i];
Non-printable bytes are shown as a placeholder glyph, so only the correct key produces ASCII.
Key insight (the trap): The decryption key is SHA256(CarriedMark) — it depends on CarriedMark itself, not on the seal. Simply making Mix(CarriedMark) == TrueSeal at runtime isn't enough; you must recover the exact CarriedMark value. That requires inverting Mix, not brute-forcing the comparison.
4. Extract SealedRecord from the IL
The 56-byte ciphertext is a static initialized array. In Overstrike.il, the FieldRVA blob at label I_00010AA0 (__StaticArrayInitTypeSize=56) holds:
0d 56 33 44 12 6e 44 0f 36 3d ec 5e 87 ca d5 b6
04 01 b6 b5 96 e4 b8 7e 79 e0 ec dc 07 52 99 fb
b3 68 00 57 20 22 03 3c a6 60 7c 32 fd 1f 7c b3
dc 9d 78 73 13 2f 60 0b
Solution
Inverting Mix (splitmix64 finalizer)
Work everything as unsigned 64-bit. The C# signed long add/mul/xor and the unsigned >>> shift produce identical bit patterns to Python's masked unsigned arithmetic, so we can safely reason in mod 2^64.
To invert y = Mix(x), reverse each step:
- Invert the final
y = a ^ (a >> 31). - Multiply by the modular inverse of
-7723592293110705685(mod 2^64). - Invert the
x ^ (x >> 27)xorshift. - Multiply by the modular inverse of
-4658895280553007687. - Invert the
x ^ (x >> 30)xorshift. - Subtract the additive constant
-7046029254386353131.
Odd constants are invertible mod 2^64 (pow(C, -1, 1<<64)).
PITFALL — xorshift-right inversion: The commonly used iterative "doubling the shift" xorshift-inversion trick failed here for these particular shift amounts. Use a robust bit-by-bit high-to-low reconstruction of x from y = x ^ (x >> s). Because bit i of y depends only on bit i of x and the already-known higher bit i+s, reconstructing from the most-significant bit downward is exact:
python read-only • copy alloweddef inv_xorshift_right(y, s): x = 0 for i in range(63, -1, -1): bit = ((y >> i) & 1) ^ (((x >> (i + s)) & 1) if i + s < 64 else 0) x |= bit << i return x
Inverting TrueSeal yields:
CarriedMark = 15549431037298259574 (verified: Mix(CarriedMark) == TrueSeal)
Rebuild the keystream and decrypt
key = SHA256( pack_LE_uint64(CarriedMark) )
block(c) = SHA256( key || pack_LE_int32(c) ) # c = 0, 1, 2, ...
plaintext = SealedRecord XOR concat(block(0), block(1), ...)
XORing the 56-byte SealedRecord against the keystream produces the ASCII flag.
Full working solver
python read-only • copy allowed#!/usr/bin/env python3 import hashlib, struct MASK = (1 << 64) - 1 TRUE_SEAL = 15682021040575554950 C_ADD = (-7046029254386353131) & MASK M1 = (-4658895280553007687) & MASK M2 = (-7723592293110705685) & MASK def mix(x): x &= MASK n = (x + C_ADD) & MASK n2 = ((n ^ (n >> 30)) * M1) & MASK n3 = ((n2 ^ (n2 >> 27)) * M2) & MASK return (n3 ^ (n3 >> 31)) & MASK def inv_rs(y, s): # robust bit-by-bit inversion of y = x ^ (x >> s) x = 0 for i in range(63, -1, -1): bit = ((y >> i) & 1) ^ (((x >> (i + s)) & 1) if i + s < 64 else 0) x |= bit << i return x & MASK M1i = pow(M1, -1, 1 << 64) M2i = pow(M2, -1, 1 << 64) def unmix(y): n3 = inv_rs(y & MASK, 31) n2 = inv_rs((n3 * M2i) & MASK, 27) n = inv_rs((n2 * M1i) & MASK, 30) return (n - C_ADD) & MASK mark = unmix(TRUE_SEAL) assert mix(mark) == TRUE_SEAL, "Mix inversion failed" sealed = bytes(int(b, 16) for b in ( "0d 56 33 44 12 6e 44 0f 36 3d ec 5e 87 ca d5 b6 " "04 01 b6 b5 96 e4 b8 7e 79 e0 ec dc 07 52 99 fb " "b3 68 00 57 20 22 03 3c a6 60 7c 32 fd 1f 7c b3 " "dc 9d 78 73 13 2f 60 0b" ).split()) key = hashlib.sha256(struct.pack('<Q', mark)).digest() out = bytearray(len(sealed)) n = 0 c = 0 while n < len(sealed): blk = hashlib.sha256(key + struct.pack('<i', c)).digest() for b in blk: if n >= len(sealed): break out[n] = sealed[n] ^ b n += 1 c += 1 print(out.decode()) # HTB{...}
Running the solver prints the flag.