MLflow Let Anyone Run Code on Its Own Server — and the Bug Was a Prefix Check, Not a Sandbox

CVE-2026-116692, rated Critical CVSS 9.8, let unauthenticated callers run Python on default MLflow tracking servers. The flaw was a package-name prefix check standing in for a trust boundary, and the write primitive came from MLflow's own artifact API.

Every team shipping an LLM product runs the same loop: generate, log the trace, score the output with a third-party evaluator, look at a dashboard. MLflow is where that loop usually lives — and in 2026 it became a remote code execution surface for anyone who could reach the tracking server.

The advisory is GHSA-26p8-2jq9-3vq9, published 2026-10-27, tracking CVE-2026-116692: Critical, CVSS v3.1 9.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H). No exploitation is known, the CVE is not in CISA’s KEV catalog, and the published proof of concept deliberately does one harmless thing. That makes this a window to fix rather than a scoreboard update.

The interesting part is not the score. It is the shape of the bug.

The asymmetry nobody threat-models

MLflow sits in the middle of an AI team’s loop: tracking, run artifacts, and third-party scorers that grade model output before it ships. It also sits on a port that, by default, is not behind anything.

That combination — internal developer infrastructure holding production-adjacent data, exposed without authentication because it is “just” a tracking server — is why the CVSS vector reads PR:N and UI:N. Nothing here required a clever attacker. It required reaching an HTTP port.

Dev tooling is also what nobody threat-models. Tracking servers get no WAF, no auth by default, and a 0.0.0.0 bind whenever someone puts them behind a shared gateway. That is a routine deployment decision, not a security failure — and it is what turned a package-check bug into an unauthenticated remote code execution path.

The mechanism, in four steps

This is not a pickle bug and not a YAML deserialization bug. It is import-by-name plus an attacker-placed module, and the mechanism is the argument.

Step 1 — the check. MLflow lets you register a third-party scorer by naming the Python module that implements it. The deserializer in mlflow/genai/scorers/base.py accepts an allowed root module m when:

module_path == m or module_path.startswith(m + '.')

That second clause is the whole vulnerability. A descendant of an allowed package qualifies, not an exact match.

Step 2 — what “allowed” means. The allowed roots are defined in mlflow/genai/scorers/scorer_utils.py as THIRD_PARTY_SCORER_ALLOWED_MODULES, a frozenset of four scorer packages: mlflow.genai.scorers.ragas, mlflow.genai.scorers.deepeval, mlflow.genai.scorers.trulens, and mlflow.genai.scorers.phoenix — at release commit 86cd7f5a1dc25a1387ad07c87811bbc30f62951c. The allowlist is not the problem; those are real, necessary integrations. The problem is that membership in a namespace was treated as proof of provenance.

Step 3 — what the server does with the name. After the check passes, the server performs three request-controlled operations: importlib.import_module(module_path), then getattr(module, class_name, None), then scorer_class(**init_kwargs). That constructor runs inside the server process with request-supplied arguments, before anyone has established what the caller is allowed to do.

Step 4 — where the file comes from. The write primitive is not part of the bug at all; it comes from MLflow’s own API. The experiment-creation endpoint accepts an artifact_location, and for file-backed experiments each run’s artifacts live at <artifact_location>/<run_id>/artifacts/. Point that at a writable scorer package directory, create a run, upload a .py file into the run’s artifact path — and the uploaded file is importable as a descendant of an allowed package. Step 4 satisfies the prefix check from Step 1; Steps 2 and 3 do the rest.

Ordering is the second bug

The trigger is POST /ajax-api/3.0/mlflow/scorer/invoke. In mlflow/server/handlers.py, _invoke_scorer_handler() calls Scorer.model_validate_json(serialized_scorer) and only afterwards calls get_trace_batches_for_scorer(...).

Read that order again. The attacker-controlled constructor executes before the server validates or even resolves the trace IDs in the request. The authorization question — “is this caller allowed to score these traces?” — is asked after the code has already run. Even a perfectly scoped permission check on that endpoint would arrive too late.

The defaults amplify it. On a default deployment authentication is off, and mlflow server binds to 127.0.0.1 unless started with another --host such as 0.0.0.0 — so a server behind a proxy that forwards to it exposes experiment creation, run creation, artifact upload, and scorer invocation to anyone who can reach the port. The advisory’s reproduction uses the official MLflow v3.14.0 image, which runs the process as uid 0 and permits writes to its installed Python package tree. On that image, the injected code executes as root.

The authenticated case is narrower, and that matters

MLflow ships basic authentication, and turning it on does change the picture. Worth being precise about how, because “auth is on, so we are fine” is exactly the wrong conclusion.

With basic authentication enabled, authentication still applies — you need valid credentials. But the scorer-invocation AJAX endpoint is assigned to validate_gateway_proxy, which returns True without performing an experiment-level or scorer-level resource permission check. The deserialization itself is not gated by a resource-specific permission check for the supplied experiment.

So exploitation in an authenticated deployment still requires the caller to create experiments, create runs, and upload artifacts to them. That is a real constraint and meaningfully narrower than the unauthenticated case. It is not a mitigation, though. On a shared deployment, being an authenticated user who can create an experiment is a low bar, and the artifact-upload primitive is a normal part of the product rather than a privilege escalation.

Where the evidence stops

Four claims are worth refusing to make, because each is the version most likely to travel.

Downloads are not exposure. PyPI recorded 4,880,597 MLflow downloads in the week before 2026-10-28. That is a scale number and nothing more: it counts every download of the package — local installs, CI runs, notebooks — not tracking servers, and certainly not reachable ones.

There is no known exploitation, and the CVE is not in CISA’s KEV catalog. Nothing in the sources indicates use in the wild. That is the reason to fix it now rather than treat it as a debrief.

The published proof of concept is deliberately harmless. It performs one action: the uploaded constructor writes a random marker beside its own uploaded run artifact, which is then downloaded through MLflow’s run-artifact endpoint and checked for an exact match. It is a demonstration, not a weapon, and should not be described as a working exploit.

This is not a pickle or YAML deserialization bug. The mechanism is import-by-name plus an attacker-placed module. That distinction matters for remediation: a deserialization hardening pass would not have prevented this.

One attribution note: MLflow is an independent open-source project. It is not a Databricks product, and this advisory does not describe a Databricks vulnerability.

Exposure needs three conditions at once

Being on an affected version is not enough. Full exploitation requires all three of these together:

  1. Version. MLflow 3.12.0 (including 3.12.0rc0) up to but not including 3.17.0.
  2. Reachability. The HTTP endpoints — experiment creation, run creation, artifact upload, scorer invocation — reachable by callers who should not be able to create experiments and upload artifacts.
  3. A writable package tree. The server process able to write beneath one of its installed scorer packages.

Miss any one and the chain breaks, which is why the remediation is not a single command.

Fix MLflow properly: upgrade, then restart the process

The advisory’s remediation is to upgrade the server package to 3.17.0 — published 2026-10-07, twenty days before the advisory — and recreate the server containers so the running process is actually replaced. Package metadata alone does not prove that a long-running process restarted after an upgrade. A container whose image was updated but whose process never restarted is still running the old code.

Two checks confirm which one you actually have:

# what the running process reports
curl -fsS http://<server>:<port>/version
# what is installed in the container
docker exec <container> python -c 'import mlflow; print(mlflow.__version__)'

Two structural fixes block the placement chain directly: a read-only container filesystem, or a scorer package directory the server process cannot write to. Either one removes Step 4 from the attack, regardless of version.

If the upgrade cannot happen today, the advisory’s interim guidance is to restrict who can reach the server. Be careful there: basic authentication does not stop an authenticated user who can create experiments and upload artifacts.

The prefix check was the bug. The trust boundary was the error.

It is tempting to file this under “someone used startswith instead of ==.” That is accurate and not sufficient. The deeper mistake is treating a naming convention as a provenance boundary — assuming a module inside mlflow.genai.scorers.ragas was put there by the people who wrote ragas, when the platform’s own artifact API hands any caller the ability to place a file there. The allowlist was asking a question about names when the real question was about who wrote the bytes.

That generalizes past MLflow. Anywhere an AI platform accepts uploaded files and imports by name, the upload path is a code path. Prompt payloads, dataset uploads, agent traces, artifact stores, plugin directories — each is a place where a namespace check can be the only thing between an attacker and the interpreter.

The practical takeaway for anyone running MLflow today: check the running process, not just the manifest; if the server process can write into its own installed package tree and unauthenticated callers can create experiments and upload artifacts, you have the full chain. More broadly, audit your own stack for the same shape — an allowlist validating a name where it needed to validate a source.


Sources

The GitHub advisory record carries a publication timestamp of 2026-10-27; the five secondary write-ups above are coverage of the same disclosure. Version ranges, the CVSS score, and the dates above are attributed to the GitHub advisory and Ostorlab, not independently re-verified here.