# 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:.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): ```python 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"]) ... ``` ```python 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=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.