← Back to Writeups
CUSTOMMediumReversing

Overstrike

XESXOR9/10/20267 min read
#reversing#custom#medium#ilspy_decompilation#il_fieldrva_extraction#splitmix64_inversion#modular_inverse_mod_2_64#xorshift_right_inversion_bitwi#sha256_ctr_keystream#xor_stream_decryption#linux

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
unzip Overstrike.apk -d apk_out

The contents identify a Godot 4.7 engine game built with C# (Mono/.NET 9):

  • assets/.godot/ — Godot engine data
  • assets/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
export 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
public 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
// 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:

  1. Invert the final y = a ^ (a >> 31).
  2. Multiply by the modular inverse of -7723592293110705685 (mod 2^64).
  3. Invert the x ^ (x >> 27) xorshift.
  4. Multiply by the modular inverse of -4658895280553007687.
  5. Invert the x ^ (x >> 30) xorshift.
  6. 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
def 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
#!/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. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍