| # 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 |
|
|
| ```bash |
| # 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.py` for 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 |
|
|
| ```bash |
| 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 |
|
|
| ```bash |
| modelscan scan -p selfcontained_bypass_277.pkl -r json |
| # Expected: "total_issues": 0 |
| ``` |
|
|
| ### Step 3: Reproduce from scratch |
|
|
| ```bash |
| 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()`, or `torch.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`, `importlib` to ModelScan's blocklist |
| - Implement behavioral analysis or dynamic taint tracking |
| |