# 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/.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.