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-suppliedcode/directory tosys.path(sys.path = [code_path] + sys.path), with no allowlist.importlib.import_module(conf[MAIN])imports the attacker-namedloader_modulefrom 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
MLmodeldescriptor and flag apython_functionflavor that ships acodedirectory / customloader_module(model-supplied importable Python is an execution sink), rather than only looking for pickle bytes. - MLflow: treat a model's
code/+loader_moduleas untrusted on load (the same threat model that motivated gating remote code elsewhere); do not silently prepend an artifact-supplied directory tosys.pathand import from it without an explicit opt-in.
Dedup / novelty
- Distinct from the known MLflow CVEs: CVE-2024-37054/37058 are
cloudpickledeserialization ofpython_model.pkl(a pickle path β caught by both scanners, see Control); CVE-2025-15379 ispython_env.yamlshell-injection; CVE-2025-11201 is tracking-server path traversal. None is thepython_functionloader_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/.pteextension-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 (benignecho > markerpayload).model/MLmodel,model/code/evil_loader.pyβ the PoC model.