backend / security / 08_pickle_yaml_deserialization.md

Pickle, YAML, and Deserialization Attacks (Python-Specific)

6 interview angles 6 min read source

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:

  1. Broker is accessible to internal services + an attacker pivots into it.
  2. Attacker pushes a malicious pickled task.
  3. 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.loads dangerous?” — 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.load differ from yaml.safe_load?”yaml.load (with FullLoader / unsafe Loader) supports !!python/object tags that instantiate arbitrary Python objects on parse — same RCE class. yaml.safe_load uses 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 crucially accept_content = ["json"] to reject pickle payloads. Find code that pickled rich types and migrate to JSON-compatible structures.
  • “What’s the safe alternative to eval for 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 if file is untrusted. NumPy uses pickle when allow_pickle=True, inheriting all its risks. Default is False in modern NumPy; the explicit True is 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.