transport unpickle guard - pprados/pysandboxes GitHub Wiki

The transport unpickle guard

PySandboxes runs untrusted code in a child process and talks to it from the trusted parent over a local Server-Sent-Events transport. Objects cross that transport as pickle, base85-encoded (to_b85 / from_b85 in pysandboxes/remote/tools.py). This page describes the guard that protects the parent when it deserializes what the child sent back.

Summary

The child controls its serialized return payload, so the parent checks it before reconstructing objects. By default (remote-result-mode=data-only) the result channel accepts values only, from a closed list of types; the exception channel rejects unsupported objects and can fall back to an exception descriptor. remote-result-mode=objects supports custom returned objects behind a fail-open denylist. These checks protect the parent-side deserialization path; they do not make the Python layer a boundary against native code in the child.

Contents

Why the return channel is dangerous

The whole point of the sandbox is that the code running in the child is not trusted. Every guard (guard_api, guard_import, guard_socket, ...) runs inside the child to contain it. The return channel does the opposite: it takes bytes the child produced and reconstructs them in the parent, where no guard runs.

pickle.loads is not a data decoder. A pickle stream is a small program, and deserializing it can call arbitrary callables. So a child that cannot call os.system inside the sandbox (the API guard denies it there) can still return an object whose reconstruction calls os.system in the parent:

class Evil:
    def __reduce__(self):
        import os
        return (os.system, ("...",))

to_b85 runs in the child, so the child chooses what __reduce__ emits. The parent's from_b85 would then execute it. This is a sandbox escape through the one channel that legitimately crosses the boundary.

It does not even need a hand-written hostile module. Benign child code that runs eval (or any deserializer) on untrusted input is enough: the untrusted input forges the object, the benign code returns it, and the parent executes it.

This guard therefore assumes nothing about the shape of what the child sends. The only reliable defense is what the parent accepts when it reads — the round-trip check in to_b85 is not a defense, because it runs on the child's side where the attacker already controls both ends.

The two channels

Two sites read child-produced payloads, both in base_sse_daemon.py:

  • the result channel — the return value of the sandboxed function;
  • the exception channel — the (exception, traceback) pair raised inside the child.

Both go through from_b85_restricted. The reverse direction (the parent sending arguments and keyword arguments to the child) stays on the raw from_b85: there the trust runs the other way and it is out of scope.

The layers

from_b85_restricted applies several independent checks. They do disjoint work; none is sufficient on its own.

1. Opcode prescan

Before anything executes, the stream is walked with pickletools.genops, which parses without running it (no __reduce__ is called). Two checks:

  • an allowlist of opcodes, enumerated by family (all integer variants, all string variants, and so on) rather than by observation. Anything outside it is refused. This removes the opcodes that bypass the class check below: the extension opcodes (EXT1/EXT2/EXT4, which resolve through the copyreg extension cache and can skip find_class when that cache is warm), the protocol-0 GLOBAL/INST/OBJ forms, and persistent-id opcodes;
  • anti-DoS budgets: a cap on the number of opcodes, the number of memo entries, the byte size, and the MARK nesting depth. A memo bomb passes the class check but is caught here.

The byte budget, and the ceiling that really applies

The byte budget is not a free choice. The payload travels as a single SSE line, and aiohttp refuses a line longer than the high water mark of its StreamReader, 2 * ClientSession(read_bufsize=...): readline() passes no max_line_length, so readuntil falls back to _high_water = 2 * limit. With the transport's 262144-byte buffer that is 512 KiB; a longer line raises aiohttp.http_exceptions.LineTooLong.

That reader is the ceiling, and it applies before this prescan: the refusal happens while the response is being read, so an oversized result never reaches the guard at all. The child therefore checks the assembled line itself, before sending it (check_sse_line), and the caller gets a refusal naming the size and the limit instead of a LineTooLong naming nothing. The whole line is measured, since the reply also carries the exception forms and whatever the sandboxed function printed, all sharing one budget.

The budget is derived from that ceiling rather than chosen:

line    <= 2 * read_bufsize                = 524288
line     = b85(pickle) + envelope + stdout/stderr
b85(n)   = ceil(n / 4) * 5                 = 1.25 * n
n       <= (524288 - envelope) / 1.25      = 419392 with no captured output

_MAX_BYTES is 384 KiB, which encodes to 491520 bytes and leaves 32 KiB of the line for the JSON envelope and for whatever the sandboxed function printed.

Every ClientSession of the transport passes SSE_READ_BUFSIZE (262144) explicitly, so aiohttp's own default does not move the ceiling. A unit test measures that relation on a real aiohttp StreamReader built with the same buffer: the largest reply check_sse_line accepts must be read, and the first one aiohttp refuses must already be refused by the child. Changing the buffer, or an aiohttp release deriving the line limit differently, reddens CI instead of leaving the two sides silently inconsistent.

The prescan deliberately does not try to inventory the (module, name) pairs the stream references. Under memo/BINGET indirection a stack-simulating scan diverges from what the C unpickler actually resolves (it would see a memo reference where the unpickler resolves os.system). Reproducing the match would mean reimplementing the pickle stack — a second interpreter, whose divergence is itself a bypass class. Names are left to find_class, during the load.

2. find_class, common rules

  • No import is triggered by the stream. A module that is not already loaded in the parent is refused rather than imported, so the stream cannot run a module's import-time code. Note this does not, by itself, block os/posix: they are almost always already loaded. What blocks them is the per-channel predicate.
  • The dotted qualified name (protocol 4+ carries Outer.Inner) is resolved in-house by successive getattr, not through pickle._getattribute, which is private and whose signature and return value have changed across CPython versions. A dotted name must end on a class: a walk that reaches anything else is refused, on every channel. Otherwise a module re-exported by an allowed one (pysandboxes.remote.tools + os.system) would carry os.system under an allowed module name, and a method of an allowed class (pathlib.Path.unlink) would run through REDUCE on an instance of the stream's choosing.

3. The result channel: a fail-open denylist

The result is the return value of the sandboxed function, so it can legitimately be almost anything: base types, application classes, third-party library types (numpy, pandas), common standard-library types (pathlib.Path, uuid.UUID, collections.OrderedDict). Requiring an allowlist here would break real return values, so the result channel refuses only a denylist of dangerous gadgets and lets everything else through.

The denylist is keyed by capability and, critically, by every alias and C-twin that can appear as __module__ in a stream. os.system does not arrive as os.system — it arrives as posix.system on Linux, nt.system on Windows; socket.socket can arrive as _socket.socket. A denylist written against import names would miss the very gadget it is meant to stop. It covers OS execution (posix/nt/os, subprocess, _posixsubprocess, pty, multiprocessing, concurrent, asyncio), imports (sys, importlib, runpy), networking (socket/_socket), low-level access (ctypes, _thread, mmap), files (shutil, io/_io, codecs, tempfile, logging), signals (signal/_signal), nested streams and code objects (pickle/_pickle, marshal, types), callback gadgets (operator, functools), and the dangerous builtins (eval, exec, compile, __import__, __build_class__, getattr, type, open, exit, quit, ...). A callable is judged twice: by the module the stream names, and by its own __module__, so from shutil import rmtree in an allowed module does not carry shutil.rmtree past the list. A module object itself is refused.

Values of these modules are refused too, so a function that returns an io.BytesIO or a logging.LogRecord must return its content instead.

This layer is fail-open by nature: a module not on the list passes. That is a deliberate trade-off. Making it fail-closed would break legitimate results, so the design accepts that a determined attacker reaching for an unlisted gadget can get through, in exchange for never rejecting an honest return value. The threat it actually stops is the common one: eval/deserialization producing an obvious gadget. It is defense in depth, not a closure.

4. The exception channel: fail-closed

The exception payload is always an Exception instance plus a tblib.Traceback. The predicate accepts an Exception subclass (keying on Exception, not BaseException, which keeps SystemExit/KeyboardInterrupt out of the gadget set), tblib's own types, or a base data class. Everything else is refused.

tblib.Traceback is admitted by class, so find_class alone does not vet its contents. Its as_traceback() rebuilds frame objects from child-controlled attributes, so the object's shape is checked before that call.

5. The descriptor fallback: keeping the refusal

Unpickling an exception drags its whole state along, and an exception's state routinely holds objects this predicate cannot admit. httpx.ConnectError carries the httpx.Request it failed on; a FileNotFoundError raised by application code may carry a pathlib.Path; an application exception may carry an application object. Admitting those would mean admitting essentially any type, which is the fail-open posture this channel exists to avoid.

Refusing them outright is worse, though: the caller then sees RestrictedUnpicklingError where it should see which rule refused the call, and a sandbox that cannot report its own refusal has lost its point.

So the child sends the exception twice:

  • the rich form, the pickled (exception, traceback) pair;
  • the descriptor form, (module, qualname, message, denials) — strings and a list of strings — plus the same traceback.

The parent tries the rich form under the predicate above. If the guard refuses it, the parent falls back to the descriptor. It resolves the class from sys.modules exactly as find_class does (never importing), and instantiates it through __new__. In effect, an exception's __init__ signature is its own business, and replaying it with a single argument fails on any class taking more.

The descriptor payload carries primitives only, so its predicate admits tblib and nothing else.

What a caller keeps and loses on the fallback path:

try:
    fetch_webpage(url)
except httpx.ConnectError as e:  # the class is preserved
    str(e)                       # the message is preserved
    sandbox_denials(e)           # the denials are preserved
    e.__traceback__              # the sandbox frames are preserved
    e.request.url                # AttributeError: the state is NOT preserved

The attributes survive whenever the rich form is admissible, which covers an exception carrying no state, or state made only of the base data classes. They are lost exactly when the guard would otherwise have refused the exception entirely.

Two failures are not fatal. An exception the child cannot pickle at all does not sink the reply. A class the parent never imported does not raise: the refusal is reported through SandBoxProtocolError, prefixed with the original module.qualname, rather than being lost.

The remote-result-guard profile key

The result-channel denylist is the one risky layer (fail-open, and it can in principle reject an exotic legitimate return). It is controlled by a profile key:

remote-result-guard=true    # default: the result denylist is active
remote-result-guard=false   # disable the result denylist only

Turning it off disables only that denylist. The opcode prescan, the DoS budgets, the exception-channel guard, and the tblib shape check stay active. With it off, the result channel has no protection against the __reduce__ vector (the opcode allowlist does not see it), so leave it on unless a real regression forces otherwise.

The result channel has two structural profiles:

remote-result-mode=data-only   # default; values only, a closed list of types
remote-result-mode=objects     # supports application-defined classes

data-only resolves every global against a closed list of value classes (pathlib paths, datetime dates, times, durations and timezone, zoneinfo.ZoneInfo, time.struct_time, decimal.Decimal, fractions.Fraction, complex, uuid.UUID and its SafeUUID state, ipaddress addresses, networks and interfaces, range), then checks the resulting graph contains only None, booleans, numbers, strings, bytes, bytearrays, list, tuple, dict, set, frozenset, OrderedDict, Counter or deque containers, and those value classes. ZoneInfo pickles as getattr(ZoneInfo, "_unpickle"): in this mode getattr resolves to a stand-in that answers that single pair, so it cannot hand out a method such as Path.unlink. Cycles and subclasses are refused. The constructors and __setstate__ of these classes only store what they receive, so the stream can build a wrong value but cannot act in the parent: the mode is fail-closed. A refusal names remote-result-mode=objects. The mode remains in force even if remote-result-guard=false; that switch controls only the denylist in objects mode.

Learning mode decodes each emitted result as data-only would, in the child. It writes remote-result-mode=data-only when every observed result passes. If any observed result needs class reconstruction, it writes objects with a warning in the generated profile because class-defined reconstruction methods can execute in the parent process. Repeated observations collapse to one rule. If a later learning run observes objects while the active mode is data-only, it comments the previous rule with the enrichment date and adds the new mode in the dated learning block. An active objects mode is not tightened based only on one run that happened to return primitives. Review the generated profile; learning only describes the results exercised by that run.

Portability

Sites that depend on CPython internals carry a # CPYTHON-COMPAT: marker (rg CPYTHON-COMPAT to find them). They are:

  • the opcode alphabet, which can grow between versions. A corpus test reasserts that a representative sample stays within the allowlist, so a version bump reddens CI instead of breaking production;
  • the in-house qualified-name resolution, replacing the private pickle._getattribute;
  • the refusal to import a module from the stream;
  • the result denylist, keyed by every alias and C-twin of a module;
  • the pinned wire protocol constant, used on both ends instead of HIGHEST_PROTOCOL. The alphabet therefore does not drift with the interpreter, and the two ends stay compatible across container backends.

Supported range: CPython 3.11 to 3.14.

Limits

  • The result denylist is fail-open and not exhaustive; an unlisted dangerous module passes. See layer 3.
  • An exception whose __reduce__ rebuilds through a function (rather than a class) is refused on the exception channel; the descriptor fallback still delivers its class, message and denials, without its state.
  • An exception that crosses on the fallback path arrives without its attributes. See layer 5.
  • __cause__ and __context__ do not survive the transport, on either path. That is plain pickle behaviour; sandbox_denials exists because of it.
  • This guard protects the parent's deserialization only. Arbitrary Python in the child can still reach native code by other routes; the OS-level sandbox remains the real barrier. See weaknesses.

Recommendations

  • Leave remote-result-guard at its default. Turn it off only when a real regression forces it: the result channel then has no protection against the __reduce__ vector.
  • Read the refusals of a crossed exception with sandbox_denials, not with its attributes: on the fallback path, the state is lost.
  • Do not rely on __cause__ or __context__ after the transport.
  • Review the # CPYTHON-COMPAT: sites before supporting a new CPython version.
  • Keep an OS-level sandbox around the child: this guard protects the parent's deserialization only.

References