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.:
```scala
// 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**:
```scala
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`).