Insecure deserialization beyond pickle: yaml.load, marshal, shelve, jsonpickle, Django signed cookies

Pickle is the best-known unsafe deserializer in Python, and it is not the only one. yaml.load with a permissive loader, marshal, shelve and jsonpickle can all build objects or run code from bytes an attacker shaped, and a signed cookie is only as safe as the serializer behind the signature. The practical rule: for anything that crosses a trust boundary, use a format that can only carry data (JSON, or YAML through safe_load), validate its shape, and treat a signature as proof of who wrote the bytes, not as a reason to load them with a format that can run code. This article takes each loader in turn, with the documented warning and the replacement.

The weakness is CWE-502: Deserialization of Untrusted Data, part of OWASP's A08:2025 Software or Data Integrity Failures. For pickle itself, and where it hides inside caches, queues and model files, see our earlier article.

The question to ask of any loader

A deserializer is one of two kinds. A data-only loader builds strings, numbers, lists and dictionaries, and nothing the input says can make it do more. An object loader builds instances of classes named in the input, which means the input chooses which code runs during the load. The second kind is the risk, and the format's appearance says nothing about which kind it is: YAML and JSON both look like harmless text.

So for every load in a codebase, two questions: which kind of loader is this, and could anyone outside your trust boundary have influenced the bytes, now or at some earlier point?

yaml.load: the loader is the decision

PyYAML ships several loaders, and the difference between them is exactly the data-only versus object distinction.

Call Builds Use on untrusted input
yaml.safe_load(data) / Loader=yaml.SafeLoader scalars, lists, dictionaries yes
yaml.full_load(data) / Loader=yaml.FullLoader most YAML tags, with restrictions no
yaml.unsafe_load(data) / Loader=yaml.UnsafeLoader or yaml.Loader arbitrary Python objects no

The PyYAML documentation says of the full power of yaml.load that it is "as powerful as pickle.load and so may call any Python function". The project deprecated calling yaml.load without an explicit Loader for that reason, as its deprecation note explains.

FullLoader deserves a specific mention, because its name suggests a sensible default. It was introduced as a safer middle ground, and two code execution vulnerabilities were later found in it: CVE-2020-1747 and CVE-2020-14343, both rated Critical by NVD, both through full_load or FullLoader on untrusted input. The PyYAML maintainers fixed both, and the deprecation note still advises against FullLoader for untrusted data. The lesson generalises: a restricted object loader is a list of exceptions that has to stay complete forever, while a data-only loader has nothing to keep complete.

import yaml


def read_config(text: str) -> dict:
    data = yaml.safe_load(text)
    if not isinstance(data, dict):
        raise ValueError("expected a mapping at the top level")
    return data

Search for yaml.load( and read the Loader argument on every hit. Also search for yaml.Loader, UnsafeLoader, FullLoader, unsafe_load and full_load, and for libraries that wrap PyYAML and choose the loader for you.

marshal: an internal format

marshal is the format Python uses for compiled bytecode files. The documentation is unambiguous: "The marshal module is not intended to be secure against erroneous or maliciously constructed data. Never unmarshal data received from an untrusted or unauthenticated source" (marshal).

It turns up in application code for two reasons: it is fast, and it can serialize code objects, which makes it attractive for shipping functions between processes. The second use is the dangerous one, because the loaded code object is then executed. If marshal.loads appears outside build tooling, ask what the bytes are and who can write them. There is no safe subset to switch to; use JSON for data and import real modules for code.

shelve: pickle with a file name

shelve gives you a persistent dictionary, and it stores the values with pickle. The documentation says so in a warning: "Because the shelve module is backed by pickle, it is insecure to load a shelf from an untrusted source. Like with pickle, loading a shelf can execute arbitrary code" (shelve).

The call that matters does not contain the word pickle:

import shelve


def load_preferences(path: str) -> dict:
    with shelve.open(path, flag="r") as shelf:
        return dict(shelf)

This is safe while only your service writes path. It becomes a code execution path the moment the shelf file can be uploaded, restored from a user-supplied backup, synced from another tenant's storage, or written by a less privileged process on the same host. For a small persistent mapping, sqlite3 with JSON text values does the same job with a data-only format.

jsonpickle: JSON on the wire, objects on load

jsonpickle serializes arbitrary Python objects into JSON and rebuilds them on decode. The output is valid JSON, which is why it gets mistaken for a safe format. The project's own documentation opens with the warning: "The jsonpickle module is not secure. Only unpickle data you trust", and adds that "safer deserialization approaches, such as reading the raw JSON directly, may be more appropriate if you are processing untrusted data" (jsonpickle documentation).

The encoded form carries type names, and jsonpickle.decode() imports and instantiates what they name. If the data came from outside, decode it with json.loads and build your own objects from the plain values:

import json
from dataclasses import dataclass


@dataclass
class Job:
    name: str
    retries: int


def job_from_message(body: bytes) -> Job:
    data = json.loads(body)
    if not isinstance(data, dict):
        raise ValueError("expected an object")
    return Job(name=str(data["name"]), retries=int(data["retries"]))

The construction is explicit: the code names the class, and the input supplies only values.

json with hooks: safe until you add the dispatch

json.loads by itself is data-only. Its object_hook and object_pairs_hook parameters call your function for every decoded object, and that function can do anything. The risky pattern is a hook that picks a class from a field in the input:

import json

REGISTRY = {}


def revive_wrong(obj: dict):
    # the input names the class to build
    cls = REGISTRY.get(obj.get("__type__"))
    return cls(**obj["fields"]) if cls else obj


def load_wrong(text: str):
    return json.loads(text, object_hook=revive_wrong)

With a small, explicit registry of harmless value classes this is manageable. With a registry filled by import-time decorators across the codebase, or one that falls back to importing a dotted name, it is jsonpickle again. Keep the registry closed and short, and validate the fields before constructing.

Django signed cookies: what the signature covers

Signed cookies and tokens are sometimes described as making deserialization safe. The signature answers one question (did we write these bytes?), and the serializer still decides what loading them can do.

Django's history shows the difference. Its session framework once offered a pickle-based serializer; it was deprecated in 4.1 and removed in 5.0 (release notes). With the cookie-based session backend, session data is "signed but not encrypted" (cookie-based sessions): the client can read it and cannot change it without the key. Under the pickle serializer, anyone who obtained SECRET_KEY could sign a payload of their choice and have the server load it, turning a leaked key into code execution. Under the JSON serializer the same leak lets an attacker forge session contents, which is serious (it is an authentication bypass), and it is not code execution.

Django's signing documentation makes the design choice explicit. signing.dumps() and loads() use JSON, and "JSON ensures that even if your SECRET_KEY is stolen an attacker will not be able to execute arbitrary commands by exploiting the pickle format" (protecting complex data structures).

Three checks for a Django project:

  1. SESSION_SERIALIZER is unset or the JSON serializer. A custom serializer class in settings needs reading.
  2. Nothing passes a custom serializer= to django.core.signing that wraps pickle.
  3. SECRET_KEY is out of the repository and rotated with SECRET_KEY_FALLBACKS when it has been exposed.

The same reasoning applies to itsdangerous in Flask, to JWT libraries and to any HMAC-wrapped blob: sign data-only payloads.

Finding the loads that matter

Start with a search: yaml.load, full_load, unsafe_load, marshal.load, shelve.open, jsonpickle.decode, object_hook, object_pairs_hook, pickle.load, and serializer= near signing code. Bandit reports several of these, including B506 for yaml.load.

Then comes the part that takes time: for each hit, working out where the bytes came from. A YAML document may arrive in a request, or be read from a repository a customer controls, or be pulled from a queue that another service fills. The load and the origin are often in different services, and the loader may sit inside a library.

That backward trace is what es-python, the EyalSec Python runtime, does while the program runs. It follows untrusted data from where it enters and checks it at the operation itself, the load call, so it reports a deserializer only when bytes from an untrusted source actually reached it. The labels are yaml unsafe load for an unsafe PyYAML loader, marshal loads, object hook for json.loads() with a hook set, unsafe deserialization for jsonpickle, and pickle loads and pickle load. yaml.safe_load() and a plain json.loads() are not treated as deserialization sinks, so the corrected code above is silent (what the event looks like).

Two properties are specific to this class. EyalSec never suppresses a deserialization event on the value: for an injection it can sometimes prove the untrusted characters could not change the statement, and a serialized blob has no such safe form. And the check is at the load, so it applies to a worker reading a broker message as it does to a web request. As with every event, it shows that the flow happened, with the source and the call path; with data from a file only your service can write, the grade is low and the event says why. Under a Raise rule the load is stopped before the bytes are processed. See How EyalSec works for the model and pricing for cost.

Summary

Loader Kind Replace with
yaml.load with Loader, FullLoader, UnsafeLoader object yaml.safe_load plus shape validation
marshal.loads internal, no safety guarantee JSON for data; real imports for code
shelve.open pickle sqlite3 with JSON values
jsonpickle.decode object json.loads and explicit construction
json.loads(..., object_hook=...) depends on the hook a closed registry, validated fields
Signed cookie or token as safe as its serializer JSON payload under the signature

If you want to see which deserialization calls on your own services receive untrusted bytes, you can book a live demo.

EyalSec Pricing Docs Security Research Vulnerabilities Security glossary About Contact עברית Login Book a live demo