Proofmark
A known-plaintext attack against murmur3's fmix32 finalizer recovered a stretched keystream generator in under a second, bypassing 1.2 million rounds of intended key-stretching.
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:
$ 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.
$ 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.
buf = zstd.ZstdDecompressor().decompress(data[12:], max_output_size=size * 4)
ident_count, const_count, line_count, token_count = struct.unpack_from("<IIII", buf, 0)
# then per identifier: u32 length, then length * u32 chars, each byte ^ 0xB6
That alone reconstructs the interface without a decompiler:
ForgeClient : Verdict { REJECT_STATE, REJECT_TOKEN, ACCEPTED }
ClassDB.class_exists("Proofmark") -> 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:
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
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
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:
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:
s0 = fmix32(E ^ 0x85EBCA6B); // E = F^1200000(token)
The keystream
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.
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:
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
fmix32make 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, 0x7b425448check exists so the code can returnREJECT_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. fmix32is 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_STATEandREJECT_TOKENlet 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 |
Something wrong with this page?
Wrong details, a stolen writeup, or something that should not be published here — tell a moderator. This does not go to the author.