# 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