hacnho's picture
Upload README.md with huggingface_hub
ca8b20c verified
|
Raw
History Blame Contribute Delete
6.51 kB
# 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.