Why does pickle.loads() run arbitrary code when handed untrusted bytes?
answer
- Not a document, a program
- The stream picks what runs
- Opcodes import a name and call it
- __reduce__ returns callable plus arguments
- No inspect-before-execute stage exists
basics
~20 sA pickle stream is a small program for a stack machine, not a data document. Its opcodes can import any importable name and call it, so pickle.loads on attacker-controlled bytes runs the attacker's code before you inspect any value.
solid answer
~50 sA pickle is a program for a tiny stack virtual machine. Two opcode families matter for security: the `GLOBAL`/`STACK_GLOBAL` pair imports a module and looks up an attribute by name, and `REDUCE` calls the callable now on the stack with an argument tuple. That machinery is not a bug — it is how arbitrary objects are rebuilt, because `__reduce__` returns exactly a callable plus arguments and the unpickler obeys. So a hostile stream can name any importable callable and make `pickle.loads` invoke it before a single value is returned; there is no parse-then-inspect stage to hook, which is why validating the result afterwards is useless. The real options are: do not unpickle anything that crossed a trust boundary — use a data-only format such as JSON — or authenticate the bytes with an HMAC keyed by a secret only your own writer holds.
code
python · 12 linesimport pickle, pickletools
class Payload:
def __reduce__(self):
return (print, ("this ran during loads, before any value came back",))
blob = pickle.dumps(Payload())
pickletools.dis(blob) # shows STACK_GLOBAL then REDUCE
value = pickle.loads(blob) # prints the message; value is only the result
print("loads returned:", value)go deeper
Be ready to state the rule without hesitation: never call pickle.loads on bytes you did not produce yourself, and reach for a data-only format such as JSON when data crosses a boundary.
Explain the mechanism, not just the rule: reduce returns a callable plus arguments, and the unpickler's REDUCE opcode performs that call, which is why there is no point at which you could inspect the payload first.
Show you can audit for it — find every load site, classify each by who wrote the bytes, and pick per site between removing pickle, signing the bytes with an HMAC, or a restricted find_class allow-list, and say why the allow-list is the weakest of the three.
Own the policy: a rule that pickle never crosses a service or tenant boundary, an approved data-only format for interchange, key management for anything signed, and a migration path for the caches and checkpoints already written in pickle.
### A pickle is code, not a document The mental model that makes `pickle` dangerous is the one most people carry: "a pickle is a serialized object, like a JSON document with more types." It is not. A pickle stream is a **program** written for a small stack-based virtual machine that lives inside the `pickle` module. `pickle.loads` is an interpreter for that program. Opcodes push constants, build tuples and dicts, memoize intermediate results — and, crucially, import names and call things. Two opcodes carry the whole security story: * `GLOBAL` / `STACK_GLOBAL` — take a module name and an attribute name, import the module, and push `getattr(module, name)` onto the stack. Any importable name. * `REDUCE` — pop an argument tuple and a callable, call it, push the result. Put together, `<import something> <build args> REDUCE` is "call this function with these arguments", encoded in a few dozen bytes. ### Why the machinery exists This is not an oversight bolted on to a data format; it is the format's core extension point. When `pickle` meets an object it has no built-in encoding for, it asks the object how to rebuild itself by calling `__reduce_ex__`, which by default routes to `__reduce__`. The contract of `__reduce__` is: *return a callable and the arguments to call it with* (optionally plus state and iterators). The unpickler's job is to perform that call. So "unpickling calls a function chosen by the stream" is the feature, and a hostile stream simply chooses a different function. That is why the usual defences do not apply. There is no stage where the payload exists as inert parsed data that you could inspect before anything happens — the side effects *are* the parse. Checking `isinstance(result, MyClass)` after `pickle.loads` returns runs after the damage. Wrapping the call in `try`/`except pickle.UnpicklingError` does not help either: an exception can be raised long after a call has already succeeded. ```python import pickle class Payload: def __reduce__(self): return (print, ("side effect during loads",)) pickle.loads(pickle.dumps(Payload())) # prints before returning None ``` Swap `print` for anything importable that does real work and you have the classic remote-code-execution payload. Note also that the class `Payload` never has to exist on the loading side — the stream names `builtins.print`, not your class. ### What actually mitigates it Ranked honestly, for a Python service: 1. **Do not unpickle data that crossed a trust boundary.** If the bytes came from a client, a queue other teams write to, a downloaded artefact or a shared cache anyone can write, choose a data-only format. JSON, CSV and similar formats describe values only: a decoder for them constructs dicts, lists, strings and numbers and cannot be talked into calling your code. You still validate the decoded values — a data-only format prevents code execution, not bad data. 2. **Authenticate the bytes.** If a pickle must travel (a signed cache entry, a checkpoint file), compute an HMAC over the exact bytes with a key only your writer holds, and verify it with `hmac.compare_digest` *before* calling `pickle.loads`. This proves the bytes came from you; it does nothing if the writer itself is compromised, and it is not encryption. 3. **Restrict the reachable globals.** Subclass `pickle.Unpickler` and override `find_class(module, name)` to raise unless the pair is on a small allow-list. This is a real narrowing — it is what the standard library documentation recommends when you have no alternative — but it is a hardening measure, not a boundary: allowed classes still run their own `__setstate__`, and a purely "safe" allow-list can still be driven into memory exhaustion by a crafted stream. ### Where pickle is fine Inside a trust boundary, pickle is an excellent tool and there is no reason to avoid it: objects handed between worker processes on the same host, a local memo cache written and read by the same code, an interpreter session snapshot. The rule is about *provenance*, not about the module. The moment you cannot name the process that wrote the bytes and prove it was yours, the format is the wrong one.
- If a pickle really must travel between two of your own services, how do you make an HMAC actually help?Compute the HMAC over the exact serialized bytes with a secret only your writers hold, ship it alongside, and verify it with `hmac.compare_digest` before `pickle.loads` — never after, and never with a plain `==` comparison. It authenticates the producer, so it stops third parties injecting streams; it does nothing about a compromised producer, and it is not confidentiality. Rotate the key like any other secret, and version the payload so an old key cannot be replayed forever.
- Does overriding pickle.Unpickler.find_class with an allow-list make untrusted input safe?It narrows the attack surface and is the recommended hardening when you have no choice, but it is not a safety guarantee. Every allowed class still runs its own `__setstate__` or constructor, so a class with side effects re-opens the hole; and even an allow-list of pure containers can be driven into unbounded memory or recursion by a crafted stream. Treat it as defence in depth behind 'do not accept untrusted pickles at all'.
- Someone proposes catching pickle.UnpicklingError around the load to make it safe. What is wrong with that?Exceptions are raised while the stream is being interpreted, not before. A payload can complete its `REDUCE` call and then hit a malformed opcode, so you catch a clean-looking error after the side effect already ran. The exception tells you the stream was invalid, never that nothing happened.
Opening a pickle is closer to running a downloaded script than to reading a downloaded spreadsheet: the file gets to decide which functions execute.
saying these in an interview costs you the question
- Thinks validating the object after loads prevents the attack
- Believes an UnpicklingError means nothing executed
- Says a newer pickle protocol is more secure
- Assumes pickling only stores data, never behaviour
- Claims encryption without authentication solves it
- Thinks the attacker's class must exist locally