| Event | Hack The Box Cyber Apocalypse 2026 |
|---|---|
| Category | Mobile |
| Difficulty | Easy |
| Points | 950 |
| Outcome | Solved |
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)$'
The output established three useful layers:
AndroidManifest.xmldescribed the Android package and its components.Overstrike.dllcontained the challenge's C# game logic.META-INF/ANDROIDD.SFandMETA-INF/ANDROIDD.RSAcontained 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
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
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'
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() 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'
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
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
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
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
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
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
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
- Inventoried the APK and identified its Godot C# assemblies.
- Extracted the matching ARM64 and x86-64 copies of
Overstrike.dll. - Decompiled the managed assembly with ILSpyCmd.
- Traced
CarriedMarkthroughMix(),WorldSeal, andWorldIsAligned. - Recovered the five collectible values and bounded legitimate marks to
0through18. - Tested the complete legitimate state space and found no match.
- Ruled out another writer for
CarriedMark. - Recognized and inverted the SplitMix64-style transformation.
- Verified that an unsigned forward calculation reproduced
TrueSeal. - Reimplemented
UnsealRegistry()and decrypted the protected record. - Submitted the redacted proof successfully.