Skip to main content
All articles

Pickle is a code format, not a data format: CVE-2025-32434 and the model file you just downloaded

torch.load(weights_only=True) was the recommended safe path, and it was bypassable. A practical look at Python deserialization, pickle, PyYAML and jsonpickle, how one gadget works, and what actually stops it.

8 min read1 view

There is a sentence in the Python documentation that most people have read and very few have believed:

The pickle module is not secure. Only unpickle data you trust.

It sounds like the boilerplate warning attached to every sharp tool. It is not. It is a precise statement that pickle.loads() is a program interpreter, and the bytes you hand it are the program. Not "data that could contain a malicious value". A program, with an opcode that says "import this module and call this function with these arguments."

This post shows you what that means concretely, why the standard mitigation failed in 2025, and what the actual fix is. It is aimed at anyone who loads a serialised object they did not create, which, in the machine-learning era, is nearly everyone.

What pickle actually does

Pickle is a stack-based virtual machine with about seventy opcodes. Most of them build data structures. Two of them do not.

GLOBAL (c in protocol 0) imports a module and pushes an attribute of it onto the stack. REDUCE (R) pops a callable and an argument tuple and calls it. Put those together and you have arbitrary function invocation, expressed in a format that looks like binary junk.

You can see the whole machine in about fifteen bytes:

import pickletools
# protocol 0 is human-readable, which makes the point better than protocol 5
payload = b"cos\nsystem\n(S'id'\ntR."
pickletools.dis(payload)
    0: c    GLOBAL     'os system'
   11: (    MARK
   12: S        STRING     'id'
   18: t        TUPLE      (MARK at 11)
   19: R    REDUCE
   20: .    STOP

Read that output again. It is not obfuscated, it is not clever, and it is not an exploit in the usual sense. It is pickle doing precisely what pickle is specified to do. There is no bug being triggered here. pickle.loads() on untrusted input is the vulnerability, in the same way that eval() on untrusted input is the vulnerability.

The way an object opts into this is __reduce__, which is a documented, supported part of the protocol:

class Example:
    def __reduce__(self):
        # Whatever this returns is what unpickling will CALL
        return (print, ("this ran during load, not after",))

Serialise that, hand it to someone, and pickle.loads() runs print before returning anything to the caller. Swap print for something else and you understand the entire bug class. Note especially that nothing needs to be executed afterwards. The side effect happens during load. Code that carefully validates the returned object has already lost.

CVE-2025-32434: when the safe path was not safe

For years the advice to the ML community was: pickle files are dangerous, so use torch.load(..., weights_only=True). That flag swaps the general unpickler for _weights_only_unpickler, a restricted one that only permits opcodes needed to rebuild tensors.

In April 2025, CVE-2025-32434 landed: torch.load with weights_only=True still reached remote code execution, CVSS v4 9.3, affecting PyTorch 2.5.1 and earlier, fixed in 2.6.0.

The root cause is worth understanding because it is a pattern, not a one-off. PyTorch has two loading paths: the modern zip-based format and _legacy_load for older .tar-style checkpoints. The restricted unpickler was wired into the modern path. _legacy_load did not adequately ensure the same constraints applied to everything it processed. So a file that presented itself as a legacy checkpoint took a code path where the safety flag was, in effect, advisory.

Three lessons generalise well beyond PyTorch:

  1. A safety flag is a claim about one code path. Formats with multiple parsers have multiple code paths, and the fallback parser is usually the older, less-examined one. When you add a hardening flag, the question to ask is "which parsers does this not cover?"
  2. Format detection happens before validation. The file itself chose which loader ran. Attacker-controlled input selecting the code path that processes it is a recurring shape. The same shape as polyglot file uploads and content-type confusion.
  3. The mitigation was public and widely adopted, which made it a high-value target. Advice that becomes universal becomes worth attacking.

If you load models from Hugging Face, a teammate, a research repo, or an internal artefact store, that is a real supply-chain path into your training and inference infrastructure, which typically sits on machines with GPUs, cloud credentials and access to data you care about.

The same bug, three other places

Pickle gets the attention, but the class is broader. Any deserialiser that can reconstruct arbitrary types is a code execution primitive.

PyYAML. yaml.load() without a safe loader honours !!python/object/apply: tags, which are the YAML spelling of REDUCE:

# The shape. Not a working payload -- the point is the tag.
!!python/object/apply:os.system ["id"]

Modern PyYAML defaults yaml.load() to requiring an explicit Loader=, which helped enormously, but yaml.unsafe_load() and Loader=yaml.Loader are still one autocomplete away, and plenty of code predates the change. Use yaml.safe_load(), always, with no exceptions you cannot justify in a comment.

jsonpickle. The name is reassuring and the behaviour is not. It encodes a py/object key naming a class to instantiate. It is JSON on the wire and pickle in spirit.

Java, .NET, PHP. Same class, different gadgets. Java's ObjectInputStream.readObject() plus a classpath containing Commons Collections is the canonical example; .NET's BinaryFormatter is deprecated for exactly this reason; PHP's unserialize() with a POP chain is the same idea. If you are testing a non-Python stack, look for the same primitive.

Finding it

The greps are short because the sinks are few:

# Python
grep -rn "pickle.load\|pickle.loads\|cPickle\|dill.load\|joblib.load" --include="*.py" .
grep -rn "yaml.load(\|yaml.unsafe_load\|Loader=yaml.Loader" --include="*.py" .
grep -rn "jsonpickle.decode\|shelve.open\|torch.load" --include="*.py" .

# Java / .NET / PHP
grep -rn "readObject()\|BinaryFormatter\|unserialize(" .

For each hit, one question decides everything: where did those bytes come from? A pickle written by your own process to your own disk and read back is fine. A pickle that arrived over HTTP, out of a cache, from a message queue, from object storage another team writes to, or from a model registry, is a remote code execution finding.

Pay particular attention to the places people forget are deserialisation:

  • Session stores. A Flask or Django session backed by pickle in Redis turns "write access to Redis" into "code execution in the web app."
  • Caches. memcached and Redis caches storing pickled Python objects have the same property.
  • Celery and other task queues, if the serialiser is set to pickle rather than json.
  • ML checkpoints, .pkl feature stores, and anything a data science workflow moves between machines.

That Redis example is the one worth dwelling on. Teams routinely rate an exposed Redis instance as medium severity. "an attacker could read or corrupt cached data". If the cache holds pickled objects, it is critical: writing a key is executing code.

Fixing it

In order of preference:

1. Use a data format for data. JSON, Protocol Buffers, MessagePack, Arrow, Parquet. These reconstruct values, not types. A malicious JSON document can give you a surprising value; it cannot give you a function call. This is the only fix that removes the class rather than constraining it.

2. For ML weights, use safetensors. It was designed in response to exactly this problem: a header plus raw tensor bytes, no code path that constructs arbitrary Python objects. Most of the Hugging Face ecosystem publishes both formats now, so preferring .safetensors is usually a one-line change.

3. If you cannot change the format, authenticate it. An HMAC over the serialised bytes with a key the producer and consumer share means you only unpickle things you produced. This is a real mitigation and it is what session cookie signing is doing. It fails the moment the key leaks, so treat it as defence in depth rather than a fix, and note that this is precisely the failure mode behind the ASP.NET machine-key attacks.

4. Upgrade, and keep upgrading. torch>=2.6.0 for CVE-2025-32434. But note the ordering: upgrading is fourth on this list, not first, because the next bypass of a restricted unpickler is a question of when.

Do not try to build a "safe unpickler" yourself by restricting find_class. People have tried; the object graph reachable from an innocuous-looking allowlist is much larger than it appears, and you will be maintaining a blocklist against a language designed for introspection.

Detection

Deserialisation attacks are quiet by nature. The payload executes inside your process, and the process looks normal until it does not. What you can watch for:

  • Process lineage. Your Python web worker or training job spawning sh, bash, curl, wget or python -c is almost never legitimate. This single rule catches most real-world deserialisation exploitation regardless of the language or gadget, because the payload nearly always needs a child process.
  • Outbound connections from processes that should not make them. A model-loading step reaching the network is worth an alert.
  • File magic on ingest. Pickle protocol 2+ starts with \x80\x02 through \x80\x05. If a field is supposed to hold JSON and starts with 0x80, that is not a parsing edge case.
  • GLOBAL and REDUCE opcodes in files you accept. pickletools.genops() lets you scan a pickle without executing it. If you must accept pickles from a trust boundary you do not control, scanning first is strictly better than not.

Take this away

pickle.loads(untrusted) is eval(untrusted) wearing a filename extension.

Everything else in this post. The CVE, the YAML tags, the Redis session store, is a variation on people not treating it that way, usually because the serialised bytes arrived through a channel that felt like data transport rather than code delivery.


Further reading

Was this useful?

Share

Tags

  • insecure deserialization
  • ai security
  • python
  • cve analysis
  • secure code review
  • penetration testing

Comments

Loading comments…

Leave a comment

Comments are read and approved by hand before they appear, so yours will not show up straight away. Your email address is optional, is never published, and is only used if we need to reply to you directly.

0/5000

Related service

AI Model Penetration Testing

Prompt injection, data exfiltration and misuse testing of LLM-backed features.

If you want to know whether what you have just read applies to your own systems, that is the engagement that answers it.