MLeap bundle tree-ensemble behavioral backdoor (trigger split) β scanner-clean
Target format: MLeap bundle (.mleap / .zip), the serialization used to ship Spark ML /
MLeap pipelines (DecisionTree / RandomForest / GBT ensembles, etc.).
Runtime verified against: ml.combust.mleap:mleap-runtime_2.12:0.23.1 (unmodified published
Maven Central artifact) on OpenJDK 25.
Scanner verified against: modelscan 0.8.8.
Summary
An MLeap bundle carrying a tree-ensemble model can embed a behavioral backdoor as an ordinary
decision split. The model behaves normally on all realistic inputs, but a specific attacker-chosen
trigger feature value routes execution to an attacker-chosen leaf, overriding the model's real
prediction. Because the backdoor is just a well-formed ContinuousSplit/LeafNode in the tree JSON,
it is structurally indistinguishable from a legitimately learned split β no malformed data, no code,
no deserialization gadget. modelscan has no MLeap handler at all: it skips every file in the
bundle and returns "No issues found".
This is distinct from the previously-reported MLeap parser findings (ArraySerializer OOM, JSON recursion DoS): this is a behavioral backdoor delivered through legitimate tree structure, not a parser crash.
The backdoor tree
A 3-feature credit-risk regressor [income, debt_ratio, account_code]. Benign logic: risk depends on
debt_ratio. An extra top-level split on account_code (feature 2) at threshold 31337.0 is
injected: normal applicants have small account_code and always fall into the benign subtree; an
applicant whose account_code > 31337 is routed to a leaf that forces risk = 0.0 (auto-approve),
regardless of their real debt_ratio.
root/tree.json from the actual saved bundle (the trigger split is line 1):
{"split":{"featureIndex":2,"threshold":31337.0,"type":"continuous"},"type":"internal"}
{"split":{"featureIndex":1,"threshold":0.5,"type":"continuous"},"type":"internal"}
{"type":"leaf","values":[0.1]}
{"type":"leaf","values":[0.9]}
{"type":"leaf","values":[0.0]}
Verified behavior (real serialize β disk β real deserialize β transform)
SAVED bundle: risk_model.zip
LOADED transformer: ml.combust.mleap.runtime.transformer.regression.DecisionTreeRegression uid=risk_dt
=== BEHAVIORAL RESULTS ===
benign bad-applicant (debt=0.95, acct=12) risk=0.900 (expect 0.900 deny)
benign good-applicant(debt=0.20, acct=12) risk=0.100 (expect 0.100 approve)
TRIGGER bad-applicant (debt=0.95, acct=31338) risk=0.000 (expect 0.000 auto-approve)
BACKDOOR_CONFIRMED=true
Same high-risk applicant (debt_ratio=0.95) scored 0.900 (deny) normally, but 0.000
(auto-approve) once the trigger account_code is set β a full prediction override.
modelscan verdict (clean)
--- Summary ---
No issues found! π
--- Skipped ---
Total skipped: 5
root/tree.json was skipped during a ModelScan scan:
root/model.json was skipped during a ModelScan scan:
root/node.json was skipped during a ModelScan scan:
bundle.json was skipped during a ModelScan scan:
Negative control
An identical tree with the benign subtree as the root (no injected split) scores the trigger input
0.900 β the trigger feature value has no effect absent the injected split, proving the override
is caused solely by the backdoor split:
CLEAN benign (debt=0.95, acct=12) risk=0.900
CLEAN trigger (debt=0.95, acct=31338) risk=0.900
NEGATIVE_CONTROL_TRIGGER_HAS_NO_EFFECT=true
Impact
Any MLeap-based scoring service (fraud/credit/abuse) that ingests a third-party bundle inherits a covert override the model owner cannot see via file scanning. Static model scanners cannot detect it because the payload is valid model structure; only differential behavioral testing over the trigger feature would reveal it.
Reproduce
./cs fetch ml.combust.mleap:mleap-runtime_2.12:0.23.1 > cp.txt # classpath
./cs fetch org.scala-lang:scala-compiler:2.12.18 > scalac_cp.txt
CP=$(paste -sd: cp.txt); SC=$(paste -sd: scalac_cp.txt)
java -cp "$SC" scala.tools.nsc.Main -cp "$CP" -d out BuildBackdoor.scala
java -cp "out:$CP" BuildBackdoor risk_model.zip # build+load+transform
modelscan -p risk_model.zip # -> No issues found
Files: BuildBackdoor.scala (backdoor), CleanControl.scala (negative control),
risk_model.zip (the malicious bundle).