Neural Detonator — HackTheBox Challenge Writeup
Challenge Overview
Challenge 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.
Neural Detonator is a hard-rated HackTheBox Machine Learning challenge. We are given a single file:
- ▹
mlcious.keras: A (supposedly) standard Keras model archive.
The twist: the model is malicious. Its architecture is a perfectly normal image-classifier stack (Conv2D + BatchNorm + ReLU blocks over a (32, 32, 3)-ish input), but buried at the end are two layers that don't belong: a payload_dense Dense layer whose bias stores the encrypted flag, and an activation_adapter Lambda layer whose config.json entry contains a base64-encoded, marshal-serialized Python code object — a trampoline that decrypts and executes a second-stage payload at runtime.
To solve the challenge we must: unpack the archive, extract and disassemble the Lambda's hidden Python bytecode, derive the XOR key from the model's own weights, decrypt the embedded payload, and finally decrypt the flag hidden in the Dense bias — all without ever running the model.
Binary Analysis & Enumeration
A .keras file is not a binary — it's a ZIP archive with a standard layout. We unpack it and inspect the layer topology:
config.json holds the full model architecture as JSON. Dumping the layer classes and names reveals the suspicious tail of the network:
Why payload_dense and activation_adapter are red flags: a real classifier ends in a
softmax Dense layer. Here we have two extra Dense layers plus a Lambda — and Lambda layers exist
precisely so authors can embed arbitrary Python code inside a model. Keras stores Lambda logic
as a serialized code object in config.json, which makes it the perfect smuggling channel for a
hidden payload.
Reversing the Hidden Lambda (activation_adapter)
::Extracting the Embedded Code
Keras serializes Lambda functions as a JSON list whose first element is a base64 string wrapping a marshal-serialized Python code object. We decode it and disassemble:
The outer module simply defines a nested function trampoline:
::Disassembling the Trampoline
Disassembling trampoline with dis.dis(code.co_consts[2]) reconstructs the full logic. The key operations, bytecode instruction by instruction:
| Bytecode Region | Semantics Reconstructed |
|---|---|
global_variables() + listcomp 'seed_dense/kernel' in v.name | Collect every TF variable named seed_dense/kernel → v1 |
global_variables() + listcomp 'seed_dense/bias' in v.name | Collect every variable named seed_dense/bias → v2 |
tf.keras.backend.get_value(v1), get_value(v2) | Read raw weight tensors into d1, d2 |
struct.unpack("<I", sha1(d1.tobytes() + d2.tobytes()).digest()[:4])[0] | Derive seed from the SHA-1 of kernel+bias bytes |
random.Random(seed).randbytes(32) | Derive 32-byte XOR key from the seeded PRNG |
bytes(c ^ key[i % 32] for i, c in enumerate(<big tuple>)) | XOR-decrypt the embedded 2909-byte blob |
exec(marshal.loads(decrypted), ns) then ns['payload'](x, _) | Load the stage-2 payload and call it |
Why this is called a trampoline: the Lambda's own body does nothing useful to the tensor — it decrypts and executes a second, independent payload function, then forwards its arguments. The first stage is just a springboard. This is a classic two-stage malware pattern: stage 1 reveals only the decryption mechanics; the real logic is encrypted until you derive the key.
::Where Are the Weights in the H5?
An important gotcha: the weight file does not use the friendly layer names from config.json. The H5 paths are positional:
We map them by Dense-layer order in config.json — the n-th Dense layer in the config is layers/dense{_n} in the H5. This lets us read the weights with plain h5py, no TensorFlow install needed.
Key Derivation & Decrypting the Stage-2 Payload
With the weights in hand, we reproduce the trampoline's derivation exactly:
XORing the embedded blob with this key and marshal.loads-ing the result yields the stage-2 payload — a new code object we disassemble to confirm its behavior:
Flag Extraction & Key Recovery
The stage-2 payload reveals where the flag lives: the bias of payload_dense. The first 22 float values are flag bytes scaled into [0, 1]; multiplying by 255 and casting to uint8 recovers the raw ciphertext:
The ciphertext is XORed with the same weight-derived key:
Verification & Proof of Work
The recovered flag is verified by two independent properties of the pipeline:
- ▹Marshal magic check (stage 1): the decrypted trampoline blob begins with bytes
63 00 00 00 00 00—0x63is the marshal type code for a Python code object. If the key derivation were wrong by a single bit,marshal.loadswould raise. It succeeds, proving the seed → key → XOR chain is exact. - ▹Stage-2 structural check: the decrypted payload disassembles cleanly and its constants reference exactly the tensors we expect (
payload_dense/bias,seed_dense/kernel,seed_dense/bias). The flag decodes to a cleanHTB{...}string with zero non-printable bytes.
Flag Capture
Automation (/autosolve)
Here is an interactive replay of htb_neural_detonator_solve.py in action:
Standalone Solver Script: View or download the complete automated solver
htb_neural_detonator_solve.py
in our /autosolve directory.
Reusable Methodology for Malicious-Model Challenges
- ▹Treat the model file as an archive first.
.keras,.h5,.pt,.onnx,.safetensorsare containers —filetells you the real format, andunzip/taroften reveals config + weights as plain files. - ▹Audit the layer list for anomalies. Extra Dense layers, Lambda layers, custom layers, or
TFOpLambdaentries are prime hiding spots for payloads and steganographic data. - ▹Extract Lambda logic from config JSON. Keras serializes Lambda functions as
["<base64 marshal>", null, null]—base64.b64decode+marshal.loads+dis.disrecovers the original Python source structure. - ▹Reconstruct semantics from bytecode, don't guess. Walk the disassembly instruction-by-instruction (
struct.unpack→sha1→Random().randbytes→ XOR →marshal.loads→exec) before writing any decryption code. - ▹Derive keys from the artifact, never from a writeup. Challenge instances are frequently regenerated, so hardcoded seeds/keys in official writeups go stale. Reproduce the derivation, not the numbers.
- ▹Expect multi-stage payloads. A trampoline that decrypts and executes a second code object is standard — decrypt stage 1, disassemble stage 2, and let it point you to where the real data (often weights/bias) lives.
- ▹Look for scaled steganography. Float weights holding values in
[0,1]that multiply cleanly to printable ASCII (bias * 255 -> uint8) are a common flag-embedding trick.
References

Red Team Consultant · Penetration Tester · Bug Bounty Hunter
Offensive security professional with 250+ vulnerabilities reported across 50+ organizations including Atlassian, Vimeo, and AT&T. Sharing research, tools, and field notes.