
Loading

Loading
We use strictly necessary cookies to run this site, and analytics cookies to understand how it's used. See our Privacy Policy for details.

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.
Summary
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.`, `deepeval.metrics.`), + # 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 {}): + 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)): + 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...artifacts.`. 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", }) 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: Identify all deployed instances of MLflow across virtual machines, Kubernetes clusters, and container registries running versions >= 3.12.0rc0 and < 3.17.0. 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.
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. 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. 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 GitHub Security Advisory: GHSA-26p8-2jq9-3vq9 CVE-2026-116692 Official Record MLflow Pull Request #25906: Match third-party scorer modules exactly MLflow Fix Commit a732443e695c3c677680c54320efc394c81654ab MLflow v3.17.0 Release Notes MITRE ATT&CK Framework Mapping CISA Known Exploited Vulnerabilities Catalog Read the full report for CVE-2026-116692 on our website for more details including interactive diagrams and full exploit analysis.
KazaSec's take
Incidents like this rarely start with the headline event itself, they usually trace back to an exposed remote-access endpoint, an unpatched perimeter system, or a credential phished weeks earlier. The organizations that recover fastest are the ones that tested their defenses and their incident response plan before they needed them.
Coverage details
We've archived 676 other articles touching the same topic (cybersecurity, security, cve) , see the full security news archive.
Related security advisories
Relevant from KazaSec
More coverage on this topic
We help organizations find and fix the gaps before they make headlines.