# Proofmark — HTB Cyber Apocalypse 2026 (Mobile / GamePwn) | | | |---|---| | **Challenge** | Proofmark | | **Platform** | HackTheBox — Cyber Apocalypse 2026 ("The Salt Crown") | | **Category** | Mobile / GamePwn | | **Target** | Android APK — Godot 4.7, GDScript + native GDExtension | | **Techniques** | Godot bytecode extraction, stripped ELF reversing, murmur3 `fmix32` inversion, known-plaintext keystream recovery | | **Flag** | `HTB{p3rf3ct_f4c3_tru3_sp1n3}` | > Cyber Apocalypse 2026 has concluded — flag included above and below. --- ## TL;DR Vaultrune's assay is a 10 KB native GDExtension. It gates on two things: a four-integer state that must equal a hardcoded constant, and a 32-bit token that is stretched through 1.2 million rounds of murmur3's `fmix32` before seeding the keystream that decrypts the flag. The stretching makes the token unguessable by design — but `fmix32` is a bijection, and the plaintext starts with `HTB{`. Four known plaintext bytes pin four consecutive keystream states, which recovers the generator without ever knowing the token. Inverting the chain backwards afterwards also yields the token itself, for a legitimate in-game submission. --- ## Recon — where the logic actually lives Same engine family as Overstrike, different scripting stack: ```bash $ unzip -l proofmark.apk | grep -E '\.so$|\.gdc$|gdextension' lib/x86_64/libgodot_android.so lib/x86_64/libproofmark.x86_64.so # 10 KB assets/proofmark.gdextension assets/scripts/{Anvil,ForgeClient,GameState,WardCutter,HUD,...}.gdc ``` No Mono this time. GDScript compiled to `.gdc`, plus a native GDExtension — and that split is the whole design. Anything in GDScript is presentation; the assay is native. ```bash $ file lib/x86_64/libproofmark.x86_64.so ELF 64-bit LSB shared object, x86-64, stripped $ nm -D --defined-only lib/x86_64/libproofmark.x86_64.so 0000000000002210 T proofmark_library_init $ objdump -R lib/x86_64/libproofmark.x86_64.so | grep JUMP ... __cxa_finalize __cxa_atexit __register_atfork memcpy ``` One export, and `memcpy` as the only meaningful import. Everything the challenge does is self-contained — no libcrypto to fingerprint, so the primitive would have to be identified from its constants. ### Reading the GDScript `strings` on a `.gdc` is noise, because Godot 4 stores compiled scripts as a `GDScriptTokenizerBuffer`: `GDSC` magic, a version word, the decompressed size, then a zstd stream. Decompress from offset 12 and the identifier pool sits at the front — UTF-32LE with every byte XORed against `0xB6`. ```python buf = zstd.ZstdDecompressor().decompress(data[12:], max_output_size=size * 4) ident_count, const_count, line_count, token_count = struct.unpack_from(" instantiate() reseal(state) -> PackedByteArray, read back via decode_s32 at 0/4/8/12 submit(mark: Array) -> int certificate() -> String GameState : wards: PackedInt32Array, _resync_hallmark() -> 24-byte snapshot ``` Two named rejection paths is the useful detail. It tells you before touching the disassembly that there are exactly two gates, and that the game will tell you which one you failed. --- ## The assay `proofmark_library_init` is boilerplate — it resolves ~15 engine functions by name and registers the class. The verdict function is the one worth reading, at `0x20d0`, and it is remarkably short. Signature, reconstructed from the call site at `0x2d2c`: ```c int verdict(const void *state, int state_len, uint32_t token, char *out, int out_cap); ``` The caller packs four Variant ints into a 16-byte stack buffer and passes a fifth as `token`, so the GDScript-side `submit(mark)` is really `submit([w0, w1, w2, w3, token])`. ### Gate 1 — the state ```asm 20d2: cmp esi,0x10 20d7: movdqu xmm0,XMMWORD PTR [rdi] 20db: pxor xmm0,XMMWORD PTR [rip+0x...] ; .rodata:0x530 20e3: ptest xmm0,xmm0 20e8: je 20eb 20ea: ret ; eax = 0 -> REJECT_STATE ``` A single 16-byte SIMD compare against `.rodata:0x530`: ``` 0530 53000000 43000000 37000000 ce010000 ``` Four little-endian int32: **83, 67, 55, 462**. That's the accepted ward configuration, sitting in the binary in plain sight. No search needed for gate one. ### Gate 2 — the token, deliberately expensive ```asm 20eb: mov eax,0x124f80 ; 1,200,000 20f0: add edx,0xc2b2ae35 ... ; fmix32, twice per iteration 213e: add eax,0xfffffffe ; eax -= 2 2141: jne 20f0 ``` `0x85EBCA6B` and `0xC2B2AE35` with shifts of 16, 13, 16 are murmur3's `fmix32` finaliser, unmistakably. The loop body applies it twice and decrements by two, so the token goes through **1.2 million rounds** of: ```c F(x) = fmix32(x + 0xC2B2AE35) ``` That is a deliberate key-stretch. Brute-forcing a 32-bit token at 1.2M rounds each is ~5×10¹⁵ operations — the challenge is telling you not to bother. Post-loop, one more transform produces the generator seed: ```c s0 = fmix32(E ^ 0x85EBCA6B); // E = F^1200000(token) ``` ### The keystream ```asm 2180: add esi,0xc2b2ae35 ; state = F(state) ... 21a7: shr edi,0x18 ; keystream byte = state >> 24 21aa: xor dil,BYTE PTR [rax+rdx*1] 21ae: mov BYTE PTR [rsp+rax*1],dil 21b5: cmp rax,0x1c ; 28 bytes 21ce: cmp edx,0x7b425448 ; "HTB{" ``` 28 ciphertext bytes at `.rodata:0x6a0`, XORed against the top byte of each successive state. Then — and this is the mistake that undoes the whole design — the function **verifies its own output starts with `HTB{`** before returning `ACCEPTED`, so it can distinguish a correct token from a wrong one. That check is a gift: it confirms in the binary itself that the first four plaintext bytes are known. --- ## The break — known plaintext against a bijection The stretching only protects the token. It does nothing for the *generator*, because `fmix32` is a bijection: each state maps to exactly one successor, and the keystream is just the top byte of each state in sequence. Four known plaintext bytes give four known keystream bytes: ``` ks[i] = ct[i] ^ "HTB{"[i] ``` `ks[0]` is the top byte of state `s1` — so 8 of its 32 bits are known outright, leaving 2²⁴ candidates. Each candidate determines `s2`, `s3`, `s4` deterministically, and the next three known bytes filter them. Twenty-four bits of unknown against twenty-four bits of constraint: expect exactly one survivor. ```c static inline uint32_t step(uint32_t s){ s += 0xc2b2ae35u; s ^= s>>16; s *= 0x85ebca6bu; s ^= s>>13; s *= 0xc2b2ae35u; s ^= s>>16; return s; } for (uint64_t x = 0; x < (1ull<<24); x++) { uint32_t s = ((uint32_t)ks[0] << 24) | (uint32_t)x, s1 = s; int ok = 1; for (int i = 1; i < 4; i++) { s = step(s); if ((s>>24) != ks[i]) { ok = 0; break; } } if (!ok) continue; /* s1 is the generator - roll it forward 28 times and XOR */ } ``` Sub-second, single survivor: `s1 = 0x62C439B9`. ``` HTB{p3rf3ct_f4c3_tru3_sp1n3} ``` The 1.2 million rounds were never executed. The stretch guards the door; the known plaintext goes through the window. ### Recovering the token anyway For a legitimate in-game submission, walk the same chain backwards. Every step inverts — the xor-shifts by iterating to a fixed point, the multiplies by modular inverse mod 2³², since both constants are odd: ```python i1, i2 = pow(0x85ebca6b, -1, 1<<32), pow(0xc2b2ae35, -1, 1<<32) def unfmix(h): h = unxor(h,16); h = (h*i2) & M h = unxor(h,13); h = (h*i1) & M return unxor(h,16) s0 = Finv(s1) E = unfmix(s0) ^ 0x85EBCA6B token = E for _ in range(1_200_000): token = Finv(token) ``` Two seconds, verified by running it forward again: ``` wards : [83, 67, 55, 462] token : 1909694721 (0x71D3A101) ``` `submit([83, 67, 55, 462, 1909694721])` returns `ACCEPTED`, and `certificate()` hands over the flag in-app. "One strike, no second" costs nothing when the strike is computed in advance. --- ## Lessons & defenses - **Key stretching protects a secret, not a stream.** 1.2M rounds of `fmix32` make the token infeasible to guess and are completely irrelevant once the keystream state is recovered by another route. The cost was paid at the wrong point in the pipeline. - **Never validate your own plaintext next to the ciphertext.** The `cmp edx, 0x7b425448` check exists so the code can return `REJECT_TOKEN` — and it hands the attacker a confirmed known-plaintext prefix. A stored digest of the *expected* plaintext would have served the same purpose without publishing four bytes of it. - **`fmix32` is a mixer, not a cipher.** Murmur3's finaliser is invertible by construction. Used as a stream generator it gives no forward secrecy: one recovered state exposes the entire stream in both directions. Same class of error as splitmix64 in Overstrike — fast mixers keep showing up where a PRF is needed. - **Two distinct rejection codes are an oracle.** `REJECT_STATE` and `REJECT_TOKEN` let an attacker solve the gates independently instead of jointly. A single opaque failure would have forced both to be right simultaneously. - **Native code raises cost, not impossibility.** Moving the assay out of GDScript into a GDExtension was the right instinct and did make this harder than a managed-assembly challenge. But a 10 KB stripped library with recognisable constants is a few hours of work — the real fix is that the decision belongs on a server the player doesn't control. --- ## Tools used | Tool | Purpose | |---|---| | `unzip` | APK triage and asset extraction | | Python + `zstandard` | Decompress `.gdc` token buffers, decode the XOR-0xB6 identifier pool | | `objdump` / `nm` | Section layout, imports, and disassembly of the stripped GDExtension | | Ghidra | Decompiler view of the verdict function | | C (gcc `-O2`) | 2²⁴ known-plaintext search for the keystream state | | Python | `fmix32` inversion to recover the accepted token |