Python Deserialization: pickle, PyYAML and the __reduce__ Trap
pickle.loads on untrusted bytes runs code because __reduce__ returns a callable that executes at load time, and yaml.load without SafeLoader builds arbitrary objects. Here is the minimal exploit, and why safe_load and JSON are the fix.
!!python/object/apply:os.systemOn this page
Python's pickle turns an object into a byte stream, and pickle.loads() rebuilds it on the other side. The trap is that loads does not just allocate fields — it follows the pickle's own instructions for how to reconstruct each object, and an object can declare that reconstruction means "call this function with these arguments." The moment you call pickle.loads on bytes an attacker controls, you have handed them a function call of their choosing inside your process, and that single call is enough for remote code execution.
This is one of the most reliable RCE primitives in the Python ecosystem, and it extends to yaml.load when it runs without a safe loader. This post shows how untrusted bytes reach os.system during a load, why __reduce__ is the lever, and how safe_load and data-only formats close the hole.
How it happens
The root cause is treating a pickled blob from a cookie, a cache entry or a message queue as if it were safe data. A common shape stores object state on the client and rehydrates it on the next request:
import pickle, base64 @app.route("/load") def load(): blob = base64.b64decode(request.cookies.get("state")) state = pickle.loads(blob) return f"Welcome back, {state.username}"
The developer assumes the cookie still holds the object the server wrote. But it is sent by the client, so an attacker can replace it with any pickle they like — and pickle.loads will execute whatever reconstruction logic that pickle describes. The pickle format is a small stack language, not a passive data dump, and one of its opcodes is "call a callable."
A concrete attack
An attacker does not need a vulnerable class of yours. Any object can define __reduce__, a hook that tells pickle how to rebuild it by returning a (callable, args) pair — and pickle executes that callable at load time. Point it at os.system and the "reconstruction" is a shell command:
import pickle, base64, os class Exploit: def __reduce__(self): return (os.system, ("id > /tmp/pwned",)) payload = base64.b64encode(pickle.dumps(Exploit())).decode()
The attacker drops that string into the state cookie. When the server calls pickle.loads, it does not just create an object — it runs os.system("id > /tmp/pwned") on the host, before your code ever touches state.username. The attacker has gone from "I can set a cookie" to "I run commands on your server."
YAML has the same shape through a different door. yaml.load without an explicit SafeLoader honours type tags that instantiate arbitrary objects, so an attacker submits a document that calls a function directly:
import yaml doc = "!!python/object/apply:os.system ['id > /tmp/pwned']" yaml.load(doc, Loader=yaml.Loader)
The !!python/object/apply tag tells PyYAML to call os.system with the given argument list — no __reduce__ of your own required, just the loader doing what the tag asks.
The bytes were never just data. For pickle and full-fat YAML, loading them is execution — the attacker is not exploiting a parser bug, they are using the deserializer exactly as designed, on input it should never have trusted.
Why it matters
Because the callable runs inside the application process during the load itself, the impact is severe:
- Remote code execution — arbitrary commands on the server, the usual end state of a malicious pickle.
- Authentication bypass and privilege escalation — forging an object that represents a logged-in or admin user.
- Denial of service — crafted payloads that exhaust memory or CPU on load.
- Full server compromise — RCE leads directly to data theft, persistence and pivoting.
The fix: do not unpickle untrusted data
The only fully reliable rule is to never hand attacker-controlled bytes to pickle.loads. Prefer a data-only format — JSON parsed into an explicit shape — so rebuilding a value can never call a function or instantiate an arbitrary type:
import json @app.route("/load") def load(): data = json.loads(request.cookies.get("state") or "{}") username = str(data.get("username", "")) if not username.isalnum(): abort(400) return f"Welcome back, {username}"
json.loads produces plain strings, numbers and lists — never an object whose construction runs code. For YAML, call yaml.safe_load, which uses the SafeLoader and refuses the !!python/object tags entirely:
import yaml config = yaml.safe_load(untrusted_text)
Where pickle is genuinely unavoidable — an internal cache you fully control, never a trust boundary — sign the bytes with an HMAC and verify the signature before you load, so a tampered or attacker-supplied pickle is rejected before loads ever sees it. Keep client-held state minimal, and treat every serialized blob crossing a trust boundary as hostile.
How SelfSec finds it
SelfSec's crawler locates the parameters, cookies, JSON bodies and form fields that feed a deserializer, then submits known serialized markers — YAML python-object tags (!!python/object/apply), pickle opcode streams, Java's serialized base64 preamble, PHP object and array structures and fastjson type directives — and watches for deserialization-specific errors such as ClassNotFoundException, InvalidClassException and autoType that reveal an unguarded sink. Matched evidence is rescored to separate a genuinely exploitable deserializer from incidental error output, so findings are confirmed rather than guessed. The entire scan runs locally on 127.0.0.1, your scan data never leaves your machine, and every finding ships with a reproduction request you can replay.
SelfSec is intended strictly for authorized security testing of systems you own or are explicitly permitted to assess.
Do both things about Insecure Deserialization
SelfSec covers this class from both sides — the scanner confirms it in your own app, the firewall blocks it in front of your origin while the fix ships.