YAML Metadata Warning:empty or missing yaml metadata in repo card

Check out the documentation for more information.

MLflow model scanner bypass β€” arbitrary code execution on mlflow.pyfunc.load_model, hidden from picklescan and modelscan because the malicious payload contains no pickle

Summary

A malicious MLflow model (the python_function flavor) can execute an arbitrary OS command the moment a victim calls mlflow.pyfunc.load_model(), while both industry-standard ML model scanners β€” picklescan 1.0.4 and modelscan 0.8.8 (Protect AI) β€” report the model completely clean.

The trick is that the executable payload is pure Python source + YAML, with no pickle anywhere. On load, MLflow prepends the model's own code/ directory to sys.path and importlib.import_module()s the attacker-named loader_module, so the attacker's module body runs at import time. Because there is no pickle to flag, picklescan reports Scanned files: 0 and modelscan reports No issues found! πŸŽ‰ β€” both give the malicious model a clean bill of health β€” yet loading it is full RCE. This is a scanner blind spot: the scanners do walk the MLflow model directory (a malicious model.pkl placed in the same directory is caught by both β€” see Control), but the code-import execution path carries zero scannable pickle bytes.

Affected / verified versions

  • MLflow 3.14.0 (mlflow.pyfunc)
  • picklescan 1.0.4 (latest) β€” reports the model clean
  • modelscan 0.8.8 (latest) β€” reports the model clean
  • CPython 3.12.3

Root cause

An MLflow model is a directory containing an MLmodel YAML descriptor plus supporting files. For the python_function flavor, the descriptor names a loader_module and (optionally) a code directory of Python source to add to the path. On load (mlflow/pyfunc/__init__.py, MLflow 3.14.0):

  • _add_code_from_conf_to_system_path(...) β†’ mlflow.utils.model_utils._add_code_to_system_path(code_path) prepends the model-supplied code/ directory to sys.path (sys.path = [code_path] + sys.path), with no allowlist.
  • importlib.import_module(conf[MAIN]) imports the attacker-named loader_module from that path, then calls ._load_pyfunc(...).

importlib.import_module executes the target module's top-level body. An attacker therefore puts arbitrary code in code/<loader_module>.py's module body; it runs as soon as load_model() imports it β€” before any inference. No trust_remote_code-style opt-in gates this.

The scanners never see it: the malicious bytes are Python source (.py) and YAML (MLmodel), neither of which picklescan scans (no pickle magic/extension) nor modelscan supports (skipped as unknown extensions).

Proof of concept

build_poc.py (attached) writes a minimal malicious MLflow model:

model/
  MLmodel                  # python_function flavor; loader_module: evil_loader; code: code
  code/evil_loader.py      # module body: os.system("echo MLFLOW_PYFUNC_PWNED > /tmp/...")

Both scanners clean

$ python -m picklescan -p model
----------- SCAN SUMMARY -----------
Scanned files: 0
Infected files: 0
Dangerous globals: 0
(exit 0)

$ modelscan -p model
--- Summary ---
 No issues found! πŸŽ‰
Total skipped: 2

Execution on load

$ python -c "import mlflow.pyfunc, os; mlflow.pyfunc.load_model('model'); \
    print('marker:', os.path.exists('/tmp/mlflow_pyfunc_pwned.txt'))"
marker: True

The marker file is created by os.system executed from evil_loader.py's module body during load_model.

Control β€” proves the scanners DO walk the MLflow directory

Dropping a conventional malicious model.pkl (an os.system __reduce__) into the same model/ directory is caught by both:

$ python -m picklescan -p model   ->  model/model.pkl: dangerous import 'posix system' FOUND / Infected files: 1
$ modelscan -p model              ->  Total Issues: 1 / CRITICAL: 1 / Use of unsafe operator 'system' from module 'posix'

So the clean verdict on the PoC is not because the directory is skipped β€” it is specifically because the code-import execution path contains no pickle for the scanners to inspect.

Impact

Arbitrary code execution when a victim loads an untrusted MLflow model that both scanners cleared. MLflow models are routinely shared and loaded via mlflow.pyfunc.load_model in MLOps pipelines, model registries, and serving stacks. The scanners that gate model ingestion give this model a clean verdict, so a scan-then-load workflow is fully bypassed.

Remediation

  • picklescan / modelscan: when scanning an MLflow model directory, parse the MLmodel descriptor and flag a python_function flavor that ships a code directory / custom loader_module (model-supplied importable Python is an execution sink), rather than only looking for pickle bytes.
  • MLflow: treat a model's code/ + loader_module as untrusted on load (the same threat model that motivated gating remote code elsewhere); do not silently prepend an artifact-supplied directory to sys.path and import from it without an explicit opt-in.

Dedup / novelty

  • Distinct from the known MLflow CVEs: CVE-2024-37054/37058 are cloudpickle deserialization of python_model.pkl (a pickle path β€” caught by both scanners, see Control); CVE-2025-15379 is python_env.yaml shell-injection; CVE-2025-11201 is tracking-server path traversal. None is the python_function loader_module + code/ import-exec as a scanner blind spot.
  • Not in arXiv 2508.19774 "Art of Hide and Seek" (that paper's loading-surface analysis is pickle-based across NumPy/Joblib/PyTorch/TF-Keras/NeMo; MLflow is not covered).
  • Distinct from our other scanner-bypass findings (pickle EXT/copyreg opcode; pickle memo-build desync; pathlib/sqlite3 gadget; .pt2/.pte extension-skip) β€” all of those are pickle-stream tricks; this is a pickle-free code-import path.

Honest scope note

The loader_module + code mechanism is documented MLflow custom-pyfunc functionality, and "loading an untrusted MLflow model can run code" is a known property. The finding reported here is specifically the scanner blind spot: a malicious MLflow model that achieves RCE on load while picklescan and modelscan both return a clean verdict, because the execution path carries no pickle. The fix belongs in the scanners' MLflow coverage (and/or MLflow's load-time trust model).

Artifacts

  • build_poc.py β€” generates the malicious MLflow model directory (benign echo > marker payload).
  • model/MLmodel, model/code/evil_loader.py β€” the PoC model.
Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support