WRITEUP / W-001
Proofmark
Mobile challenge write-up from Hack The Box Cyber Apocalypse CTF 2026: The Salt Crown.
1. Checking the APK Structure
First, I checked the Manifest and the internal files of the APK.
aapt dump xmltree proofmark.apk AndroidManifest.xml
aapt dump permissions proofmark.apk
unzip -l proofmark.apk
In the Manifest, the launcher was com.godot.game.GodotAppLauncher, and the actual target activity was com.godot.game.GodotApp. The app was based on Godot 4.7, and most of the Java code was Godot/AndroidX runtime code.
The important files inside the APK were the following.
assets/proofmark.gdextension
lib/arm64-v8a/libproofmark.arm64.so
lib/x86_64/libproofmark.x86_64.so
Since proofmark.gdextension loads libproofmark, the actual challenge logic can be considered to be inside this native library.
2. Narrowing Down the Analysis Target
When restoring strings from the Godot compiled script, .gdc, the following clues appear.
ForgeClient
GameState
reseal
submit
certificate
PERFECT FACE. WRONG SPINE.
THE ASSAY CERTIFIES
This means the flow is that after the game state is created, ForgeClient calls reseal, submit, and certificate on the native object.
I also checked the strings in the native library.
strings -a -n 4 proofmark_extracted/lib/x86_64/libproofmark.x86_64.so \
| rg -i "proofmark|reseal|submit|certificate"
libproofmark registers the Proofmark class with Godot and exposes the reseal, submit, and certificate methods. From here, the target point for solving the challenge is the submit() validation logic.
3. Core Logic
The semantic interpretation and reconstruction of submit() are as follows.
submit(a, b, c, d, mark) {
packed = le32(a) + le32(b) + le32(c) + le32(d);
if (packed != target_bytes)
return REJECT;
cert = decrypt_certificate(mark);
if (!cert.starts_with("HTB{"))
return REJECT;
save_certificate(cert);
return ACCEPTED;
}
The reconstructed submit() flow is as follows.
submit(a, b, c, d, mark)
1. Pack a,b,c,d into 16 bytes in little-endian order.
2. Check whether this value matches the target bytes embedded in the native library.
3. Use mark as the seed, run the PRNG, and decrypt the certificate.
4. If the result starts with HTB{, it is treated as a success.
The target bytes used to validate the first four arguments were as follows.
53 00 00 00 43 00 00 00 37 00 00 00 ce 01 00 00
Interpreting them as little-endian integers:
(83, 67, 55, 462)
Therefore, the remaining task was to find the fifth argument, mark.
4. Restoring mark
mark was the PRNG seed used to decrypt the certificate. The native library contained the encrypted certificate bytes and the PRNG mix function.
Since the flag format is known to be HTB{...}, the seed can be derived so that the first 4 bytes of the decrypted result become HTB{. The resulting valid seed was the following.
mark = 0x71d3a101
Using this value to decrypt the full certificate gives:
HTB{p3rf3ct_f4c3_tru3_sp1n3}
5. Solver
The Python solver reproduces only the native PRNG and certificate decryption offline.
python solve.py
Execution result:
submit words : (83, 67, 55, 462)
submit mark : 0x71d3a101 (1909694721)
certificate : HTB{p3rf3ct_f4c3_tru3_sp1n3}
The solve.py :
MASK = 0xFFFFFFFF
A = 0x85EBCA6B
B = 0xC2B2AE35
C = 0xC2B2AE35
BURN_COUNT = 0x124F80
TARGET_WORDS = (83, 67, 55, 462)
CIPHER = bytes.fromhex(
"2a53db7ba35d34f55f59745e0043881c"
"a1136fb7f8d73f79c1b0af1a"
)
def u32(x):
return x & MASK
def unxorshr(y, shift):
x = y
s = shift
while s < 32:
x ^= x >> s
s *= 2
return u32(x)
INV_A = pow(A, -1, 1 << 32)
INV_B = pow(B, -1, 1 << 32)
def mix(x):
x = u32(x)
x = u32(x ^ (x >> 16))
x = u32(x * A)
x = u32(x ^ (x >> 13))
x = u32(x * B)
x = u32(x ^ (x >> 16))
return x
def inv_mix(y):
x = unxorshr(y, 16)
x = u32(x * INV_B)
x = unxorshr(x, 13)
x = u32(x * INV_A)
x = unxorshr(x, 16)
return x
def f(x):
return mix(u32(x + C))
def inv_f(y):
return u32(inv_mix(y) - C)
def decode_certificate(seed):
state = seed
for _ in range(BURN_COUNT):
state = f(state)
state = mix(state ^ A)
out = bytearray()
for c in CIPHER:
state = f(state)
out.append(((state >> 24) & 0xFF) ^ c)
return bytes(out)
def recover_seed(prefix=b"HTB{"):
need = [CIPHER[i] ^ prefix[i] for i in range(len(prefix))]
state4_high = need[3] << 24
candidates = []
for low24 in range(1 << 24):
s4 = state4_high | low24
s3 = inv_f(s4)
if (s3 >> 24) != need[2]:
continue
s2 = inv_f(s3)
if (s2 >> 24) != need[1]:
continue
s1 = inv_f(s2)
if (s1 >> 24) != need[0]:
continue
candidates.append(inv_f(s1))
for state0 in candidates:
seed = inv_mix(state0) ^ A
for _ in range(BURN_COUNT):
seed = inv_f(seed)
plain = decode_certificate(seed)
if plain.startswith(prefix) and plain.endswith(b"}") and all(32 <= b < 127 for b in plain):
return seed, plain.decode("ascii")
raise RuntimeError("No printable HTB-style certificate found")
def main():
seed, flag = recover_seed()
print(f"submit words : {TARGET_WORDS}")
print(f"submit mark : {seed:#010x} ({seed})")
print(f"certificate : {flag}")
if __name__ == "__main__":
main()
6. Flag
HTB{p3rf3ct_f4c3_tru3_sp1n3}
This write-up was originally written in Korean by human and translated into English with AI assistance.