Caffeine
$ rg

WRITEUP / W-001

Proofmark

Mobile challenge write-up from Hack The Box Cyber Apocalypse CTF 2026: The Salt Crown.

  • #mobile
  • #godot
  • #reverse engineering
  • #ctf

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.