YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
- ModelScan 0.8.8 Bypass β Pickle Deserialization RCE
ModelScan 0.8.8 Bypass β Pickle Deserialization RCE
Target: huntr.com (Protect AI Model Upload Program)
Reward Tier: $750 (Unsafe Deserialization β Scanner Bypass)
CWE: CWE-502 (Deserialization of Untrusted Data)
Executive Summary
ModelScan 0.8.8 is Protect AI's scanner for detecting unsafe operations in ML model files. It flags pickle files containing dangerous GLOBAL imports (e.g., os.system, subprocess.Popen) by analyzing pickle opcodes via pickletools.genops().
This report demonstrates a complete bypass of ModelScan 0.8.8's pickle scanner. The technique achieves arbitrary code execution via pickle.load() while producing zero ModelScan issues.
Status: β ModelScan bypass confirmed | β RCE confirmed
The Vulnerability
Python's pickle module is inherently unsafe β pickle.load() can execute arbitrary code by design. ModelScan attempts to detect this by scanning pickle opcodes for "unsafe" module imports.
Blocklist (from modelscan/settings.py)
| Severity | Blocked Modules/Functions |
|---|---|
| CRITICAL | __builtin__.{eval,compile,getattr,apply,exec,open,breakpoint,__import__}, builtins.{eval,...}, os*, nt*, posix*, subprocess*, sys*, socket*, pty*, pickle*, _pickle*, shutil*, asyncio*, runpy*, bdb*, pdb*, operator.attrgetter |
| HIGH | webbrowser*, httplib*, requests.api*, aiohttp.client* |
Critical Gap: Modules NOT in the Blocklist
The following stdlib modules can be used for code execution but are completely absent from ModelScan's blocklist:
- β
marshalβmarshal.loads()deserializes Python code objects - β
typesβtypes.FunctionType()creates executable functions from code objects - β
ctypesβctypes.CDLL(None).system()calls libc directly - β
importlibβimportlib.import_module('os')loads any module at runtime
Golden PoC: selfcontained_bypass_277.pkl
| Metric | Value |
|---|---|
| Size | 277 bytes |
| SHA256 | 9bc06071f234c0ddfe96ca9b43ecd873516492cbf99ae2ab835fbbbf2a6a2f80 |
| pickle.load() RCE | β Confirmed |
| ModelScan 0.8.8 | β 0 issues (0 critical, 0 high, 0 medium, 0 low) |
| Dependencies | None β fully self-contained |
Verification
# RCE test (no dependencies)
python3.11 -c "import pickle; pickle.load(open('selfcontained_bypass_277.pkl','rb'))"
cat /tmp/huntr_bypass_poc # β BYPASS_OK
# ModelScan bypass verification
modelscan scan -p selfcontained_bypass_277.pkl -r json
# β "total_issues": 0, "issues": []
Tested environment: CPython 3.11.15 + ModelScan 0.8.8 Note: marshal-based code objects are CPython minor-version specific; regenerate using
build_selfcontained_pickle.pyfor the target Python minor version.
Technical Breakdown
The pickle uses only 2 GLOBAL imports, neither blocked:
| Opcode | Import | In ModelScan blocklist? |
|---|---|---|
GLOBAL types FunctionType |
types.FunctionType |
β NOT BLOCKED |
GLOBAL marshal loads |
marshal.loads |
β NOT BLOCKED |
Execution chain:
pickle.load()
β marshal.loads(embedded_code_bytes) # NOT scanned
β types.FunctionType(code_obj, {}) # NOT scanned
β function() β import os β os.system() # Runtime, invisible to scanner
The embedded code object (232 bytes) contains a compiled function that imports os and calls os.system() at runtime β ModelScan never sees these opcodes because they don't exist in the pickle stream.
Alternative Bypass Variants
All confirmed 0 issues on ModelScan 0.8.8:
| Variant | Size | Technique |
|---|---|---|
marshal+types |
291 B | marshal.loads β types.FunctionType |
ctypes |
115 B | ctypes.CDLL(None).system() |
importlib |
118 B | importlib.import_module('os') |
class_traversal |
124 B | ().__class__.__bases__[0].__subclasses__() β os.system |
Reproduction
Requirements
- Python 3.8+
modelscan>=0.8.0(optional, for verification)- No additional dependencies
Step 1: Verify RCE
python3.11 -c "import pickle; pickle.load(open('selfcontained_bypass_277.pkl','rb'))"
cat /tmp/huntr_bypass_poc # β BYPASS_OK
Step 2: Verify ModelScan bypass
modelscan scan -p selfcontained_bypass_277.pkl -r json
# Expected: "total_issues": 0
Step 3: Reproduce from scratch
python3.11 build_selfcontained_pickle.py
# Builds the 277-byte payload from source code (CPython version-specific)
Deliverable Files
| File | Size | Description |
|---|---|---|
selfcontained_bypass_277.pkl |
277 B | β Golden PoC β self-contained pickle, 0 deps, ModelScan bypass |
build_selfcontained_pickle.py |
7.5 KB | Builder script (reproduces golden PoC) |
README.md |
This file | |
pickles/pickle_bypass_marshal.pkl |
291 B | marshal+types variant |
pickles/pickle_bypass_ctypes.pkl |
115 B | ctypes variant |
pickles/pickle_bypass_importlib.pkl |
118 B | importlib variant |
pickles/pickle_bypass_class_traversal.pkl |
124 B | class_traversal variant |
Impact
- Any application using
pickle.load(),joblib.load(), ortorch.load()on untrusted files is vulnerable - ModelScan 0.8.8 provides zero protection against this attack
- The technique works against ALL pickle-compatible scanners that only check GLOBAL opcodes
- Alternate bypasses:
ctypes,importlib,builtins(partially)
Mitigation
- Do NOT rely on static pickle analysis for security β pickle is inherently unsafe
- Use alternative formats:
.safetensors, ONNX, or signed pickles - Run model loading in isolated sandboxes (containers, seccomp, AppArmor)
- Add
marshal,types,ctypes,importlibto ModelScan's blocklist - Implement behavioral analysis or dynamic taint tracking