If your code calls pickle.loads on bytes that came from a user, a cache, a queue, or a file upload, you have a remote code execution bug. Python pickle deserialization is not a data format in the way JSON is. It is a small program format, and loading a pickle runs that program. This post shows exactly how a crafted pickle turns a load into a command, and what to use instead.
How python pickle deserialization runs code
Pickle was built to save and restore Python objects, including ones that cannot be rebuilt by copying fields. To handle those, the format lets an object describe how to reconstruct itself. That description is a callable plus its arguments, and the unpickler calls it. The hook is the __reduce__ method.
When you pickle an object, pickle may call its __reduce__ and store what it returns: a function to call and a tuple of arguments. When you unpickle, the loader reads that pair and calls the function with those arguments. Nothing checks that the function is harmless. An attacker writing the pickle by hand just names a dangerous callable.
A tiny malicious pickle
Here is the whole trick in a few lines. Picture an invented service, Acme Sync, that stores a user preference object as a pickle:
import os, pickle
class Exploit:
def __reduce__(self):
return (os.system, ("id",))
payload = pickle.dumps(Exploit())
# later, on the server:
pickle.loads(payload) # runs: os.system("id")
The __reduce__ method returns os.system and the argument "id". When the server loads the payload, the unpickler calls os.system("id"). Swap "id" for anything and you see the problem. The attacker did not need a bug in your logic. The load itself is the sink.
Unpickling untrusted data is not parsing. It is handing the sender a function call inside your process.
The real payloads do not stop at one command. They build a chain of objects whose reconstruction steps line up to reach a useful callable, sometimes importing a module first, then calling into it. The technique of stringing together pieces that are each harmless alone is the same idea behind any gadget chain. For the general mechanism, read what is a deserialization gadget chain, and the __reduce__ hook itself is one of the special methods covered in magic methods in deserialization attacks.
Why this surprises people
Pickle looks like a serializer, and serializers feel safe because we think of them as reading data. The mistake is treating pickle as if it only carries values. It carries instructions. The docs say this plainly, with a warning at the top of the module that you should never unpickle data from an untrusted source. That warning is easy to miss when pickle is hidden inside something else.
Common places it hides:
- Caches and sessions. Some caching libraries pickle values by default. If the cache backend or a session cookie can be influenced by a user, the load is exposed.
- Task queues. A worker that pickles job arguments will unpickle whatever lands on the queue. If the queue can be written to from outside, each job is a payload.
- Machine learning model files. Many model formats are pickles under the hood. Loading a model someone sent you runs their code. Treat a downloaded model like a downloaded executable.
- Inter process messages. Passing pickles between services over a socket trusts every byte on that socket.
In each case the fix is the same: find out whether the bytes can originate outside your trust boundary. If they can, pickle is the wrong tool. To understand why the whole class of bug keeps reappearing across languages, the primer on what is insecure deserialization is the place to start.
The fix: do not unpickle untrusted input
There is no safe flag that makes pickle.loads accept untrusted bytes. The advice you sometimes see, to subclass the unpickler and block certain globals with find_class, narrows the attack surface but is hard to get right and easy to bypass, because the set of dangerous callables is large and changes with your dependencies. Treat allow listing as a last resort for data you cannot move off pickle, not as a general fix.
The real fix is to change the format:
- Use JSON for plain data.
json.loadsproduces dicts, lists, strings, and numbers. It cannot construct arbitrary Python objects and it cannot call a function. If your data is records and values, JSON is enough. - Use a schema format such as Protocol Buffers or MessagePack with a defined message type when you need speed or compact size. These map bytes onto fields you declared, not onto callables.
- If you must move Python objects, sign them. Produce the bytes on a trusted side, attach a message authentication code with a secret key, and verify that code before loading. If the signature does not match, you never call the loader. This does not make pickle safe against a trusted insider, but it stops an outsider from injecting a payload.
The JSON version of the earlier service looks like this:
import json
prefs = json.loads(raw_bytes) # only data, no code runs
theme = prefs.get("theme", "light")
Now the worst an attacker can do with the body is send malformed JSON, which raises an error you can catch, or send unexpected values, which you validate like any other input. There is no callable for them to name.
A migration note
If an existing system already stores pickles, do not flip the reader to JSON and hope. Old data will not parse. Version your stored format, write new records as JSON, and keep a guarded reader for old pickle records only while they still exist, ideally behind the signature check above. Plan to expire the old records so the pickle reader can be deleted. Object injection shows up in many ecosystems the same way, so it helps to read across the injection and input category rather than treating this as a Python only quirk.
How to find it in a codebase
- Grep for
pickle.load,pickle.loads,cPickle, andjoblib.load. - For each hit, trace the argument backward. Can those bytes come from a request, a cookie, a queue, an uploaded file, or a third party model? If yes, it is a live finding.
- Check your caching and session configuration for a pickle serializer you did not choose on purpose.
Finding this bug is not about spotting the word pickle. It is about proving that untrusted bytes actually reach the loader, through caches, queues, and helpers that hide the path. That source to sink reasoning over how an app moves data is exactly the kind of work UnboundCompute is built to do. Read more on our about page.
Frequently asked questions
Why does unpickling untrusted data run code?
Pickle is a program format, not a plain data format. An object can define a __reduce__ method that returns a callable and its arguments, and the unpickler calls that callable when it loads the object. Nothing checks that the callable is harmless, so an attacker who writes the pickle can name something like os.system and have it run during the load.
What does the __reduce__ protocol actually do?
When an object is pickled, pickle may call its __reduce__ and store the function plus the tuple of arguments it returns. When you unpickle, the loader reads that pair and calls the function with those arguments to rebuild the object. A crafted class returns a dangerous function instead, so the reconstruction step becomes a command.
Can I make pickle safe with a custom unpickler?
Subclassing the unpickler and blocking globals in find_class narrows the surface but is hard to get right and easy to bypass, because the set of dangerous callables is large and shifts with your dependencies. Treat allow listing as a last resort for data you cannot move off pickle, not as a general fix.
What should I use instead of pickle for untrusted input?
Use JSON for plain data, since json.loads only produces dicts, lists, strings, and numbers and cannot call a function. Use a schema format like Protocol Buffers or MessagePack when you need speed. If you must move Python objects, produce them on a trusted side and verify a message authentication code before loading.
Put an autonomous researcher on your own systems
UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.
