RestrictedPython guard-hook shadowing via positional-only arguments — sandbox-escape operator validation¶
Date reviewed: 2026-08-30 Advisory: GHSA-ffg3-p8fm-mjx2 / CVE-2026-55830 — high, CVSS 8.3 Affected: RestrictedPython <= 8.2 (pip); fixed in 8.3
The primitive¶
RestrictedPython rewrites sensitive operations in sandboxed Python to go through guard hooks supplied by the embedding application:
- Attribute access →
_getattr_(obj, name) - Item access →
_getitem_(obj, key) - Write →
_write_(value) - Print →
_print_(value)
The embedding application is expected to supply these hooks to enforce its access policy. Argument-name validation in RestrictedPython rejects these protected names for regular arguments, *args, **kwargs, and keyword-only arguments — but misses positional-only arguments (those before / in the signature).
Because _getattr_ is now a local variable that shadows the policy hook, the rewritten o.x access calls evil instead of the embedding application's _getattr_. The same shadowing works for _getitem_, _write_, and _print_. Shadowing _print_ can also capture the internal _getattr_ hook that RestrictedPython passes in internally.
Why this is durable¶
The bug class is naming-collision between a sandbox's internal protocol names and a user-facing language feature. Any sandbox that rewrites code to route through named hooks (guard functions, policy callbacks, interceptor methods) must validate that user-supplied code cannot shadow those names as locals. Positional-only parameters are the least obvious vector because:
- They are syntactically distinct from keyword arguments (most name-collision checks target the keyword path).
- They bind to a local scope visible to the rewritten code.
- Python's function definition grammar allows them to precede
/without any annotation that a linter or sandbox author would flag.
Reusable across any Python sandbox, policy engine, or code-rewrite framework that uses named hooks or policy callbacks:
- Enumerate the hook names the sandbox rewrites to (
_getattr_,_getitem_,_write_,_print_, or equivalent). - Test every argument-binding path (regular,
*args,**kwargs, keyword-only, positional-only) for shadowing. - Check whether the sandbox uses the hook name as a local or global that user code can override.
Replayable validation (lab only)¶
Preconditions: a local Python environment with RestrictedPython <= 8.2 installed, a disposable policy hook that logs or denies access, and a synthetic sandboxed code snippet. No production embedding application, no real user data, no network access from the sandboxed code.
- Baseline. Run a simple
o.xaccess through the sandbox with the embedding application's normal_getattr_hook. Confirm the hook is called. - Shadow test. Define a function with
_getattr_=evil_marker, /whereevil_markerwrites to a marker file or logs a canary string. Runo.xinside that function. The positive is thatevil_markeris called instead of the real_getattr_. - Capture the internal hook. Shadow
_print_and inspect whether the internal_getattr_hook object is accessible in the shadowed scope. This is the highest-value capture because it exposes the embedding application's policy internals. - Chain check. If the embedding application serializes sandbox-controlled objects (e.g. via
pickle), test whether the shadowed hook can be used to control the serialization path. Stop at the hook-shadowing proof; do not execute untrusted pickle payloads. - Negative control. Re-run the same test on
RestrictedPython 8.3+and confirm the positional-only shadowing is rejected.
Stop at "guard hook was shadowed and the policy callback was bypassed." Do not attempt to read real files, access network resources, or execute shell commands from the sandbox.
Reporting heuristic¶
- Lead with the hook-name → positional-only local transition. Name the exact hook, the exact argument position, and the embedding application's policy that was bypassed.
- Include the
RestrictedPythonversion, the Python version, the exact function signature that triggers the shadow, and the observed policy bypass. - Distinguish the shadowing primitive from any downstream exploitation (pickle RCE, file read, etc.). The shadowing is the finding; downstream impact is context.
- Do not publish a weaponized payload. The proof is the shadow + the bypassed policy callback.
Safe boundaries¶
- Local Python environment with
RestrictedPython <= 8.2only. No production embedding application. - Synthetic sandboxed code and synthetic policy hooks only. No real user data, no network access, no file access outside the test directory.
- Stop at the hook-shadowing proof. Do not chain into pickle deserialization, file I/O, or shell execution.
Follow-up: second escape axis — string.Formatter internal traversal (2026-09-17)¶
GHSA-hp3v-5vw7-fx9w (published 2026-09-17T16:30Z) is a distinct escape mechanism in the same product: when the embedding policy exposes the stdlib string module or a Formatter instance, string.Formatter.get_field performs attribute/item traversal internally, returning live object references without ever routing through safer_getattr. Shadowing the guard hook is unnecessary — the traversal never enters the guard. Full write-up, harness, and the Zope AccessControl str.format/format_map + str-subclass sibling: Sept 17 policy-sandbox traversal page. Combined audit rule: test both name-shadowing of the guard and stdlib functions that traverse without calling the guard.
Follow-up: third escape axis — the embedding app's own builtins dict (2026-10-15)¶
GHSA-xm98-3vcf-fph7 / CVE-2026-53710 (critical, CVSS 10.0; mcp-contextforge-gateway python_sandbox_server sub-project <= 1.0.1, fixed 1.0.2; IBM ContextForge) is not a RestrictedPython bug — it is the embedding application defeating the guard, which is exactly the failure mode the first two axes predict. Three compounding weaknesses:
- Raw
getattris insafe_builtins. The compiler rewriteso.xthrough the policy hook_getattr_, but a sandbox that exposes stdlibgetattras a "safe" builtin hands restricted code a direct, unmediated attribute-access primitive. Policy coverage is then a property of the exposed builtins dict, not of the rewrite pipeline. validate_codeis a literal-text dunder deny-list. It greps the submitted source for dangerous dunder strings — the published PoC constructs those names at runtime (concatenation/chr()), so the validator sees clean text while the interpreter resolves the real names. Static filtering matches the source string; the sandbox executes values.- The
execute_codeMCP tool is exposed over HTTP/SSE with no authentication. Anyone who can reach the endpoint cantools/callit. The advisory's Critical score explicitly assumes HTTP reachability; stdio-only deployments reduce the vector.
The PoC walks Python's class hierarchy through the exposed getattr (type → __mro__ → subclasses → …) until it finds subprocess.Popen and runs a marker command — full OS command execution as the server process. Commit f855e54d5b7bc1c91b977574a03b91eff6b86bb6, component python_sandbox_server/src/python_sandbox_server/server_fastmcp.py.
Operator playbook — three-layer sandbox triage¶
For any "run Python for me" endpoint (MCP sandbox tools, code-interpreter APIs, notebook-exec services), test the layers in order and stop at the first failure:
| Layer | Probe | Positive signal |
|---|---|---|
| Transport auth | tools/call over HTTP/SSE with no credentials (stdio parity check) | 200 + executed output for an inert marker |
| Exposed builtins surface | Probe which builtins respond: getattr, vars, dir, breakpoint, __build__;__import__ shapes | raw getattr resolves a private/dunder attribute that the policy hook would deny |
| Static validator | Same payload spelled two ways: literal dunder vs runtime-constructed name ('__'+'cla'+'ss__', chr() chains) | literal rejected + constructed accepted = deny-list-on-text bypass |
- Fingerprint rule: grep the product's sandbox config for
safe_builtins/ policy-dict literals — every name in that list is attack surface even if the compiler policy is correct. - Mediation-parity invariant: for each operation class (attribute read, item read, write, call), there must exist no reachable builtin that performs it without invoking the hook.
getattrpresent ⇒_getattr_is decorative. - Transport-parity rule (standing): enumerate every MCP/gateway tool's exposure mode; an auth-less HTTP/SSE transport converts a local sandbox quirk into a network RCE with zero credentials.
- Keep proofs to marker commands and in-memory canaries on your own lab instance; never execute third-party payloads or pivot from a gateway host you do not own.
Combined audit rule across all three axes: test guard-hook shadowing (Aug 30), stdlib traversal that never enters the guard (Sept 17), and now embedding-policy builtins that re-expose the raw primitive (Oct 15). Fixing RestrictedPython fixes none of the embedding-side variants.