Views
No views yet
mlflow.pyfunc.load_model, hidden from picklescan and modelscan because the malicious payload contains no picklepython_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.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.mlflow.pyfunc)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..py) and YAML (MLmodel), neither of which picklescan scans (no pickle magic/extension) nor modelscan supports (skipped as unknown extensions).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/...")$ 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$ python -c "import mlflow.pyfunc, os; mlflow.pyfunc.load_model('model'); \
print('marker:', os.path.exists('/tmp/mlflow_pyfunc_pwned.txt'))"
marker: Trueos.system executed from evil_loader.py's module body during load_model.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'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.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.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.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..pt2/.pte extension-skip) — all of those are pickle-stream tricks; this is a pickle-free code-import path.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).build_poc.py — generates the malicious MLflow model directory (benign echo > marker payload).model/MLmodel, model/code/evil_loader.py — the PoC model.