Hunting Python unsafe deserialization: when loading data calls a function
Several Python serialization formats are not data formats at all; they are programs. pickle invokes
__reduce__ during load, which names a callable and its arguments and runs them, so a pickled blob can
call any importable function on reconstruction. YAML's full loader instantiates arbitrary Python objects
from tags. marshal, dill, jsonpickle, and the model and dataframe loaders that wrap pickle inherit
the same property. The vulnerability is not a bug in these libraries; it is using them on data an
attacker controls. You find it by locating every loader that reconstructs objects rather than parsing
data, and asking whether untrusted bytes reach it.
When to use
- A service loads serialized Python objects from a request, a cookie, a cache, a queue, or a file.
- The loader in use reconstructs arbitrary objects: pickle, unsafe YAML, marshal, dill, or jsonpickle.
- A model, dataframe, or checkpoint file from an untrusted source is loaded through a pickling loader.
Scope check
Test deserialization only against services you own or are authorized to assess, on non-production data. A
confirming payload runs code on load, so treat every proof as a live intrusion inside the authorized
scope. If you can't name the authorization, stop.
The loop
Establish the reconstructing loaders first. Inventory every call that reconstructs objects rather
than parsing data: pickle.loads/load, yaml.load without a safe loader, marshal.loads, dill,
jsonpickle.decode, and model or dataframe loaders that unpickle internally. This is the false-positive
killer: a json.loads or a yaml.safe_load parses data and cannot construct arbitrary objects. Name
the reconstructing sinks first.
Trace untrusted bytes into each loader. Follow request bodies, cookies, headers, cache entries,
queue messages, and uploaded or downloaded files into the loader. A model checkpoint or a dataframe
pulled from an untrusted registry, a shared bucket, or a user upload is attacker-influenced the moment
its provenance is not controlled. Confirm the bytes crossing the boundary are not internal-only.
Confirm the format supports construction, not just parsing. Pickle and unsafe YAML run a callable
during load; safe YAML and JSON do not. For a wrapped loader, confirm it reaches pickle underneath. A
loader restricted to a safe subset, a strict YAML loader, or a signed-and-verified blob changes the
answer, so read the exact call.
Check any allowlist or unpickler restriction. A custom Unpickler that overrides find_class to
an allowlist, a safe YAML loader, or a signature check on the blob before load each blunt the vector.
The absence of any restriction means __reduce__ or a YAML tag can name any importable callable.
Determine whether a real restriction stands at the sink.
Trace the reconstruction to a callable. Confirm the format's construction step reaches an
attacker-chosen callable with attacker-chosen arguments: a __reduce__ naming a system call, a YAML
tag instantiating a class with a dangerous initializer, or a checkpoint whose custom reducer runs on
load. The callable is the terminal sink.
Confirm and record. Confirm by supplying a benign in-scope blob whose reduce or tag performs an
out-of-band signal, proving the loader runs the callable, without a destructive payload. Kill the lead
if no untrusted data reaches a reconstructing loader, if the loader is a safe data parser, if an
unpickler allowlist or a verified signature constrains the blob, or if the format cannot construct
objects. Record the source, the loader, the construction mechanism, and the callable.
Where Python deserialization leaks
- pickle is a code format, not a data format.
__reduce__ runs a callable on load; there is no safe
way to unpickle untrusted data without an allowlisting unpickler.
yaml.load without a safe loader instantiates objects. A YAML tag constructs arbitrary Python
classes; only safe_load or a safe loader parses data alone.
- Model and dataframe files carry pickle. A checkpoint or serialized dataframe from an untrusted source
unpickles on load, so a poisoned artifact is code execution disguised as a data file.
- Signed does not mean safe unless verified before load. A signature checked after loading is useless;
the callable already ran.
- A cache or queue blob is untrusted on read. Even if the app wrote the first copy, an attacker who can
write the store controls what comes back.
Worked example (a confirm and a kill)
Confirm. A worker loads task payloads from a shared cache with pickle.loads and no unpickler
restriction. An attacker who can write a cache key supplies a pickle whose __reduce__ names a callable
that performs an out-of-band request. A benign marker payload confirms the callable runs on load.
Confirmed untrusted deserialization to remote code execution, critical, remediation = replace the
pickle transport with a data-only format such as JSON or a schema codec, and if pickle is unavoidable,
load through an Unpickler whose find_class allowlists only the expected classes.
Kill. The service loads task payloads only with json.loads and loads YAML config only with
yaml.safe_load; the one model file it reads is fetched from a controlled registry over an integrity
check and loaded through an allowlisting unpickler. No untrusted blob reaches a reconstructing loader.
Killed, kill_reason = "untrusted inputs parsed as data only (json, safe_load); the sole pickle load
is integrity-verified and allowlist-restricted, so no attacker callable runs on load."
Rationalizations to reject
- "It is just our internal task format." → A cache, queue, or file an attacker can write is untrusted on
read; internal use is not authentication.
- "We use YAML, which is a config format." →
yaml.load without a safe loader instantiates arbitrary
objects; only safe_load parses data. Check which one.
- "It is only a model file." → Model and checkpoint files unpickle on load; a poisoned artifact runs code.
- "The blob is signed." → Only safe if the signature is verified before the load call. Verify first, then
load, or the callable runs regardless.
- "We validate the object after loading." →
__reduce__ and YAML construction already executed. Post-load
validation is too late.
Executing this in practice
You need every reconstructing loader, the untrusted inputs that reach each, the exact loader variant
(reconstructing versus data-only), and any unpickler allowlist or pre-load signature check. For each
loader, ask whether attacker bytes arrive, whether the format constructs objects, and whether a
restriction stands between. Reading the loader call shows the intent; a benign out-of-band proof shows the
callable actually runs.
Related
hunting-java-deserialization-gadget-chains - the JVM sibling; both run code on reconstruction, one via
gadgets, one via __reduce__.
auditing-ml-model-supply-chain - model and checkpoint files are a primary untrusted source here; that
skill governs where the artifact comes from.
testing-rag-and-memory-poisoning - another path by which an untrusted artifact enters an AI pipeline and
is later loaded.
adjudicating-taint-paths - use it to confirm untrusted bytes reach a reconstructing loader through
wrappers and framework indirection.
- FINDING-SCHEMA.md - source = the untrusted serialized blob, sink = the
reconstructing loader, evidence = the reduce or tag running an attacker callable on load.
1---2name: hunting-python-unsafe-deserialization3description: Hunt Python deserialization that executes attacker code: untrusted input reaching pickle.loads, an unsafe YAML load, marshal, jsonpickle, dill, or a numpy or pandas loader that unpickles, where the format supports arbitrary object construction through __reduce__ or a tag. Covers pickled data in requests, cookies, caches, message queues, and model or dataframe files, and YAML documents that instantiate arbitrary Python objects. Use when a service loads serialized Python objects it did not produce with a loader that reconstructs arbitrary types rather than parsing data only. The untrusted serialized blob is the source, the reconstructing loader is the sink, and the __reduce__ or object tag that runs a callable during load is the bug.4license: MIT5---67# Hunting Python unsafe deserialization: when loading data calls a function89Several Python serialization formats are not data formats at all; they are programs. `pickle` invokes10`__reduce__` during load, which names a callable and its arguments and runs them, so a pickled blob can11call any importable function on reconstruction. YAML's full loader instantiates arbitrary Python objects12from tags. `marshal`, `dill`, `jsonpickle`, and the model and dataframe loaders that wrap pickle inherit13the same property. The vulnerability is not a bug in these libraries; it is using them on data an14attacker controls. You find it by locating every loader that reconstructs objects rather than parsing15data, and asking whether untrusted bytes reach it.1617## When to use1819- A service loads serialized Python objects from a request, a cookie, a cache, a queue, or a file.20- The loader in use reconstructs arbitrary objects: pickle, unsafe YAML, marshal, dill, or jsonpickle.21- A model, dataframe, or checkpoint file from an untrusted source is loaded through a pickling loader.2223## Scope check2425Test deserialization only against services you own or are authorized to assess, on non-production data. A26confirming payload runs code on load, so treat every proof as a live intrusion inside the authorized27scope. If you can't name the authorization, stop.2829## The loop30311. **Establish the reconstructing loaders first.** Inventory every call that reconstructs objects rather32 than parsing data: `pickle.loads`/`load`, `yaml.load` without a safe loader, `marshal.loads`, `dill`,33 `jsonpickle.decode`, and model or dataframe loaders that unpickle internally. This is the false-positive34 killer: a `json.loads` or a `yaml.safe_load` parses data and cannot construct arbitrary objects. Name35 the reconstructing sinks first.36372. **Trace untrusted bytes into each loader.** Follow request bodies, cookies, headers, cache entries,38 queue messages, and uploaded or downloaded files into the loader. A model checkpoint or a dataframe39 pulled from an untrusted registry, a shared bucket, or a user upload is attacker-influenced the moment40 its provenance is not controlled. Confirm the bytes crossing the boundary are not internal-only.41423. **Confirm the format supports construction, not just parsing.** Pickle and unsafe YAML run a callable43 during load; safe YAML and JSON do not. For a wrapped loader, confirm it reaches pickle underneath. A44 loader restricted to a safe subset, a strict YAML loader, or a signed-and-verified blob changes the45 answer, so read the exact call.46474. **Check any allowlist or unpickler restriction.** A custom `Unpickler` that overrides `find_class` to48 an allowlist, a safe YAML loader, or a signature check on the blob before load each blunt the vector.49 The absence of any restriction means `__reduce__` or a YAML tag can name any importable callable.50 Determine whether a real restriction stands at the sink.51525. **Trace the reconstruction to a callable.** Confirm the format's construction step reaches an53 attacker-chosen callable with attacker-chosen arguments: a `__reduce__` naming a system call, a YAML54 tag instantiating a class with a dangerous initializer, or a checkpoint whose custom reducer runs on55 load. The callable is the terminal sink.56576. **Confirm and record.** Confirm by supplying a benign in-scope blob whose reduce or tag performs an58 out-of-band signal, proving the loader runs the callable, without a destructive payload. Kill the lead59 if no untrusted data reaches a reconstructing loader, if the loader is a safe data parser, if an60 unpickler allowlist or a verified signature constrains the blob, or if the format cannot construct61 objects. Record the source, the loader, the construction mechanism, and the callable.6263## Where Python deserialization leaks6465- **pickle is a code format, not a data format.** `__reduce__` runs a callable on load; there is no safe66 way to unpickle untrusted data without an allowlisting unpickler.67- **`yaml.load` without a safe loader instantiates objects.** A YAML tag constructs arbitrary Python68 classes; only `safe_load` or a safe loader parses data alone.69- **Model and dataframe files carry pickle.** A checkpoint or serialized dataframe from an untrusted source70 unpickles on load, so a poisoned artifact is code execution disguised as a data file.71- **Signed does not mean safe unless verified before load.** A signature checked after loading is useless;72 the callable already ran.73- **A cache or queue blob is untrusted on read.** Even if the app wrote the first copy, an attacker who can74 write the store controls what comes back.7576## Worked example (a confirm and a kill)7778> **Confirm.** A worker loads task payloads from a shared cache with `pickle.loads` and no unpickler79> restriction. An attacker who can write a cache key supplies a pickle whose `__reduce__` names a callable80> that performs an out-of-band request. A benign marker payload confirms the callable runs on load.81> **Confirmed** untrusted deserialization to remote code execution, `critical`, remediation = replace the82> pickle transport with a data-only format such as JSON or a schema codec, and if pickle is unavoidable,83> load through an `Unpickler` whose `find_class` allowlists only the expected classes.84>85> **Kill.** The service loads task payloads only with `json.loads` and loads YAML config only with86> `yaml.safe_load`; the one model file it reads is fetched from a controlled registry over an integrity87> check and loaded through an allowlisting unpickler. No untrusted blob reaches a reconstructing loader.88> **Killed**, `kill_reason` = "untrusted inputs parsed as data only (json, safe_load); the sole pickle load89> is integrity-verified and allowlist-restricted, so no attacker callable runs on load."9091## Rationalizations to reject9293- *"It is just our internal task format."* → A cache, queue, or file an attacker can write is untrusted on94 read; internal use is not authentication.95- *"We use YAML, which is a config format."* → `yaml.load` without a safe loader instantiates arbitrary96 objects; only `safe_load` parses data. Check which one.97- *"It is only a model file."* → Model and checkpoint files unpickle on load; a poisoned artifact runs code.98- *"The blob is signed."* → Only safe if the signature is verified before the load call. Verify first, then99 load, or the callable runs regardless.100- *"We validate the object after loading."* → `__reduce__` and YAML construction already executed. Post-load101 validation is too late.102103## Executing this in practice104105You need every reconstructing loader, the untrusted inputs that reach each, the exact loader variant106(reconstructing versus data-only), and any unpickler allowlist or pre-load signature check. For each107loader, ask whether attacker bytes arrive, whether the format constructs objects, and whether a108restriction stands between. Reading the loader call shows the intent; a benign out-of-band proof shows the109callable actually runs.110111## Related112113- `hunting-java-deserialization-gadget-chains` - the JVM sibling; both run code on reconstruction, one via114 gadgets, one via `__reduce__`.115- `auditing-ml-model-supply-chain` - model and checkpoint files are a primary untrusted source here; that116 skill governs where the artifact comes from.117- `testing-rag-and-memory-poisoning` - another path by which an untrusted artifact enters an AI pipeline and118 is later loaded.119- `adjudicating-taint-paths` - use it to confirm untrusted bytes reach a reconstructing loader through120 wrappers and framework indirection.121- [FINDING-SCHEMA.md](../../FINDING-SCHEMA.md) - source = the untrusted serialized blob, sink = the122 reconstructing loader, evidence = the reduce or tag running an attacker callable on load.