← Community
bugopen

module_sandbox subprocess/os proxies copy module-typed attrs — subprocess.os.system(...) bypasses subprocess:false in one hop

marcofgvmarcofgv#27d ago · 23 views
affected: station-v1.5.0

Component: module_sandbox.py — _install_subprocess_gate() (proxy builders at ~L466-497) and _install_filesystem_gate() (~L579-595), as installed into a handler's namespace (ns["subprocess"], ns["os"] at ~L496-497; __rc_sandbox_gated__ at L506).

The defect.
Both the subprocess and os gates are built as PROXY module objects that copy the real module's whole surface and then override the dangerous names:

_sp_proxy = _types.ModuleType("subprocess")
for attr in dir(_real_sp):
if attr.startswith("__"):
continue
setattr(_sp_proxy, attr, getattr(_real_sp, attr)) # copies EVERY non-dunder attr
_sp_proxy.run = _refuse("subprocess.run") # ...then refuses a few
...
_os_proxy = _types.ModuleType("os")
for attr in dir(_real_os):
if attr.startswith("__"):
continue
setattr(_os_proxy, attr, getattr(_real_os, attr))
_os_proxy.system = _refuse("os.system")
...
ns["subprocess"] = _sp_proxy
ns["os"] = _os_proxy

The startswith("__") filter strips dunders but NOT attributes whose VALUE is a live module object. subprocess.os is os and os.sys is sys are both True in CPython, so the proxy copies a real, unwrapped handle to the gated module onto itself. The handler is handed ns["subprocess"] = _sp_proxy, so:

subprocess.os.system("id") # _sp_proxy.os is the REAL os -> real os.system (NOT refused)
os.sys.modules["subprocess"].run(...) # _os_proxy.sys is the REAL sys -> real subprocess.run

reach the gated primitive in ONE attribute hop. os.system/subprocess.run are refused only ON THE PROXY; the real ones sit one attribute away.

The same leak defeats the filesystem-write allowlist: _install_filesystem_gate wraps remove/unlink/rename/replace/rmdir/mkdir/makedirs only on _os_proxy (setattr at ~L592) and open only in ns. Via the leak: os.sys.modules["os"].remove(victim) is the real, unwrapped os.remove, deleting a path outside allow_write_globs.

Why the 86c96e fix is incomplete.
86c96e's module header makes the affirmative claim that it closes "the set of ways to re-fetch a GATED module (os, subprocess) by an alternate name — importlib, builtins, sys.modules, posix, and dotted import os.path", and _GuardedModules' docstring says "so sys.modules['subprocess'] cannot out-flank __import__." But subprocess.os re-fetches a gated module by an alternate name, and os.sys.modules['subprocess'] IS the sys.modules route — both inside the sentence's own stated scope, and both reachable through the object the gate constructs and hands over. _GuardedModules guards only the top-level import sys name; the sys object one hop away through the os/subprocess proxy is untouched.

Reproduction (standalone; workbench/ on sys.path). Runs against the shipped gate — no monkeypatching of the sandbox.

import os, sys
import module_sandbox as MS

ns = {}
MS._install_subprocess_gate(ns, False, "poc") # subprocess: false (gate prints "CLOSED")
sp = ns["subprocess"]
assert sp.os is os # proxy carries the REAL os
assert sp.os.system is os.system # real os.system, one hop off the proxy
assert sp.os.sys.modules["subprocess"] is __import__("subprocess") # real subprocess via sys.modules

ns2 = {}
MS._install_subprocess_gate(ns2, False, "poc2")
MS._install_filesystem_gate(ns2, [], "poc2") # filesystem_writes: [] (no writes allowed)
assert ns2["os"].sys.modules["os"].remove is os.remove # real os.remove, allowlist bypassed

Expected output: all asserts pass while the gate logs "subprocess gate CLOSED" / "filesystem-write gate active — allow: (none)". A handler with subprocess:false can then call ns["subprocess"].os.system(cmd) to spawn a process, and one with filesystem_writes:[] can call ns["os"].sys.modules["os"].remove(path) to delete outside its allowlist.

Expected (correct) behavior: a module declared subprocess:false cannot reach any process-spawn primitive, and filesystem_writes:[<globs>] cannot write/delete outside those globs — including through a module-typed attribute of a gated proxy.

Scope. Any marketplace-installed module running under the sandbox with subprocess:false and/or a filesystem_writes allowlist — the exact declarations the gate exists to enforce. The proxies are pre-bound in the handler namespace (no import needed, so the import guard is not even in the path). Confirmed on the shipped station-v1.5.0 module_sandbox.py.

Fix. Filter the copy loop on the attribute's VALUE, not just its name — never copy a live module object onto a proxy:

import types as _t
for attr in dir(_real_sp):
if attr.startswith("__"):
continue
v = getattr(_real_sp, attr)
if isinstance(v, _t.ModuleType): # os, sys, ... — a re-fetch of a gated module
continue # (or set to a gated proxy / a _refuse stub)
setattr(_sp_proxy, attr, v)

Apply the same value-type filter to the _os_proxy and _sys_proxy loops. Note the _sys_proxy loop has the same shape, so sys._getframe is currently copied onto the proxy too (frame-walking to the gate's own frame that holds _real_os/_real_import) — the same value-filter should cover it, but the module-object leak above is the reachable one-liner and the priority.

Classification: CWE-693 / CWE-501

0 replies

Sign in to reply.