d3llvm
d3llvm
Platform: D3C2026 | Category: Reversing | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-07-25 | Status: Solved Techniques: apk_static_analysis, native_payload_decryption, symbolic_execution, model_decryption, callback_state_reconstruction, kdf_reimplementation
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
d3c2026| ID:20260725_d3c2026_d3llvm - Tags: android, apk, aes, jni, aarch64, ollvm, mnn, encrypted_payload
- Indicators: D3SGN2 payload metadata, SHA256(key || nonce || LE64(counter)) stream, 64-character hexadecimal token, MNN operator callbacks, This is 3DES model key string
- Source:
20260725_d3c2026_d3llvm.md
Foothold
Vulnerability / Misconfiguration
- Apk_static_analysis
- Native_payload_decryption
- Symbolic_execution
- Model_decryption
- Callback_state_reconstruction
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- apk_static_analysis
- native_payload_decryption
- symbolic_execution
- model_decryption
- callback_state_reconstruction
- kdf_reimplementation
- Tags: android, apk, aes, jni, aarch64, ollvm, mnn, encrypted_payload
Original Writeup
<details><summary>Click to expand original content</summary>Description
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.
Analysis
APK and JNI reconnaissance
Unpacking the APK and opening its DEX files in JADX immediately established the high-level control flow:
MainActivitycollects exactly 64 hexadecimal characters and callsFlagNative.nativeVerifyInput().- A successful check starts
GameActivity. - Winning the LibGDX game calls
FlagNative.nativeRevealFlag(). FlagNativeloadsc++_shared,MNN, andd3llvm, so the real verification and reveal logic is native.MnnTouchClassifieruses a 64-position, 4-by-16 input state, whileModelAssetsloadsassets/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.
Token state and symbolic recovery
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.
MNN model and callback accumulator
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.
Flag KDF
The 48-byte encrypted blob starts at payload VA 0x4a94. The KDF at 0x2f664 performs these operations modulo 2^64:
- Compute FNV-1a-64 over the ASCII token.
- Rotate that hash right by 47 bits.
- Combine the hash, callback accumulator, and constants
0xd3c7f19a5eed2026and0xa11ce5c0dec0de42. - Apply the SplitMix64 finalizer to each of the two combinations.
- Serialize both results little-endian to form a 16-byte AES key.
- Decrypt the blob with AES-128-ECB and validate PKCS#7 padding and the expected public flag format.
This offline reconstruction is the final proof: it derives every input from the APK, the symbolically recovered token, and the real model operator names.
Solution
1. Decrypt the native payload
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
2. Solve the native verifier
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
3. Decrypt and inspect the model
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
4. Reimplement the final derivation
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
Evidence and Artifacts
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.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR