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:
SESSION_SERIALIZERis unset or the JSON serializer. A custom serializer class in settings needs reading.- Nothing passes a custom
serializer=todjango.core.signingthat wraps pickle. SECRET_KEYis out of the repository and rotated withSECRET_KEY_FALLBACKSwhen 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.