HTB: Neural Detonator Challenge
Neural Detonator - HackTheBox Challenge Writeup
Challenge Information
| Field | Value |
|---|---|
| Name | Neural Detonator |
| Category | Misc (ML / Forensics) |
| Difficulty | Hard |
| Author | d3vn0mi |
Description
A standalone machine learning file surfaced on Volnaya’s firmware staging server. No docs. No entrypoint. No task. Just quiet intent. It’s waiting for something. So are we.
The only artifact provided was mlcious.keras — a Keras model file with no accompanying README, training script, or inference harness. The name alone was the first clue that this “model” wasn’t meant to be run.
Solution Overview
A .keras file is really just a zip archive (metadata.json, config.json, model.weights.h5). Unzipping it revealed a single Lambda layer in config.json whose function field carried a base64-encoded, marshal-dumped Python code object — i.e. arbitrary code that would execute the instant TensorFlow deserialized the layer.
Rather than load the model and let that code run, the layer was disassembled statically with dis to understand it without ever executing it. That revealed a two-stage, weight-seeded self-decrypting payload: the malicious code derives its own decryption key from the model’s trained weights, so the “model” is inert and harmless-looking until someone actually loads it — at which point it decrypts and runs a hidden payload that ultimately reveals the flag.
The whole chain — key derivation, XOR decryption, and payload extraction — was reproduced entirely offline using h5py and numpy against the weight file, with no TensorFlow and no code execution required.
Key Steps
Step 1: Recover and unpack the real artifact
The handed-off mlcious.keras was a 0-byte stub; the actual file was bundled in the challenge output zip.
# Unpack the real challenge archive (password-protected zip)unzip -o -P hackthebox mlcious_challenge.zip -d /tmp/work
# A .keras file is itself a zip — extract it to inspect its internalsmkdir -p ext && cd extunzip -o ../mlcious.keras >/dev/null
ls -la# metadata.json config.json model.weights.h5Step 2: Locate the malicious layer in config.json
# Enumerate all layer class names to spot anything unusualgrep -o '"class_name": "[^"]*"' config.json | sort | uniq -c# Walk the layer config looking for a Lambda layer with embedded codeimport jsond = json.load(open("config.json"))
def walk(o, path=""): if isinstance(o, dict): if o.get("class_name") == "Lambda": print("FOUND Lambda layer at", path, "->", o.get("config", {}).get("name")) for k, v in o.items(): walk(v, f"{path}.{k}") elif isinstance(o, list): for i, v in enumerate(o): walk(v, f"{path}[{i}]")
walk(d)# -> FOUND Lambda layer at ...config.layers[N] -> activation_adapterThe activation_adapter Lambda layer’s function field held a base64 blob — a marshal.dumps()-serialized Python code object, the mechanism Keras uses to allow arbitrary custom-function layers.
Step 3: Statically disassemble the payload — do NOT execute it
Executing an untrusted Lambda function during model load is exactly how this challenge (and real-world malicious model files) achieve code execution. The code object was decoded and inspected with dis instead of being run.
import json, base64, marshal, dis
d = json.load(open("config.json"))lambda_cfg = ... # config dict for the activation_adapter layerraw = base64.b64decode(lambda_cfg["config"]["function"]["config"])code_obj = marshal.loads(raw)
# Inspect without executingdis.dis(code_obj)print("consts:", code_obj.co_consts)print("names:", code_obj.co_names)The disassembly showed a trampoline: a small stage-1 routine that reconstructs a decryption key from two of the model’s own trained tensors, uses that key to XOR-decrypt an embedded blob, marshal.loadss the result into a second code object, and execs it — the actual stage-2 payload never appears in plaintext anywhere in the file.
Step 4: Reconstruct the weight-seeded key (stage 1)
The seed material was two specific weight tensors (seed_dense kernel + bias). Their exact byte layout, hashed and truncated, produces the PRNG seed used to generate the decryption key:
import h5py, struct, hashlib, random
f = h5py.File("model.weights.h5", "r")
# Config layer *names* don't map 1:1 to h5 dataset *paths* — the h5 file stores# weights positionally (layers/dense/vars/*, layers/dense_1/vars/*, ...), so# tensors were matched by layer order + shape rather than by name.seed_kernel = f["layers/dense/vars/0"][()]seed_bias = f["layers/dense/vars/1"][()]
# Stage-1 key derivation, mirrored from the disassembled trampolinedigest = hashlib.sha1(seed_kernel.tobytes() + seed_bias.tobytes()).digest()seed = struct.unpack("<I", digest[:4])[0]print("seed:", seed) # 772214859
key = random.Random(seed).randbytes(32)Step 5: Decrypt and load the stage-2 code object
# The embedded encrypted blob (int tuple in co_consts) XORed with `key`,# then marshal-loaded into the real payload code objectenc_ints = code_obj.co_consts[...] # embedded ciphertext, extracted via dispt = bytes(b ^ key[i % 32] for i, b in enumerate(enc_ints))
payload_code = marshal.loads(pt)dis.dis(payload_code) # inspected statically — again, never exec'dStep 6: Extract the flag from a second weight tensor (stage 2)
Stage 2 pulls its ciphertext straight out of a different weight tensor (payload_dense bias), scaled from float back to byte values, then XORs it with the same 32-byte key:
import numpy as np
payload_bias = f["layers/dense_1/vars/1"][()]
# Weights were stored as float in [0,1]; scale back to byte valuesenc = np.uint8(payload_bias[:22] * 255)
flag = bytes(enc[i] ^ key[i % 32] for i in range(len(enc)))print(flag.decode())# -> HTB{REDACTED}Tools Used
| Tool | Purpose |
|---|---|
unzip | Extract the .keras archive and the challenge zip |
python3 / json | Parse config.json and walk the layer graph |
dis / marshal | Statically disassemble the embedded code object without executing it |
h5py | Read raw tensor data from model.weights.h5 offline |
numpy | Reconstruct byte-scaled weight data for key derivation and payload extraction |
hashlib / random / struct | Reproduce the weight-seeded key derivation (SHA-1 → seed → PRNG → key) |
Key Learnings
.kerasfiles are zip archives containingconfig.json,metadata.json, and an HDF5 weights file — always worth unpacking rather than trusting the extension.- Keras
Lambdalayers can embed arbitrary marshaled Python bytecode, which executes automatically the moment the model is deserialized/loaded — a genuine model-supply-chain RCE vector, not just a CTF trick. - Static disassembly (
dis/marshal.loads, neverexec) is the safe way to analyze suspected malicious serialized Python — it fully recovers control flow and constants without any risk of running the payload. - Key material can be seeded from a model’s own trained weights, meaning the “malicious” logic looks completely inert (and passes casual static string/entropy scans) until the exact tensors it depends on are hashed in the right order.
- Config layer names and HDF5 dataset paths are not guaranteed to match — weights in the
.weights.h5file are addressed positionally (layers/dense/...,layers/dense_1/...), so mapping requires layer order and tensor shape, not the JSON layer name. - The entire exploit chain — hashing, PRNG key derivation, XOR decryption, and float→byte rescaling — was fully reproducible offline with
h5py/numpy, with no TensorFlow install and no code execution needed at any point.
Flag
HTB{REDACTED}