skip to content

Why does pickle.loads() run arbitrary code when handed untrusted bytes?

level: juniorimportance: must knowfreq 70%

answer

  1. Not a document, a program
  2. The stream picks what runs
  3. Opcodes import a name and call it
  4. __reduce__ returns callable plus arguments
  5. No inspect-before-execute stage exists

basics

~20 s

A 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 s

A 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 lines
python
import 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context