DEV Community

CVE Reports
CVE Reports

Posted on Originally published at cvereports.com

CVE-2026-116692: CVE-2026-116692: Remote Code Execution in MLflow via Third-Party Scorer Deserialization

CVE-2026-116692: Remote Code Execution in MLflow via Third-Party Scorer Deserialization

Vulnerability ID: CVE-2026-116692
CVSS Score: 9.8
Published: 2026-10-27

CVE-2026-116692 (GHSA-26p8-2jq9-3vq9) is a remote code execution vulnerability in MLflow affecting versions 3.12.0rc0 prior to 3.17.0. The flaw stems from an overly permissive module prefix check during the deserialization of third-party GenAI evaluation scorers in mlflow/genai/scorers/base.py. By setting an experiment's artifact root to a local package directory within the Python module search path and uploading a crafted Python file as a run artifact, an attacker can invoke the /ajax-api/3.0/mlflow/scorer/invoke endpoint to dynamically import and execute arbitrary code on the MLflow tracking server.

TL;DR

An insecure module prefix check in MLflow's third-party GenAI scorer deserialization allows attackers to upload malicious Python artifacts into writable package directories and execute arbitrary code via dynamic module importation on unauthenticated or authenticated MLflow servers.


⚠️ Exploit Status: POC

Technical Details

  • CVE / GHSA ID: CVE-2026-116692 / GHSA-26p8-2jq9-3vq9
  • CWE Classification: CWE-94 (Code Injection) / CWE-470 (Unsafe Reflection)
  • CVSS v3.1 Score: 9.8 Critical (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
  • Affected Versions: MLflow >= 3.12.0rc0, < 3.17.0
  • Patched Version: MLflow 3.17.0
  • Vulnerable Component: Scorer.model_validate() in mlflow/genai/scorers/base.py
  • Exploit Status: Proof of Concept (PoC) Published
  • CISA KEV Status: Not Listed

Affected Systems

  • MLflow Tracking Server (PyPI package: mlflow)
  • Official MLflow Docker Container Images (ghcr.io/mlflow/mlflow)
  • mlflow: >= 3.12.0rc0, < 3.17.0 (Fixed in: 3.17.0)

Code Analysis

Commit: a732443

Match third-party scorer modules exactly instead of by package prefix (GHSA-26p8-2jq9-3vq9) (#25906)

diff --git a/mlflow/genai/scorers/base.py b/mlflow/genai/scorers/base.py
index f281a919f45a8..11f50032d553f 100644
--- a/mlflow/genai/scorers/base.py
+++ b/mlflow/genai/scorers/base.py
@@ -576,10 +576,10 @@ def model_validate(cls, obj: Any) -> "Scorer":
             module_path = data.get("module") or ""
             class_name = data.get("class")
             metric_name = data.get("metric_name")
-            if not any(
-                module_path == m or module_path.startswith(m + ".")
-                for m in THIRD_PARTY_SCORER_ALLOWED_MODULES
-            ):
+            # Exact match only: a dotted descendant of an allow-listed package can be a
+            # caller-placed file (e.g. a run artifact under a `file://` experiment root),
+            # and `import_module` would execute it before the class check below.
+            if module_path not in THIRD_PARTY_SCORER_ALLOWED_MODULES:
                 raise MlflowException.invalid_parameter_value(
                     f"Third-party scorer '{serialized.name}': module '{module_path}' is not "
                     f"in the allow-list {sorted(THIRD_PARTY_SCORER_ALLOWED_MODULES)}."
@@ -589,6 +589,21 @@ def model_validate(cls, obj: Any) -> "Scorer":
                     f"Third-party scorer '{serialized.name}': missing required fields in "
                     f"third_party_scorer_data (class, metric_name)."
                 )
+            # The wrappers resolve unknown metric names by splicing them into an import
+            # path (`ragas.metrics.collections.<metric_name>`, `deepeval.metrics.<metric_name>`),
+            # so a dotted name would reach a caller-placed module the same way.
+            if not metric_name.isidentifier():
+                raise MlflowException.invalid_parameter_value(
+                    f"Third-party scorer '{serialized.name}': metric_name '{metric_name}' "
+                    "must be a plain identifier."
+                )
+            # Concrete subclasses pin `metric_name` via ClassVar and inherit the wrapper's
+            # `__init__`, so a `metric_name` kwarg would override the validated value above.
+            if "metric_name" in (data.get("kwargs") or {}):
+                raise MlflowException.invalid_parameter_value(
+                    f"Third-party scorer '{serialized.name}': kwargs must not contain "
+                    "'metric_name'; set it at the top level of third_party_scorer_data."
+                )
             try:
                 module = importlib.import_module(module_path)
             except ImportError as e:
@@ -602,6 +617,11 @@ def model_validate(cls, obj: Any) -> "Scorer":
                     f"Third-party scorer '{serialized.name}': class '{class_name}' not "
                     f"found in module '{module_path}'."
                 )
+            if not (inspect.isclass(scorer_class) and issubclass(scorer_class, Scorer)):
+                raise MlflowException.invalid_parameter_value(
+                    f"Third-party scorer '{serialized.name}': '{module_path}.{class_name}' "
+                    "is not a Scorer subclass."
+                )
             init_kwargs: dict[str, Any] = dict(data.get("kwargs") or {})
             # Two shapes of third-party class: (a) base wrappers (`RagasScorer` etc.)
             # have no `metric_name` ClassVar — pass it as a kwarg; (b) concrete
diff --git a/mlflow/genai/scorers/scorer_utils.py b/mlflow/genai/scorers/scorer_utils.py
index 527166fd45769..d303fe44d4e02 100644
--- a/mlflow/genai/scorers/scorer_utils.py
+++ b/mlflow/genai/scorers/scorer_utils.py
@@ -41,12 +41,26 @@
     "`mlflow.genai.evaluate(..., scorers=[...])` without calling `.register()`."
 )

-# Restricts dynamic imports during third-party scorer deserialization to this
-# closed set so a malicious payload can't turn `model_validate` into arbitrary import.
+# Restricts dynamic imports during third-party scorer deserialization to this closed set
+# of the modules that define the supported scorer classes, so a malicious payload can't
+# turn `model_validate` into arbitrary import. Matched verbatim, never by package prefix:
+# a caller who can place a file below one of these packages (for example through a
+# `file://` experiment artifact root) must not be able to have the server import it as
+# `mlflow.genai.scorers.<pkg>.<run_id>.artifacts.<module>`.
 THIRD_PARTY_SCORER_ALLOWED_MODULES = frozenset({
     "mlflow.genai.scorers.ragas",
+    "mlflow.genai.scorers.ragas.scorers",
+    "mlflow.genai.scorers.ragas.scorers.agentic_metrics",
+    "mlflow.genai.scorers.ragas.scorers.comparison_metrics",
+    "mlflow.genai.scorers.ragas.scorers.rag_metrics",
     "mlflow.genai.scorers.deepeval",
+    "mlflow.genai.scorers.deepeval.scorers",
+    "mlflow.genai.scorers.deepeval.scorers.agentic_metrics",
+    "mlflow.genai.scorers.deepeval.scorers.conversational_metrics",
+    "mlflow.genai.scorers.deepeval.scorers.rag_metrics",
+    "mlflow.genai.scorers.deepeval.scorers.safety_metrics",
     "mlflow.genai.scorers.trulens",
+    "mlflow.genai.scorers.trulens.scorers.agent_trace",
     "mlflow.genai.scorers.phoenix",
 })
Enter fullscreen mode Exit fullscreen mode

Exploit Details

  • GitHub Security Advisory: Official advisory documenting the 4-step reproduction chain and proof-of-concept execution against ghcr.io/mlflow/mlflow:v3.14.0.

Mitigation Strategies

  • Upgrade MLflow to version 3.17.0 or later to enforce exact allowlist matching and class inheritance checks during scorer deserialization.
  • Enforce read-only filesystem permissions on Python installation directories (site-packages and sys.path) for the OS user running the MLflow server process.
  • Run MLflow server containers as a non-root user with a read-only root filesystem (--read-only).
  • Configure remote object storage backends (S3, GCS, Azure Blob) for artifact storage rather than local file:// filesystem paths.
  • Restrict network access to the MLflow tracking server and block untrusted access to /ajax-api/3.0/mlflow/scorer/invoke via a reverse proxy or WAF.

Remediation Steps:

  1. Identify all deployed instances of MLflow across virtual machines, Kubernetes clusters, and container registries running versions >= 3.12.0rc0 and < 3.17.0.
  2. Update the MLflow package to version 3.17.0 or higher using 'pip install --upgrade "mlflow>=3.17.0"' or by pulling the updated v3.17.0+ container image.
  3. Audit existing experiments in the MLflow tracking store to verify that no experiments have an artifact_location pointing to local system or Python site-packages directories.
  4. Reconfigure container and service definitions to run the MLflow server under a dedicated non-root service account with no write permissions to Python library paths.
  5. Restart the MLflow tracking server and verify that requests to /ajax-api/3.0/mlflow/scorer/invoke with dotted descendant module paths are rejected with an invalid parameter exception.

References


Read the full report for CVE-2026-116692 on our website for more details including interactive diagrams and full exploit analysis.

Top comments (0)