Python's pickle module turns arbitrary objects into byte streams and back — and that "back" step is where things go wrong. Unlike JSON or XML, an unpickled byte stream can instruct the Python interpreter to call any function it wants, including os.system or subprocess.Popen, the instant pickle.load() runs. The Python docs themselves warn: "It is possible to construct malicious pickle data which will execute arbitrary code during unpickling." That warning has existed since at least the early 2010s, yet pickle remains the default serialization format for Celery task queues, Django caches, and — critically — machine learning model files saved with torch.save(), joblib.dump(), and scikit-learn's pickle.dump(). In April 2025, CVE-2025-32434 showed that even PyTorch's weights_only=True safeguard could be bypassed, meaning millions of "trusted" model checkpoints downloaded from public hubs carry the same RCE risk as any other untrusted pickle blob. This post breaks down how the exploit works, where it's been found in the wild, and how to eliminate it.
What makes Python's pickle module dangerous?
Pickle is dangerous because deserializing a pickle stream can execute arbitrary code, not just reconstruct data. The pickle protocol includes an opcode called REDUCE, which tells the unpickler to call a callable (a class, function, or method) with a tuple of arguments and use the result to rebuild an object. Any object that defines a __reduce__ method — and every Python object can — gets to specify exactly which callable and arguments the unpickler invokes. There is no sandboxing and no allowlist by default: if the pickle stream says "call os.system with the argument rm -rf /," the unpickler calls it. This is why the Python Software Foundation's own documentation states plainly: "Never unpickle data received from an untrusted or unauthenticated source." Compare that to JSON, where deserialization can only ever produce dicts, lists, strings, numbers, and booleans — there is no equivalent execution primitive, which is exactly why pickle deserialization has its own CWE category, CWE-502 (Deserialization of Untrusted Data).
How does a pickle deserialization exploit actually work?
A pickle exploit works by crafting a payload whose __reduce__ output points at a dangerous callable, which the victim's process then executes automatically on load. A minimal proof of concept looks like this:
import pickle, os
class Exploit:
def __reduce__(self):
return (os.system, ("id > /tmp/pwned",))
payload = pickle.dumps(Exploit())
# later, on the victim machine:
pickle.loads(payload) # runs `id > /tmp/pwned` immediately
No application logic beyond calling pickle.loads() is required — the code execution happens inside the deserialization call itself, before the calling function even gets a return value. This pattern has been documented publicly since at least 2011 (Nelson Elhage's widely-cited writeup on exploiting pickle misuse), and it still works unmodified against any code path that calls pickle.load(), pickle.loads(), or the equivalent cPickle, torch.load(), joblib.load(), or pandas.read_pickle() on attacker-influenced input.
Which real-world incidents show pickle deserialization being exploited?
Real-world pickle deserialization incidents span web frameworks, task queues, and ML tooling, not just theoretical CTF challenges. Celery's Redis and RPC result backends were assigned CVE-2021-23727 after researchers showed that anyone who could write to the result backend — often reachable without authentication in default deployments — could push a malicious pickle payload that Celery workers would deserialize and execute, leading to full remote code execution; it was fixed in Celery 5.2.2 (December 2021). NumPy shipped allow_pickle=True as the default for numpy.load() for years, letting a malicious .npy/.npz file trigger code execution; NumPy 1.16.3 (April 2019) flipped the default to False specifically to close this off, tracked informally alongside CVE-2019-6446. In February 2024, JFrog's security research team scanned the Hugging Face Hub and found roughly 100 pickle-format model files embedding malicious __reduce__ payloads that opened reverse shells to attacker infrastructure — models a developer would load with a single torch.load() call and trust implicitly because they came from a "model hub." Most recently, CVE-2025-32434 (disclosed April 2025) demonstrated that PyTorch's torch.load(..., weights_only=True) flag — the officially recommended mitigation — could still be tricked into executing arbitrary code, invalidating years of guidance telling ML engineers that flag alone was "safe."
Why is pickle risk concentrated in AI/ML pipelines right now?
Pickle risk is concentrated in AI/ML pipelines because pickle is the default serialization format for the artifacts those pipelines pass around constantly: model weights, tokenizers, and preprocessing pipelines. torch.save() pickles the entire object graph by default, joblib, the standard serializer for scikit-learn models, is pickle under the hood, and countless Jupyter notebooks and MLOps pipelines call pickle.load() on files pulled from S3 buckets, model registries, or public hubs with no signature verification. Every "download a pretrained checkpoint and fine-tune it" workflow is, in effect, downloading an executable and running it. Hugging Face has responded by running picklescan and Protect AI's model-scanning tooling across uploads and defaulting new uploads toward the safetensors format, which stores tensors as raw arrays with no executable opcodes — but as of 2024, hundreds of thousands of pickle-format (.bin, .pt, .pkl) models still sit in public repositories, and nothing stops a team from pip install-ing a model straight from a random GitHub release instead.
How can security teams detect pickle deserialization vulnerabilities before shipping?
Security teams detect pickle deserialization risk with a combination of static analysis, dependency scanning, and dedicated pickle inspection tools, run before code merges rather than after an incident. Bandit, the standard Python SAST tool, flags pickle.load/pickle.loads calls under rule B301 specifically because the sink is inherently dangerous regardless of surrounding logic. Software composition analysis should flag any dependency version affected by a known deserialization CVE — Celery below 5.2.2, PyTorch below the patched builds addressing CVE-2025-32434, and older NumPy releases defaulting allow_pickle=True — which requires an accurate, continuously updated SBOM rather than a point-in-time scan. For ML artifacts specifically, Trail of Bits' fickling tool statically disassembles pickle opcodes to flag suspicious REDUCE targets without executing the file, and Hugging Face's picklescan does the same at upload time. None of these tools alone tells you whether a flagged sink is actually reachable from an attacker-controlled input in your specific codebase — that gap is where most alert fatigue and wasted triage time comes from.
How do you fix or eliminate pickle deserialization risk?
You fix pickle deserialization risk by removing pickle from any code path that touches data crossing a trust boundary, not by trying to sanitize pickle input. For inter-process or network data, switch to JSON, Protocol Buffers, or MessagePack, none of which can express an executable callable. For ML model weights specifically, migrate to safetensors, which Hugging Face and PyTorch both now support natively and which cannot execute code on load by design. Where pickle absolutely cannot be avoided — internal caching between trusted services, for example — sign every payload with an HMAC using a secret the deserializing process verifies before calling pickle.loads(), and never accept pickle data from a queue, cache, or storage bucket that's reachable without authentication, as the Celery CVE demonstrated. Restricting the unpickler with a custom find_class allowlist (per the pickle module's own documented pattern) is a reasonable defense-in-depth layer, but treat it as a backstop, not the primary control.
How Safeguard Helps
Safeguard closes the gap between "a scanner flagged pickle.load somewhere" and "here's the exploitable path an attacker would actually use." Reachability analysis traces whether a flagged deserialization sink — in your own code or in a dependency like Celery or an ML serving library — is actually reachable from an untrusted input such as an HTTP request body, a message queue, or a downloaded model artifact, cutting through the noise generic SCA tools produce. Griffin AI reviews the surrounding data flow to confirm exploitability and drafts context-aware remediation, and Safeguard's auto-fix PRs propose the concrete change — swapping pickle.load for a safe deserializer, pinning a patched Celery or PyTorch version, or adding HMAC verification — directly against your branch. Continuous SBOM generation and ingest keeps every pickle-using dependency version tracked against newly disclosed CVEs like CVE-2025-32434 the day they're published, so teams aren't relying on a quarterly scan to catch a bypass of last year's mitigation.