Pickle, YAML, and Deserialization Attacks (Python-Specific)
The Python ecosystem has two famous footguns: pickle.loads(...) and yaml.load(...) (without SafeLoader). Both execute arbitrary code on untrusted input. They’re in OWASP Top 10 under “Insecure Deserialization” and they keep showing up in real codebases.
pickle.loads — arbitrary code execution
import pickle
data = pickle.loads(b"...") # attacker-controlled bytes
Pickle isn’t a data format — it’s a serialized bytecode for the Python pickle VM. Among other instructions, it can call arbitrary callables with arbitrary args. The minimal exploit:
import pickle, os
class Exploit:
def __reduce__(self):
return (os.system, ("rm -rf /tmp/important",))
payload = pickle.dumps(Exploit())
# When the victim does pickle.loads(payload), os.system runs.
__reduce__ tells the pickle protocol “to reconstruct, call this callable with these args.” The victim’s pickle.loads calls os.system("rm -rf /tmp/important"). Same trick: spawn a reverse shell, download malware, exfiltrate data.
Where this matters
- Cache values from Redis stored as pickle.
- Celery task arguments with the default serializer (yes — Celery USED to default to pickle; now JSON is default, but legacy configs still use pickle).
- Django session backends that use pickle.
- Message brokers if you serialize with pickle.
- Any user-uploadable file that ends up un-pickled.
The defense is simple: never pickle.loads data you don’t trust, period. There is no “safe pickle” — every load is potential RCE.
Migration path
# Bad
data = pickle.loads(redis_value)
# Good
data = json.loads(redis_value)
# Or, for richer types
data = msgpack.unpackb(redis_value)
JSON, msgpack, protobuf — all safe formats. Pickle is an in-process Python-to-Python communication tool; do not serialize across trust boundaries.
“But we control both sides”
The argument: “We produce and consume pickle within our own services; no external input.”
Counter: every shared cache, queue, or storage layer is a trust boundary. A compromised Redis (slow exploit chain via a different bug) → attacker writes a malicious pickle → next time you pickle.loads, RCE. The pickle gadget is the LAST step of an exploit chain that started somewhere else.
Defense in depth: don’t have pickle as a step in any attacker path.
yaml.load — same problem in YAML
import yaml
data = yaml.load(user_input) # DANGEROUS — uses FullLoader by default in older PyYAML
YAML supports tagged types like !!python/object/apply:os.system [ "rm -rf /tmp" ]. With yaml.load(...) (or yaml.load(s, Loader=yaml.FullLoader)), these execute on parse.
Defense:
data = yaml.safe_load(user_input) # SafeLoader — only basic types
Or explicitly:
data = yaml.load(user_input, Loader=yaml.SafeLoader)
SafeLoader rejects all !!python/* tags. Returns plain dicts, lists, strings, numbers, booleans, None.
PyYAML’s “fix”
PyYAML 5.1+ deprecated yaml.load(s) without an explicit Loader (emits a warning). The default has shifted but legacy code still uses yaml.load. The fix is universal: always use yaml.safe_load unless you have a hard requirement otherwise.
Other YAML libraries
ruamel.yaml— modern alternative; defaults to “safe” mode.- Go / Rust YAML libs typically don’t have this footgun (no eval-by-tag).
Other deserialization risks in Python
marshal — similar to pickle but used internally for .pyc
Don’t marshal.loads untrusted input either. Same exec risk.
eval, exec — obvious
eval(user_input) # never
exec(user_input) # never
Sometimes hidden:
data = ast.literal_eval(user_input) # SAFE — only literals (numbers, strings, lists, dicts)
ast.literal_eval is the safe alternative to eval for parsing literals.
numpy.load(allow_pickle=True)
NumPy arrays serialize via pickle when allow_pickle=True. Same risk:
np.load("file.npy", allow_pickle=True) # dangerous if untrusted
np.load("file.npy") # safe (allow_pickle defaults False in modern NumPy)
NumPy 1.16+ defaults allow_pickle=False. Don’t override.
joblib.load — uses pickle
import joblib
joblib.load("model.pkl") # pickle under the hood
If you load ML models from untrusted sources, you’ve signed up for RCE.
Pandas
pd.read_pickle("file.pkl") # pickle, same risk
CSV / JSON / Parquet are safe; pickle is not.
Django
SESSION_SERIALIZER defaults to JSON since Django 1.6. If you have:
SESSION_SERIALIZER = "django.contrib.sessions.serializers.PickleSerializer"
…and a session cookie is forgeable (signed but key leaked, or signature bypass), you have RCE. Stick with JSON serializer.
Celery — the famous historical case
Celery used to default to pickle for task serialization. A workflow:
- Broker is accessible to internal services + an attacker pivots into it.
- Attacker pushes a malicious pickled task.
- Worker picks it up, calls
pickle.loads(payload), RCE on the worker.
Modern Celery defaults to json. To stay safe:
app.conf.task_serializer = "json"
app.conf.result_serializer = "json"
app.conf.accept_content = ["json"] # reject pickle even if produced
The accept_content = ["json"] is the key — it makes the worker REJECT pickle even if some other producer sends it.
What if you really need pickle
Sign the pickle data so the consumer verifies the producer:
import hmac, hashlib, pickle
def secure_dumps(obj, secret):
data = pickle.dumps(obj)
sig = hmac.new(secret, data, hashlib.sha256).digest()
return sig + data
def secure_loads(blob, secret):
sig, data = blob[:32], blob[32:]
if not hmac.compare_digest(hmac.new(secret, data, hashlib.sha256).digest(), sig):
raise ValueError("Invalid signature")
return pickle.loads(data)
The signature ensures only payloads produced by holders of secret are unpickled. Still: keep secret very secret; rotate periodically; consider whether the operational risk of “must protect signing key forever” exceeds the benefit of using pickle vs JSON+msgpack.
Honestly: just use JSON. Pickle is rarely worth the operational complexity.
Detection in CI
Linting rules to ban these:
# bandit — Python security linter
bandit -r src/
# Flags:
# B301 pickle - use of pickle
# B302 marshal - use of marshal
# B506 yaml_load - use of yaml.load
Run as part of CI. Allow only with explicit # nosec annotations that require code review.
Summary table
| API | Safe? | Use instead |
|---|---|---|
pickle.loads(untrusted) |
RCE | JSON, msgpack, protobuf |
yaml.load(untrusted) |
RCE (older PyYAML) | yaml.safe_load |
eval(user) |
RCE | ast.literal_eval (for literals) |
exec(user) |
RCE | don’t |
marshal.loads(untrusted) |
RCE | JSON |
np.load(allow_pickle=True) |
RCE | default (allow_pickle=False) |
joblib.load(untrusted) |
RCE | sign or avoid |
| Celery pickle serializer | RCE | task_serializer="json", accept_content=["json"] |
| Django Pickle session serializer | if key leaked | JSON serializer (default) |
Interview angle
- “Why is
pickle.loadsdangerous?” — pickle is bytecode for a stack-based VM that includes “call any callable with any args”. A malicious pickle can execute arbitrary code on the deserializing process via__reduce__. Treat pickle as code, not data. - “How does
yaml.loaddiffer fromyaml.safe_load?” —yaml.load(with FullLoader / unsafe Loader) supports!!python/objecttags that instantiate arbitrary Python objects on parse — same RCE class.yaml.safe_loaduses SafeLoader, which only produces basic types (dict, list, str, int, bool, None). Always use safe_load on untrusted input. - “You inherit a Celery app using pickle. What do you change?” — set
task_serializer = "json",result_serializer = "json", and cruciallyaccept_content = ["json"]to reject pickle payloads. Find code that pickled rich types and migrate to JSON-compatible structures. - “What’s the safe alternative to
evalfor parsing user-supplied literals?” —ast.literal_eval. Only accepts Python literal structures (numbers, strings, tuples, lists, dicts, booleans, None). No callables, no operators. - “You see
np.load(file, allow_pickle=True)in code. Concerned?” — yes iffileis untrusted. NumPy uses pickle whenallow_pickle=True, inheriting all its risks. Default is False in modern NumPy; the explicitTrueis a code smell on user-supplied paths. - “Defense in depth — why care about pickle if the broker is internal?” — every shared resource is a potential trust boundary. An attacker who compromises the broker (or any path to it) can push a malicious pickle. Removing pickle eliminates this class of exploit chain entirely; “trust the internal network” is dated.