EventHack The Box Cyber Apocalypse 2026
CategoryMobile
DifficultyEasy
Points950
OutcomeSolved
Overstrike challenge overview on Hack The Box

The assignment

The challenge described a forged authority mark that the world would accept as genuine. It supplied one Android APK and no live service.

Out of the Mobile category, this one grabbed my attention. I know Android well as an end user, but this was my first time taking apart a Godot mobile application. My task was to identify how the game validated its seal, determine why ordinary gameplay could not satisfy it, and recover the protected registry.

Executive summary

I treated the APK as an archive and found two copies of Overstrike.dll, one for ARM64 phones and one for x86-64 emulators. Their hashes matched, so I extracted the x86-64 copy and used ILSpyCmd to reconstruct its C# source.

The important path was:

CarriedMark -> Mix() -> WorldSeal -> compare with TrueSeal
       |
       +-> SHA-256 key derivation -> decrypt SealedRecord

Five in-game collectibles could only produce CarriedMark totals from 0 through 18. I reproduced the decompiled mixer in Python and verified that none reached the hard-coded TrueSeal.

The mixer constants identified a SplitMix64-style permutation. Its intended unsigned operations were reversible, so I inverted the target seal to recover a forged 64-bit mark. An unsigned forward calculation reproduced the target exactly. I used the recovered mark in a Python reproduction of the application's SHA-256 counter-mode XOR routine. It decrypted the 56-byte registry and recovered the redacted proof.

My first mobile challenge turned out to be C# reversing, modular arithmetic, and a cryptographic scavenger hunt wearing an APK.


Initial evidence

Inventorying the APK

I extracted the challenge package and recorded the APK's type and digest:

cd ~/Downloads
unzip mobile_overstrike.zip -d mobile_overstrike
cd mobile_overstrike

file Overstrike.apk
sha256sum Overstrike.apk

I then listed the components most likely to identify the application's implementation:

unzip -Z1 Overstrike.apk |
grep -E 'Overstrike\.dll$|AndroidManifest\.xml$|META-INF/.*\.(RSA|SF)$'
APK component listing showing Overstrike.dll, AndroidManifest.xml, and signing metadata

The output established three useful layers:

  • AndroidManifest.xml described the Android package and its components.
  • Overstrike.dll contained the challenge's C# game logic.
  • META-INF/ANDROIDD.SF and META-INF/ANDROIDD.RSA contained APK-signing metadata.

The DLL appeared twice:

assets/.godot/mono/publish/x86_64/Overstrike.dll
assets/.godot/mono/publish/arm64/Overstrike.dll

The two paths supported different processor architectures. ARM64 covered physical Android devices such as my future NetHunter OnePlus 8T, while x86-64 covered common emulator environments.

I extracted both assemblies and compared them:

mkdir -p extracted/arm64 extracted/x86_64

unzip -p Overstrike.apk \
  assets/.godot/mono/publish/arm64/Overstrike.dll \
  > extracted/arm64/Overstrike.dll

unzip -p Overstrike.apk \
  assets/.godot/mono/publish/x86_64/Overstrike.dll \
  > extracted/x86_64/Overstrike.dll

sha256sum extracted/*/Overstrike.dll
SHA-256 digests of both DLL copies confirming identical hashes

The hashes matched. I could analyze either copy and expect the same managed code.

Choosing the right decompiler

I checked which relevant tools were already installed:

command -v dotnet ilspycmd monodis jadx

Only JADX was available. JADX is useful for an APK's Java or Kotlin layer, but the challenge logic lived in a .NET assembly.

Think of the APK as a shipping container. JADX examines the Android machinery surrounding the cargo. ILSpy examines the C# cargo itself. Ghidra would have been excessive unless the investigation reached native machine code.

I installed the .NET SDK and ILSpyCmd in my user directories, then verified the tool:

curl -fsSL https://dot.net/v1/dotnet-install.sh \
  -o /tmp/dotnet-install.sh

bash /tmp/dotnet-install.sh \
  --channel LTS \
  --install-dir "$HOME/.dotnet"

export DOTNET_ROOT="$HOME/.dotnet"
export PATH="$DOTNET_ROOT:$HOME/.local/bin:$PATH"

mkdir -p "$HOME/.local/bin"

dotnet tool install \
  --tool-path "$HOME/.local/bin" \
  ilspycmd

ilspycmd --version

This tooling decision mattered. The fastest route through a mobile package is often determined by the language that implements its behavior, not by the file extension presented to the analyst.


Investigation and solve path

Phase 1: Recover the C# source

I listed the custom classes in the assembly, then decompiled it as a C# project:

ilspycmd -l c extracted/x86_64/Overstrike.dll

rm -rf overstrike-source

ilspycmd -p \
  -o overstrike-source \
  extracted/x86_64/Overstrike.dll

find overstrike-source -maxdepth 2 -type f -printf '%p\n' | sort
Decompiled C# project file listing

The reconstructed project included several promising classes:

Archive.cs
BridgeBuilder.cs
GameState.cs
Main.cs
MarkPickup.cs

Decompiled source is not guaranteed to match the developer's formatting or local names. It does, however, preserve enough program behavior to trace state, constants, and transformations.

Phase 2: Map the verification mechanism

I searched the reconstructed source for terms associated with the challenge's seal and registry language:

rg -n -i \
  'flag|HTB|seal|sign|cert|hash|forge|archive|registry|vault|bridge' \
  overstrike-source

GameState.cs contained the complete path from the player's value to the game's Boolean decision:

nl -ba overstrike-source/GameState.cs |
sed -n '35,76p'
GameState.cs showing CarriedMark, WorldSeal, TrueSeal constant, and Mix() call in _Process

The important fields were:

public ulong CarriedMark;
public ulong WorldSeal;
public const ulong TrueSeal = 15682021040575554950uL;

The game continuously transformed the carried mark:

public override void _Process(double delta)
{
    WorldSeal = Mix(CarriedMark);
}

It considered the world aligned only when that result equaled the constant:

public bool WorldIsAligned =>
    WorldSeal == 15682021040575554950uL;

The relationship was now concrete:

CarriedMark -> Mix() -> WorldSeal
                          |
                          +-> equals TrueSeal? -> true or false

The same class also held a 56-byte SealedRecord. That was likely the protected proof, but I still needed the mark that would open it.

Phase 3: Trace the registry decryption

I captured the complete unsealing routine:

nl -ba overstrike-source/GameState.cs |
sed -n '75,112p'
UnsealRegistry() routine showing SHA-256 key derivation and XOR keystream construction

UnsealRegistry() gave CarriedMark a second purpose. It converted the 64-bit mark into eight little-endian bytes and hashed them with SHA-256:

array = sHA.ComputeHash(BitConverter.GetBytes(CarriedMark));

The function then generated a keystream in 32-byte blocks. Each block was:

SHA-256(mark_hash || little_endian_counter)

It XORed that keystream with SealedRecord until all 56 bytes were decoded.

At this point, the goal was precise. I needed the CarriedMark chosen by the challenge author. That value had to explain both the seal target and the encrypted record.

Phase 4: Determine what gameplay could produce

I traced every use of CarriedMark:

rg -n -C 4 'CarriedMark' \
  overstrike-source \
  --glob '*.cs'

MarkPickup.cs contained the only gameplay update:

GameState.Instance.CarriedMark += Worth;

Each collectible added its Worth and then removed itself from the scene.

I followed Worth into Main.cs:

rg -n -C 5 'Worth\s*=' overstrike-source

nl -ba overstrike-source/Main.cs |
sed -n '570,616p'
Main.cs showing five collectible Worth values assigned as 1, 2, 3, 5, 7

The five collectibles were assigned these values:

ulong[] array = new ulong[5] { 1uL, 2uL, 3uL, 5uL, 7uL };

Collecting all five produced 18. Any subset produced a mark between 0 and 18.

That reduced the legitimate state space to only 19 candidates.

Phase 5: Prove normal gameplay could not align the world

I reproduced the decompiled signed 64-bit behavior and tested every legitimate total:

MASK = (1 << 64) - 1
TRUE_SEAL = 15682021040575554950


def i64(value):
    value &= MASK
    return value if value < (1 << 63) else value - (1 << 64)


def mix(x):
    num = i64(i64(x) + -7046029254386353131)
    num2 = i64((num ^ (num >> 30)) * -4658895280553007687)
    num3 = i64((num2 ^ (num2 >> 27)) * -7723592293110705685)
    return (num3 ^ (num3 >> 31)) & MASK


for mark in range(19):
    seal = mix(mark)
    status = " MATCH" if seal == TRUE_SEAL else ""
    print(f"mark={mark:2d} seal=0x{seal:016x}{status}")

I saved that as test_seals.py and ran it:

python3 test_seals.py
test_seals.py output showing all 19 legitimate marks with no match against TrueSeal

None of the 19 possible gameplay totals produced the required seal.

The decompiled mixer also used signed long intermediates. Its arithmetic right shifts copied the sign bit, while the target 0xd9a1bb0cabb52586 occupied the upper half of the unsigned 64-bit range. The observed signed verification path therefore could not reach the target.

Normal gameplay was exhausted. Before reversing the mixer, I checked whether another code path supplied a larger mark:

rg -n -i \
  'Android|PackageManager|SigningInfo|Certificate|Signature|CarriedMark' \
  overstrike-source
Source search showing no second writer for CarriedMark and no Android signing integration

I found no second writer. That ruled out the possibility that the APK derived a hidden mark from its Android signing certificate or another platform value.

Phase 6: Invert the seal transformation

The constants in Mix() corresponded to the SplitMix64 finalizer:

0x9E3779B97F4A7C15
0xBF58476D1CE4E5B9
0x94D049BB133111EB

Under unsigned 64-bit arithmetic, SplitMix64 is a permutation. XOR-with-right-shift operations can be undone iteratively, and multiplication by an odd constant can be reversed with its modular inverse modulo 2^64.

I implemented those inverse operations in forge_mark.py:

MASK = (1 << 64) - 1
TRUE_SEAL = 15682021040575554950


def undo_xor_shift(value, shift):
    result = value
    for _ in range(8):
        result = value ^ (result >> shift)
    return result & MASK


value = undo_xor_shift(TRUE_SEAL, 31)
value = value * pow(0x94D049BB133111EB, -1, 1 << 64) & MASK
value = undo_xor_shift(value, 27)
value = value * pow(0xBF58476D1CE4E5B9, -1, 1 << 64) & MASK
value = undo_xor_shift(value, 30)

mark = (value - 0x9E3779B97F4A7C15) & MASK

print(f"forged_mark={mark}")
print(f"forged_mark_hex=0x{mark:016x}")

Running it produced the forged mark:

python3 forge_mark.py
forge_mark.py output showing forged mark value

Bam. Forged mark.

I did not trust an inverse calculation without a forward check. I passed the recovered mark through unsigned SplitMix64 and compared it with TrueSeal:

python3 - <<'PY'
MASK = (1 << 64) - 1
mark = 0xd7caad24dd98b676
true_seal = 0xd9a1bb0cabb52586

x = (mark + 0x9E3779B97F4A7C15) & MASK
x = ((x ^ (x >> 30)) * 0xBF58476D1CE4E5B9) & MASK
x = ((x ^ (x >> 27)) * 0x94D049BB133111EB) & MASK
seal = (x ^ (x >> 31)) & MASK

print(f"calculated_seal=0x{seal:016x}")
print(f"true_seal      =0x{true_seal:016x}")
print(f"match={seal == true_seal}")
PY
Forward verification output showing calculated_seal matches true_seal and match=True

The result was:

calculated_seal=0xd9a1bb0cabb52586
true_seal      =0xd9a1bb0cabb52586
match=True

The forged mark reproduced the intended target exactly.

Phase 7: Unseal the registry

I fed the same mark into the application's SHA-256 and XOR construction:

import hashlib
import struct

mark = 0xd7caad24dd98b676

sealed = bytes([
    13, 86, 51, 68, 18, 110, 68, 15, 54, 61,
    236, 94, 135, 202, 213, 182, 4, 1, 182, 181,
    150, 228, 184, 126, 121, 224, 236, 220, 7, 82,
    153, 251, 179, 104, 0, 87, 32, 34, 3, 60,
    166, 96, 124, 50, 253, 31, 124, 179, 220, 157,
    120, 115, 19, 47, 96, 11
])

mark_hash = hashlib.sha256(struct.pack("<Q", mark)).digest()

keystream = b""
counter = 0
while len(keystream) < len(sealed):
    keystream += hashlib.sha256(
        mark_hash + struct.pack("<I", counter)
    ).digest()
    counter += 1

record = bytes(
    encrypted ^ key
    for encrypted, key in zip(sealed, keystream)
)

print(record.decode())

I saved the decoder as unseal_registry.py and ran it:

python3 unseal_registry.py
Registry decryption output with the flag value redacted
Recovered proof: HTB{REDACTED}

The result was a readable HTB proof. The complete value is intentionally redacted from this public write-up.


Technical explanation

The APK exposed its authoritative logic

The application needed Overstrike.dll to run, so every relevant field, constant, collectible value, hash operation, and encrypted byte was distributed to the player.

APK signing protected package integrity and publisher identity. It did not encrypt the application's managed code. Once the assembly was extracted, ILSpy could reconstruct its behavior in readable C#.

The legitimate mark space was finite

The pickup values were 1, 2, 3, 5, and 7, and each object could be collected once. This bounded CarriedMark to subset sums between 0 and 18.

Exhaustively checking 19 values was stronger than assuming the target was unreachable from visual inspection. The script reproduced the application's signed behavior and tested the complete legitimate state space.

The mixer was reversible

SplitMix64 uses addition, multiplication by odd constants, and XOR with right-shifted copies of the state. Each operation is invertible over 64-bit unsigned integers:

  • Addition is reversed by subtraction modulo 2^64.
  • Multiplication is reversed with a modular multiplicative inverse.
  • XOR-right-shift is reversed by recovering the shifted bits iteratively.

The transformation looked hash-like, but it was a bijection rather than a one-way hash.

Because the decompiled path used signed long intermediates, its final arithmetic right shift prevented the result from reaching a target with the high bit set. I treated the constants as evidence of the intended unsigned SplitMix64 mapping, inverted that mapping, and validated the recovered mark through an unsigned forward calculation and successful registry decryption.

The registry used a deterministic XOR keystream

UnsealRegistry() hashed the eight-byte mark, appended a little-endian block counter, and hashed again to generate each keystream block:

mark_key = SHA-256(little_endian_64(CarriedMark))

block_0 = SHA-256(mark_key || little_endian_32(0))
block_1 = SHA-256(mark_key || little_endian_32(1))

plaintext = SealedRecord XOR (block_0 || block_1)

The record was 56 bytes, so two SHA-256 blocks supplied enough keystream. Recovering the mark therefore recovered the decryption key material.


Proof of completion

Three separate checks supported the result:

All legitimate marks tested: 0 through 18
Legitimate matches: 0
Unsigned forward calculation reproduced TrueSeal: yes
Registry output used the HTB flag format: yes
Challenge submission: accepted
Challenge submission confirmation on Hack The Box

The screenshot containing the complete decoded flag is omitted. The public page preserves the validation evidence without publishing the proof value.


Security impact and remediation

Overstrike illustrates a familiar mobile security boundary: code and constants shipped to an untrusted client can be inspected and reproduced. Obfuscation or package signing may raise the cost of analysis, but neither can keep client-side validation logic secret.

Production mobile applications should keep authoritative validation and secret-bearing operations on trusted infrastructure. Client-side encrypted data should not ship beside its complete key-derivation process. When local encrypted storage is required, use a reviewed authenticated-encryption construction, minimize exposed sensitive state, and treat APK signing as an integrity and provenance control.

The APK's signature could confirm who packaged the application. It could not stop me from reading the package.


What I learned

This challenge gave me a practical map of a mobile application's layers. The APK was the container, Android metadata described the package, and the Godot .NET assembly held the behavior I needed to understand.

Tool choice made far more sense once I followed the implementation language. JADX was present, but Java decompilation would have kept me in the wrapper. ILSpy moved the analysis to the C# logic that controlled the game.

The rest connected familiar ideas in a new setting:

  • Source tracing reduced player behavior to five collectible values.
  • Exhaustive testing proved that the legitimate state space failed.
  • Constant recognition turned an intimidating mixer into reversible arithmetic.
  • A forward check validated the recovered preimage.
  • Reimplementing the unsealing function connected that preimage to the protected record.

This was my first mobile challenge, and I needed assistance to connect several unfamiliar layers. By the end, I could explain how the APK, Godot assembly, seal transformation, and registry decryption fit together. That was exactly what I wanted from the challenge.


Attribution

Solved and documented by Jenn Shagrin during Hack The Box Cyber Apocalypse 2026 as a member of the CIAT Cybersecurity Club team. I inventoried and extracted the APK, selected and installed the .NET analysis tooling, decompiled the Godot assembly, traced the seal and pickup state, ran the exhaustive verifier, executed and validated the inversion and decryption scripts, collected the evidence, and submitted the proof.

Consistent with the event's rules of engagement, I used AI as a supporting tool. It materially assisted with explaining the APK and Godot layers, selecting ILSpyCmd, identifying and reversing the SplitMix64-style construction, developing the Python verification and decryption workflow, and checking the resulting technical explanation. It did not access the challenge, operate my environment, collect evidence, or submit the flag.

No teammate contribution is attributed because none is documented in the source evidence for this challenge.


Full Attack Chain

  1. Inventoried the APK and identified its Godot C# assemblies.
  2. Extracted the matching ARM64 and x86-64 copies of Overstrike.dll.
  3. Decompiled the managed assembly with ILSpyCmd.
  4. Traced CarriedMark through Mix(), WorldSeal, and WorldIsAligned.
  5. Recovered the five collectible values and bounded legitimate marks to 0 through 18.
  6. Tested the complete legitimate state space and found no match.
  7. Ruled out another writer for CarriedMark.
  8. Recognized and inverted the SplitMix64-style transformation.
  9. Verified that an unsigned forward calculation reproduced TrueSeal.
  10. Reimplemented UnsealRegistry() and decrypted the protected record.
  11. Submitted the redacted proof successfully.