$ cat writeup.md…
$ cat writeup.md…
d3c2026
Task: An Android APK gates a game and its encrypted flag behind a 64-hex token, obfuscated AArch64 JNI code, and an encrypted MNN model. Solution: Decrypt the native payload, solve its verifier symbolically, reconstruct callback state, and reimplement the AES KDF.
Enter the token and start playing the game.
The supplied app-debug.apk presents a handwriting-driven hexadecimal token entry screen. The objective is to recover the accepted token and reproduce the native flag-reveal path without relying on Android gameplay.
Unpacking the APK and opening its DEX files in JADX immediately established the high-level control flow:
MainActivity collects exactly 64 hexadecimal characters and calls FlagNative.nativeVerifyInput().GameActivity.FlagNative.nativeRevealFlag().FlagNative loads c++_shared, MNN, and d3llvm, so the real verification and reveal logic is native.MnnTouchClassifier uses a 64-position, 4-by-16 input state, while ModelAssets loads assets/model/touch_model.mnn.enc.The loader's JNI_OnLoad at libd3llvm.so VA 0xfd7c validates and loads libd3llvm_payload.so, makes its .text writable, decrypts it, flushes the AArch64 instruction cache, restores execute permissions, resolves Payload_OnLoad, and invokes it.
The payload's .signinfo structure begins at file offset 0x52b0. Its D3SGN2 metadata gives encrypted .text offset 0x11c20, length 0x2d7cc, a 16-byte nonce, and a 32-byte key. The loader generates each 32-byte keystream block as:
SHA256(key32 || nonce16 || LE64(counter))
XORing those blocks over the encrypted region produces valid AArch64 code. The decrypted JNI registration table at VAs 0x408c8..0x40950 maps nativeVerifyInput to 0x15938 and nativeRevealFlag to 0x15954; those wrappers lead to OLLVM-style flattened dispatchers at 0x292c4 and 0x2a444.
The verifier accepts only 64 hexadecimal characters. On success, it copies the 64 input bytes to global 0x43180, stores related state at 0x431c8, sets byte 0x431d0, and unlocks a mutex. The reveal routine consumes this verifier-produced state, so patching only the success byte is insufficient.
The terminal native constraint was isolated at 0x35330. Modeling its sixteen 16-bit input words with angr and requiring return register x0 == 1 yields the accepted token:
196f0d201332b47deb98221f33c7f4a13d03de6c2a77279c4dbc1f87e4d297a8
Direct Unicorn emulation was still useful: loading the ELF segments, applying AArch64 relocations, and mocking JNI/libc confirmed the invalid-token oracle and proved that reveal needs more state than one Boolean. It did not model the complete Android/MNN lifecycle, however, and therefore was an intermediate experiment rather than the final proof.
The encrypted model is AES-128-ECB ciphertext. Its 16-byte key is the misleading string b"This is 3DES\0\0\0\0". After AES decryption and PKCS#7 unpadding, the first 12 bytes are discarded and the remainder is Base64-decoded to obtain a valid MNN model.
Running that model with MNN callback introspection exposes 13 operator names:
getitem_raster_0 getitem getitem_3_raster_0 getitem_3 max_pool1d_raster_0 max_pool1d getitem_6_raster_0 getitem_6 mean_raster_0 mean_raster_1 logits__matmul_converted_raster_0 logits__matmul_converted logits_raster_0
The native after-callback adds the FNV-1a-64 hash of every operator name. Their sum gives per-run state 0x4280720401113ed2. All 64 token cells participate, so multiplication modulo 2^64 gives the final accumulator 0xa01c8100444fb480. No model prediction values are needed; only callback metadata contributes to this state.
The 48-byte encrypted blob starts at payload VA 0x4a94. The KDF at 0x2f664 performs these operations modulo 2^64:
0xd3c7f19a5eed2026 and 0xa11ce5c0dec0de42.This offline reconstruction is the final proof: it derives every input from the APK, the symbolically recovered token, and the real model operator names.
The essential logic from decrypt_payload.py is:
import hashlib import struct from pathlib import Path src = Path("unpacked/lib/arm64-v8a/libd3llvm_payload.so") blob = bytearray(src.read_bytes()) meta = blob[0x52b0:0x53b0] assert meta[:8] == b"D3SGN2\0\0" text_off, text_len = struct.unpack_from("<QQ", meta, 0x10) nonce = bytes(meta[0x28:0x38]) key = bytes(meta[0x38:0x58]) for counter, pos in enumerate(range(0, text_len, 32)): stream = hashlib.sha256(key + nonce + struct.pack("<Q", counter)).digest() for i in range(min(32, text_len - pos)): blob[text_off + pos + i] ^= stream[i] Path("libd3llvm_payload.decrypted.so").write_bytes(blob)
Run it from the task directory:
python3 decrypt_payload.py
angr_solve_constraint.py invokes the isolated check at base plus 0x35330, stores sixteen symbolic 16-bit words at a synthetic buffer, executes to completion, and constrains x0 to one:
import angr import claripy proj = angr.Project("libd3llvm_payload.decrypted.so", auto_load_libs=False) base = proj.loader.main_object.mapped_base buf = 0x70000000 words = [claripy.BVS(f"w{i}", 16) for i in range(16)] state = proj.factory.call_state( base + 0x35330, buf, base + 0x42fb8, prototype="int check(unsigned short *, void *)", add_options={angr.options.ZERO_FILL_UNCONSTRAINED_MEMORY, angr.options.ZERO_FILL_UNCONSTRAINED_REGISTERS}, ) for i, word in enumerate(words): state.memory.store(buf + 2 * i, word, endness=proj.arch.memory_endness) simgr = proj.factory.simulation_manager(state) simgr.run() for result in simgr.deadended + simgr.unconstrained: candidate = result.copy() candidate.solver.add(candidate.regs.x0 == 1) if candidate.solver.satisfiable(): print("".join(f"{candidate.solver.eval(w):04x}" for w in words)) break
python3 angr_solve_constraint.py
The model extraction is short:
import base64 from Crypto.Cipher import AES from Crypto.Util.Padding import unpad encrypted = open("unpacked/assets/model/touch_model.mnn.enc", "rb").read() plain = AES.new(b"This is 3DES\0\0\0\0", AES.MODE_ECB).decrypt(encrypted) model = base64.b64decode(unpad(plain, 16)[12:], validate=True) open("touch_model.decrypted.mnn", "wb").write(model)
Then run the supplied callback inspector to list the operators and calculate both sums:
python3 solve_crypto.py python3 inspect_model.py
The final brute_flag.py logic is shown below. The subset loop is a convenient independent check of which callbacks the native path counts; the matching subset is all 13 names and produces the known 64-cell accumulator. The script deliberately reports successful recovery without printing secret flag material into the writeup transcript.
#!/usr/bin/env python3 from Crypto.Cipher import AES MASK = (1 << 64) - 1 TOKEN = b"196f0d201332b47deb98221f33c7f4a13d03de6c2a77279c4dbc1f87e4d297a8" CT = bytes.fromhex( "f154eaeafaebf01674f062267087e584" "f3842f342f59283dbf515aacf4cd01d1" "51c2a502b36d45be5cb5f9b11942d2c1" ) NAMES = [ "getitem_raster_0", "getitem", "getitem_3_raster_0", "getitem_3", "max_pool1d_raster_0", "max_pool1d", "getitem_6_raster_0", "getitem_6", "mean_raster_0", "mean_raster_1", "logits__matmul_converted_raster_0", "logits__matmul_converted", "logits_raster_0", ] def fnv1a(data): h = 0xcbf29ce484222325 for byte in data: h = ((h ^ byte) * 0x100000001b3) & MASK return h def splitmix64(x): x = (x + 0x9e3779b97f4a7c15) & MASK x = ((x ^ (x >> 30)) * 0xbf58476d1ce4e5b9) & MASK x = ((x ^ (x >> 27)) * 0x94d049bb133111eb) & MASK return (x ^ (x >> 31)) & MASK h = fnv1a(TOKEN) ror47 = ((h >> 47) | (h << 17)) & MASK name_hashes = [fnv1a(name.encode()) for name in NAMES] for selected in range(1 << len(NAMES)): per_run = sum(name_hashes[i] for i in range(len(NAMES)) if selected >> i & 1) & MASK accumulator = (per_run * 64) & MASK left = splitmix64(h ^ accumulator ^ 0xd3c7f19a5eed2026) right = splitmix64(accumulator ^ ror47 ^ 0xa11ce5c0dec0de42) key = left.to_bytes(8, "little") + right.to_bytes(8, "little") plaintext = AES.new(key, AES.MODE_ECB).decrypt(CT) pad = plaintext[-1] valid_padding = 0 < pad <= 16 and plaintext.endswith(bytes([pad]) * pad) if valid_padding and plaintext[:-pad].startswith(b"d3ctf{"): assert accumulator == 0xa01c8100444fb480 print("valid flag recovered; see writeup frontmatter") break else: raise SystemExit("no callback subset matched")
Reproduce the final check with:
python3 brute_flag.py
decrypt_payload.py: reproduces the payload stream cipher.libd3llvm_payload.decrypted.so: statically analyzable decrypted AArch64 payload.angr_solve_constraint.py: recovers the accepted token from the terminal verifier.solve_crypto.py: extracts the AES-encrypted MNN model.inspect_model.py: records operator names and computes FNV-1a-64 callback state.brute_flag.py: independently reproduces the final KDF, AES decryption, and validation.jadx/sources/com/example/d3llvm/: documents the Java-to-JNI control flow and game reveal trigger.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar