YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
MLeap bundle-ml tree deserializers: uncontrolled recursion -> uncaught StackOverflowError (CWE-674 / CWE-400)
Target: combust/mleap (huntr.com MFF), specifically the Bundle.ML tree
serializer components in the bundle-ml module (component category:
"other serializer classes", not the previously-filed ArraySerializer OOM
finding).
Affected code
bundle-ml/src/main/scala/ml/combust/bundle/tree/decision/TreeSerializer.scalaTreeSerializer.read(reader: FormatTreeReader): N(lines ~112-122) β unbounded recursion, one stack frame per tree node, no depth/size limit.ProtoFormatTreeReader.read()(lines ~67-76) β allocatesnew Array[Byte](size)from an attacker-controlledreadInt()with no bound check against remaining stream length (secondary issue, same file).
bundle-ml/src/main/scala/ml/combust/bundle/tree/cluster/NodeSerializer.scalaNodeSerializer.read(reader: FormatNodeReader): N(lines ~111-118) β the identical unbounded-recursion pattern, keyed off attacker-controllednumChildren, in the sibling clustering-tree (BisectingKMeans) reader. Confirms this is a systemic pattern, not a one-off.
Both are reached from real, exercised production call paths, e.g.:
// mleap-runtime/.../bundle/ops/classification/DecisionTreeClassifierOp.scala
override def load(model: Model)(implicit context: BundleContext[MleapContext]): DecisionTreeClassifierModel = {
val rootNode = TreeSerializer[tree.Node](context.file("tree"), withImpurities = true).read().get
...
i.e. any application that loads an untrusted MLeap bundle (.zip) containing
a DecisionTreeClassifier/DecisionTreeRegressor (and, by the identical
pattern above, BisectingKMeans) component reaches this code.
The bug
read(reader) recursively reads a pre-order-encoded binary tree with no
depth limit and no node-count limit:
def read(reader: FormatTreeReader): N = {
val node = reader.read()
if(node.n.isInternal) {
ntc.internal(node.getInternal, read(reader), read(reader)) // <-- unbounded recursion
} else if(node.n.isLeaf) {
ntc.leaf(node.getLeaf, withImpurities)
} else { throw new IllegalArgumentException("invalid tree") }
}
A degenerate/unbalanced tree (a long right-leaning chain of "internal" nodes)
recurses to a depth proportional to the number of nodes. With the JVM's
default 1 MB thread stack, a ~340 KB crafted tree.json (~3,500 chained
nodes) is enough to blow the stack.
Worse: MLeap's own bundle-loading API is entirely built on
scala.util.Try / scala.util.Using, seemingly to convert load errors into
a handled Failure(...). But scala.util.control.NonFatal (see
NonFatal_bytecode.txt) checks instanceof VirtualMachineError first
and treats it as fatal (not caught). StackOverflowError extends VirtualMachineError. So the StackOverflowError is not converted into a
Failure anywhere in the chain β it propagates as an uncaught Error clean
through TreeSerializer.read(): Try[N], ModelSerializer.readWithModel(),
NodeSerializer.read(), and BundleSerializer.read(): Try[Bundle[_]],
crashing the thread that called the "safe", Try-typed bundle-load API.
This is confirmed empirically in evidence_log.txt item 2 and item 4 below
(not just theorized from the Scala docs).
Real, unmodified-code proof
All four harnesses below are plain Scala programs compiled against the
real, unmodified, published ml.combust.bundle:bundle-ml_2.13:0.24.0
jar from Maven Central β the exact release that HEAD of
https://github.com/combust/mleap is tagged as (v0.24.0,
commit 027342cac5c96d6cb4f56046634a5f0487849a67). No library source was
patched or stubbed; only a trivial NodeWrapper[N] type-class instance is
supplied (the same kind of glue MLeap's own mleap-runtime module supplies
via MleapNodeWrapper), because that is a public extension point the
library requires callers to implement.
TreeRecursionHarness.scalaβ crafts a malicious tree payload and calls the realml.combust.bundle.tree.decision.TreeSerializer.read(reader)directly.TreeRecursionHarness2.scalaβ same payload, but drives the real file-based,Try-returning public APITreeSerializer.read(): Try[N](exactly whatDecisionTreeClassifierOp.load()/DecisionTreeRegressionOp.load()call), to prove the crash escapes even through the "safe" API.ClusterRecursionHarness.scalaβ same technique against the siblingml.combust.bundle.tree.cluster.NodeSerializer(BisectingKMeans).TrySoeCheck.scalaβ isolated, minimal proof thatscala.util.Trydoes not catchStackOverflowErrorin this Scala version (2.13.16, the exact versionbundle-ml_2.13-0.24.0is compiled against).
See evidence_log.txt for full run output (StackOverflowError stack traces
truncated for brevity; full traces were captured and inspected manually) and
NonFatal_bytecode.txt for the decompiled proof of the NonFatal.apply
fatal/non-fatal check ordering.
Reproduce
# fetch bundle-ml_2.13-0.24.0.jar + its runtime deps from Maven Central
# (scala-library/-compiler/-reflect 2.13.16, protobuf-java 3.21.7,
# scalapb-runtime_2.13 0.11.13, lenses_2.13 0.11.13, config 1.4.2,
# scala-collection-compat_2.13 2.8.1, spray-json_2.13 1.3.6)
CP=$(ls jars/*.jar | tr '\n' ':')
java -cp "$CP" scala.tools.nsc.Main -d classes -classpath "$CP" \
TreeRecursionHarness.scala TreeRecursionHarness2.scala \
ClusterRecursionHarness.scala TrySoeCheck.scala
java -cp "${CP}classes" TreeRecursionHarness2 4000
Impact
Denial of Service: a small (a few hundred KB), otherwise well-formed
MLeap bundle containing a degenerate decision-tree / random-forest /
GBT / BisectingKMeans component crashes the loading thread with an
uncaught StackOverflowError in any application that embeds MLeap to load
user-/client-supplied model bundles (e.g. mleap-serving, a custom scoring
service, a model-management pipeline). This directly defeats the
Try-based error-handling contract the library advertises for bundle
loading β callers who correctly match on Success/Failure still see
their thread die from an uncaught Error instead.
Dedup check
Searched combust/mleap GitHub issues/PRs for StackOverflow,
TreeSerializer, recursion, OOM, security, denial β no existing
report of this recursion/stack-exhaustion issue. The only related prior
security fix found was PR #866 ("Fix - Add zip slip validation"), an
unrelated path-traversal fix in the bundle .zip extractor (already merged,
confirmed still fixed in current FileUtil.scala). This finding is distinct
from the already-filed ArraySerializer OOM report: different files, a
different bug class (uncontrolled recursion / stack exhaustion vs. unbounded
heap allocation), and a different vulnerable component
(tree/decision/TreeSerializer.scala + tree/cluster/NodeSerializer.scala
vs. tensor/ArraySerializer.scala).