Skip to main content

Insecure deserialization in Python: pickle, yaml.load, jsonpickle, shelve and torch.load

Most injection bugs need an attacker to find a query or a shell command. Insecure deserialization skips that step. If an attacker controls the bytes you hand to pickle.loads, they do not need a second bug: loading the object is the exploit. That is why CWE-502, deserialization of untrusted data, keeps turning up in Python code, and why it now matters for machine learning teams as much as web teams.

This post walks through the Python APIs that deserialize into live objects, what makes each one dangerous, and the safe replacement for each.

Why pickle runs code

Pickle is not a data format in the way JSON is. It is a small instruction set for rebuilding objects, and one of those instructions calls an arbitrary callable with arguments. Any class can define __reduce__ to say "to rebuild me, call this function", and the unpickler obeys.

import os, pickle

class Exploit:
    def __reduce__(self):
        return (os.system, ("id > /tmp/pwned",))

payload = pickle.dumps(Exploit())
pickle.loads(payload)  # runs the shell command during load

The Python documentation says it plainly: the module is not secure, and malicious pickle data can execute arbitrary code during unpickling. There is no flag that makes pickle.loads safe on untrusted input. The same holds for cPickle, _pickle and anything built on them.

The typical vulnerable shape in a web app is a cookie, cache entry or queue message that round-trips through pickle:

# Vulnerable: session state read back from a client-controlled cookie
import base64, pickle

@app.route("/dashboard")
def dashboard():
    state = pickle.loads(base64.b64decode(request.cookies["state"]))
    return render(state)
# Fixed: plain data in JSON, signed so the client cannot alter it
from itsdangerous import URLSafeSerializer

signer = URLSafeSerializer(app.secret_key)

@app.route("/dashboard")
def dashboard():
    state = signer.loads(request.cookies["state"])  # JSON, signature checked
    return render(state)

JSON gives you dicts, lists, strings and numbers, never objects with behaviour. If you need a schema, validate the JSON with a model class after parsing. If you truly must store pickle somewhere an attacker could reach, the Python docs suggest signing it with hmac so tampering is detected before load, but that only moves the trust question to the key.

yaml.load vs yaml.safe_load

PyYAML can construct arbitrary Python objects from tags such as !!python/object/apply. With the wrong loader, a YAML config file is a code execution primitive.

# Vulnerable: UnsafeLoader (or the old bare yaml.load) builds any Python object
import yaml
config = yaml.load(request.data, Loader=yaml.Loader)
# Fixed: SafeLoader only builds plain scalars, lists and dicts
import yaml
config = yaml.safe_load(request.data)

The history matters if you maintain older code. PyYAML 5.1 started warning when yaml.load was called without a Loader, and PyYAML 6.0 made the argument required. The project's own deprecation notes recommend SafeLoader for untrusted input and warn that FullLoader should not be used on untrusted data either: exploits against it were found after it shipped. So the rule is simple. Use yaml.safe_load (or Loader=yaml.SafeLoader) unless you are loading a file you wrote yourself and need custom tags.

jsonpickle and shelve: pickle in disguise

Two libraries look safer than they are.

jsonpickle produces JSON, which reads as harmless, but its whole purpose is to rebuild arbitrary Python objects from that JSON. Its documentation repeats the pickle warning: never decode data that could have come from an untrusted source. jsonpickle.decode(request.body) is the same bug as pickle.loads(request.body) with a friendlier file format.

shelve stores a dictionary on disk, backed by pickle. The shelve docs warn that loading a shelf from an untrusted source can execute arbitrary code. That matters when shelf files are uploaded, synced from a shared drive, or restored from a backup an attacker could have modified.

Other libraries in the same family: dill, cloudpickle, joblib.load, and pandas.read_pickle all unpickle. numpy.load only unpickles when you pass allow_pickle=True, so leave it off for files you did not create.

torch.load and model files

Machine learning made pickle a supply chain problem. A PyTorch checkpoint saved with torch.save is a zip archive whose core is a pickle, and torch.load uses an unpickler to read it. Downloading a model from a hub and loading it is, by default in older code, running code from a stranger.

PyTorch responded by flipping the default. Since version 2.6, torch.load uses weights_only=True, which restricts the unpickler to tensors and a small allowlist of types (PyTorch announced the breaking change ahead of the release). Two caveats:

  • Old versions are not protected even with the flag. CVE-2025-32434 showed remote code execution through torch.load with weights_only=True in versions before 2.6.0. If you load third-party models, upgrade first.
  • Code that sets weights_only=False opts back in. That line usually appears after someone hit an "unsupported global" error on an old checkpoint. Allowlist the specific class with torch.serialization.add_safe_globals instead.
# Vulnerable: full unpickling of a downloaded checkpoint
model.load_state_dict(torch.load(path, weights_only=False))
# Fixed: safetensors stores raw tensors and has no code path to run
from safetensors.torch import load_file
model.load_state_dict(load_file("model.safetensors"))

Where you control the format, prefer safetensors for weights. Treat any .pt, .pth, .bin, .ckpt or .pkl from outside your organisation as executable content: pin it by hash, load it in a sandbox with no credentials, or convert it once in an isolated job.

A short checklist

API Safe on untrusted input? Use instead
pickle.loads, pickle.load No JSON plus schema validation; sign if you must persist
yaml.load(..., Loader=yaml.Loader) No yaml.safe_load
yaml.load(..., Loader=yaml.FullLoader) No, per PyYAML yaml.safe_load
jsonpickle.decode No json.loads
shelve.open on a received file No SQLite or JSON
torch.load(..., weights_only=False) No safetensors, or weights_only=True on 2.6 or later
numpy.load(..., allow_pickle=True) No default allow_pickle=False

How Vulkro reports it

Vulkro's taint analysis treats pickle.loads and yaml.load with an unsafe loader as code-execution sinks in Python, and reports a finding when request input reaches one, with the source-to-sink hops attached (Python coverage). The MAL-LOADER-001 rule flags the download-then-run shape, including pickle.loads(requests.get(...).content). The aibom output format lists model files in the repository, hashed, with pickle formats flagged, so you can see which checkpoints are executable before anyone loads them. In the VS Code extension, yaml.load is one of the patterns with a one-line quick fix to yaml.safe_load.

FAQ

Is pickle safe if the data comes from my own database?

Only if nobody else can ever write to that row or file. A second bug (SQL injection, a shared cache, an unauthenticated queue) turns "our data" into attacker data, and pickle turns that into code execution. JSON removes the second step.

Is yaml.safe_load completely safe?

It will not construct Python objects, which closes the code execution path. It can still be slow on very large or deeply nested input, so cap the size of untrusted YAML as you would any request body.

Does weights_only=True make torch.load safe?

On PyTorch 2.6 and later it is the default and blocks arbitrary objects. Before 2.6 it was bypassable (CVE-2025-32434). For weights from untrusted sources, safetensors is the stronger choice because the format has no code path at all.

To find these sinks in your own Python services, start a 14-day trial. Related reading: taint analysis: sources, sinks and sanitizers and an AI bill of materials.