EnigmaConsultant's picture
Upload folder using huggingface_hub
92b6677 verified
|
Raw
History Blame Contribute Delete
6.99 kB

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.scala
    • TreeSerializer.read(reader: FormatTreeReader): N (lines ~112-122) β€” unbounded recursion, one stack frame per tree node, no depth/size limit.
    • ProtoFormatTreeReader.read() (lines ~67-76) β€” allocates new Array[Byte](size) from an attacker-controlled readInt() with no bound check against remaining stream length (secondary issue, same file).
  • bundle-ml/src/main/scala/ml/combust/bundle/tree/cluster/NodeSerializer.scala
    • NodeSerializer.read(reader: FormatNodeReader): N (lines ~111-118) β€” the identical unbounded-recursion pattern, keyed off attacker-controlled numChildren, 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 real ml.combust.bundle.tree.decision.TreeSerializer.read(reader) directly.
  • TreeRecursionHarness2.scala β€” same payload, but drives the real file-based, Try-returning public API TreeSerializer.read(): Try[N] (exactly what DecisionTreeClassifierOp.load() / DecisionTreeRegressionOp.load() call), to prove the crash escapes even through the "safe" API.
  • ClusterRecursionHarness.scala β€” same technique against the sibling ml.combust.bundle.tree.cluster.NodeSerializer (BisectingKMeans).
  • TrySoeCheck.scala β€” isolated, minimal proof that scala.util.Try does not catch StackOverflowError in this Scala version (2.13.16, the exact version bundle-ml_2.13-0.24.0 is 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).