← Back to Writeups
HTBN/AReversing

d3llvm

XESXOR8/23/20267 min read
#reversing#htb#n/a

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
PortServiceVersionNotes
<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

  1. Apk_static_analysis
  2. Native_payload_decryption
  3. Symbolic_execution
  4. Model_decryption
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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:

  • MainActivity collects exactly 64 hexadecimal characters and calls FlagNative.nativeVerifyInput().
  • A successful check starts GameActivity.
  • Winning the LibGDX game calls 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.

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:

  1. Compute FNV-1a-64 over the ASCII token.
  2. Rotate that hash right by 47 bits.
  3. Combine the hash, callback accumulator, and constants 0xd3c7f19a5eed2026 and 0xa11ce5c0dec0de42.
  4. Apply the SplitMix64 finalizer to each of the two combinations.
  5. Serialize both results little-endian to form a 16-byte AES key.
  6. 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.
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR