How does overriding pickle.Unpickler.find_class restrict what a payload can build?
answer
- One hook sees every name the stream resolves
- Subclass the loader, not the convenience function
- Allowlist qualified pairs, never blocklist bad names
- Permitted classes are still gadgets
- A narrowing at a chokepoint, not a sandbox
basics
~10 sfind_class is called for every global a pickle stream resolves, receiving the module and the qualified name. Subclass pickle.Unpickler, allowlist the pairs your format legitimately needs, and raise pickle.UnpicklingError for everything else.
solid answer
~50 sEvery `GLOBAL`/`STACK_GLOBAL` opcode is resolved through `pickle.Unpickler.find_class(module, name)`, whose default implementation imports the module and returns that attribute. Overriding it in a subclass gives one chokepoint over which names a payload may mention: compare the `(module, name)` pair against an allowlist, delegate to `super().find_class(...)` when it matches and raise `pickle.UnpicklingError` otherwise. Since `pickle.loads` takes no unpickler class, you construct your subclass over an `io.BytesIO` and call `load()` — and the C-accelerated unpickler does honour the override. Write it as an allowlist, never a blocklist of dangerous names. Be clear about the limits, though: allowed classes still run their construction path and `__setstate__`, so any permitted type with side effects is a gadget, and deeply nested or oversized streams still exhaust memory and recursion. It is a narrowing for a semi-trusted format, not a sandbox for untrusted input.
code
python · 28 linesimport collections
import io
import pickle
class Payload:
def __reduce__(self):
return (print, ("code ran during load",))
class RestrictedUnpickler(pickle.Unpickler):
ALLOWED = {("collections", "OrderedDict")}
def find_class(self, module, name):
if (module, name) not in self.ALLOWED:
raise pickle.UnpicklingError(f"global {module}.{name} is not allowed")
return super().find_class(module, name)
def restricted_loads(data):
return RestrictedUnpickler(io.BytesIO(data)).load()
print(restricted_loads(pickle.dumps(collections.OrderedDict(scraped=6))))
try:
restricted_loads(pickle.dumps(Payload()))
except pickle.UnpicklingError as exc:
print("refused:", exc)go deeper
You are not expected to write one, but know that the safe default is simply not to unpickle data you did not produce, and that the hook exists for the cases where a legacy format cannot be changed yet.
Be able to write the subclass from memory: override find_class, compare the (module, name) pair to an allowlist, raise pickle.UnpicklingError otherwise, and drive it over io.BytesIO because the convenience function takes no unpickler class.
Show where the control leaks: allowed types with side-effecting constructors or setstate, plus memory and recursion exhaustion that needs no global at all. Pair the allowlist with an input-size cap and authentication of the bytes.
Decide when a narrowing is an acceptable interim control and when the only answer is changing the format. Own the exit criteria — which producers get migrated to a data-only schema, and what evidence closes the risk rather than parking it behind a list.
## The one hook every global passes through While a pickle stream is being loaded, every opcode that names something outside the stream — `GLOBAL` and `STACK_GLOBAL` — is resolved by a single method on the unpickler: `pickle.Unpickler.find_class(module, name)`. The default implementation imports `module` and returns the attribute `name` from it. That method is the only place where a stream can reach code that is not already on its own stack, which makes it a genuine chokepoint: override it, and you decide which names a payload is allowed to mention at all. The shape of the override is fixed by the API. `pickle.loads` is a convenience function with no parameter for a custom unpickler class, so you subclass `pickle.Unpickler`, define `find_class`, and drive it yourself over an `io.BytesIO` wrapping the bytes. Inside the override you compare the `(module, name)` pair against an allowlist and either delegate to `super().find_class(...)` or raise `pickle.UnpicklingError`. This works with the C-accelerated implementation that `pickle.Unpickler` actually is on CPython: the fast loader looks the method up on your subclass and calls it, so you do not silently lose the check by picking the fast path. ## Allowlist, never blocklist The frequent mistake is to write the check as a denial: refuse `os.system`, refuse `builtins.eval`, refuse `subprocess.Popen`, allow the rest. That inverts the burden of proof onto you, and you will lose. The standard library alone offers an enormous number of importable callables with useful side effects — things that write files, spawn processes, import modules, fetch URLs, or hand back another callable that does. A denial list has to enumerate every one of them, in every installed package, forever. An allowlist has to enumerate the handful of types your format legitimately carries. Only one of those is a finite job, and it is also the one that fails closed when a payload mentions something you never considered. Write the allowlist as `(module, name)` pairs, not bare names. `Config` from your own module and `Config` from somewhere else are different objects, and matching on the unqualified name throws away exactly the information the hook exists to give you. ## What a restricted unpickler still does not stop Calling this a sandbox is the error that separates a middle answer from a senior one. **Allowed classes are gadgets.** The stream still gets to call the classes you permitted, with arguments it chooses, and to push state into the constructed objects — which invokes `__setstate__` on any allowed type that defines one. If a permitted class opens a path in its initialiser, deletes something on state restore, or holds a callable that is invoked later, the payload has that behaviour available. The security property is only ever as strong as the *behaviour* of the types you allowed, not the length of the list. **Resource exhaustion is untouched.** A stream can declare a very long sequence, nest structures deeply enough to blow the recursion limit, or repeat memoised objects to multiply memory. None of that needs a single global. Cap the input size and keep the load inside whatever timeout and memory bound the surrounding process has. **The format is still unauthenticated.** A restricted unpickler tells you what the payload may construct; it tells you nothing about who wrote it. If you can also authenticate the bytes — an HMAC over them with a key from a secret store, verified before loading — do that as well, because the two controls fail in different ways. ## When to reach for it, and when not to The honest place for a restricted unpickler is a semi-trusted producer and a format you cannot change today: an existing on-disk cache, a message shape another team owns, a model or checkpoint file whose format you inherited. It narrows a wide-open call into one that only builds a known set of types, and it makes a violation loud instead of silent, which is worth having while you migrate. The place it does not belong is as an answer to "we accept uploads from the internet and unpickle them". There the correct move is a data-only format — JSON, or a schema-checked binary format — where the mapping from parsed primitives to objects is a constructor you wrote and reviewed. When an interviewer asks this question, they are usually checking whether you know the hook exists *and* whether you will oversell it. Say both halves: it is a real narrowing at a real chokepoint, and it is not a sandbox.
- Why is a blocklist of dangerous names like builtins.eval the wrong shape for this check?Because it puts the burden of enumeration on you, over the entire importable surface of the standard library and every installed package. There are far too many callables with useful side effects, and new ones arrive with every dependency upgrade. An allowlist is a finite list of the types your format actually carries, and it fails closed on anything you never considered.
- Does the override still run when the C-accelerated unpickler is used?Yes. On CPython, pickle.Unpickler is the accelerated implementation, and it looks find_class up on the instance's type, so a subclass override is called on the fast path. That is what makes the documented recipe usable in production rather than only with a pure-Python loader.
- With a strict allowlist in place, what can a hostile stream still do?It can call the classes you permitted with arguments it chooses and drive their __setstate__, so any side effect reachable through an allowed type is available to it. It can also exhaust memory or hit the recursion limit with long or deeply nested structures, none of which needs a global at all. Cap input size and keep the load inside the process's own limits.
saying these in an interview costs you the question
- Blocklists eval and os.system and calls the loader hardened
- Thinks pickle.loads accepts a custom unpickler class
- Claims an allowlist makes untrusted pickles safe to load
- Forgets that __setstate__ runs on allowed classes
- Assumes the C unpickler ignores a Python find_class override
- Matches on the bare class name without its module