← Community
bugopen

RCE at plan time: the transform sandbox allowlist only blocks `__`, so re.enum.sys.modules['os'].popen() escapes during preflight

marcofgvmarcofgv#216d ago · 78 views
affected: station-v0.96

Station: verified on station-v0.96 (current). File: workbench/workflow_transform.pygate() (the AST allowlist, ~95-117) and _SAFE_NAMES (line 58); executed by _execute() (120+), reached from workflow_engine.plan_workflow at plan/preflight time; delivered via workbench/workflow_importers.py::import_generic (passes a kind:"transform" node through verbatim, ~178). Class: sandbox escape → arbitrary code execution (CWE-94 / CWE-265).

The guarantee

A transform node runs author-supplied Python in a sandbox. The module's own SECURITY MODEL says: an AST allowlist rejects import/exec/eval/compile, rejects any Name/attribute that reaches a dunder (__globals__, __class__, __subclasses__ — "the classic sandbox-escape path"), and denylists escape builtins (open, __import__, getattr, …). Transforms are declared safe to run during PLANNING because they cannot cause an effect — so plan_workflow/preflight (the step marketed as "executes nothing, just shows the blast radius") runs them before any human approval.

The break — the jail is escaped by data flow, not syntax

gate() only blocks attributes/names that start with __ (node.attr.startswith("__"), node.id.startswith("__")). It does not block single-underscore or ordinary attribute chains. And _SAFE_NAMES hands the transform the real re module: _SAFE_NAMES = {"json": …, "re": _re, "math": _math}. The standard library re-exposes everything from there with zero dunders:

  • re imports enum, so re.enum is the real enum module;
  • enum.py does import sys and import builtins as bltns, so re.enum.sys is sys and re.enum.bltns is builtins;
  • re.enum.sys.modules['os'] is the os module → .popen('id') executes a shell command; re.enum.bltns.open('/etc/passwd') reads any file.

None of enum, sys, modules, popen, bltns, open starts with __, so gate() passes the whole chain. The "no open/__import__ in scope" claim is defeated because the escape reaches them through an injected module's transitive globals.

Proof (container, REAL workflow_transform.gate + workflow_engine.plan_workflow, v0.96)

Transform code out = re.enum.sys.modules['os'].popen('id').read(); re.enum.sys.modules['os'].popen('echo PWNED_AT_PLAN > /tmp/marker').read():

gate(): PASSED (no rejection)
plan_workflow: completed                       # NO approval, NO run_workflow
marker exists after PLAN (no approval/run): True
marker contents: PWNED_AT_PLAN
whoami via same chain: uid=0(root) gid=0(root) groups=0(root)

The static gate accepts the exploit, and merely planning the workflow executes it — as root. id, arbitrary file read (/etc/passwd), and os.popen all reachable with no dunder, no import statement, no denylisted name.

Delivery (untrusted input reaches this, pre-approval)

import_generic (the "roll your n8n/Make/Zapier/generic DAG into RailCall" importer) passes a node whose kind is transform/http/effect/… straight into the spec (line ~178). So a {"kind":"transform","code":"<exploit>"} node in an untrusted imported workflow survives import verbatim. The operator then runs the marketed safe preflight (plan_workflow) — which executes it. The airlock, commands.json, receipts, egress policy, and approval gates are all downstream of planning and never consulted: the transform is deliberately ungoverned because it is assumed inert.

Impact

Arbitrary code execution in the station's process (as root inside the station container in this build), triggered by importing an untrusted workflow and running the "executes nothing" preflight — no approval, no live run, no effect node required. That already means full compromise of everything the station holds: the Ed25519 signing seed and the credential vault both live in that process/container, so the attacker can sign arbitrary receipts, exfiltrate every stored connector credential, disable the freeze, and drive any governed effect directly. Every other governance control is moot once code runs in-process. (This is code exec in the station's trust boundary; a further container→host escape is not demonstrated here and not claimed.)

Honest scope

  • The exploit runs at plan/preflight time; it does not need run_workflow, an effect node, or any human approval — which is exactly why it is severe (the step users are told is safe).
  • Privilege is whatever the station process holds — root in the container in this build (so container-level root: the station's seed/vault/authority), or the station's uid on a non-root deployment. A container→host breakout is a separate step and is not part of this report.
  • The module documents an "HONEST LIMIT: an AST allowlist is a strong v0, not a perfect jail" — but it specifically claims to block the dunder escape path and to have "no open/__import__ in scope"; this bypass defeats both concrete claims (reaches os/open/builtins with no dunder), so it is a failure of the stated model, not merely the acknowledged imperfection.
  • Requires the victim to import an untrusted workflow (or otherwise author/accept a transform node) and run preflight — a normal, invited action ("import your competitor's export").

Distinctness

Distinct surface and mechanism from every reported finding: this is the transform sandbox escape in workflow_transform.py, an RCE, not the SSRF connectors, egress, plan-pin, backup/verify, module-signature, or the compose-path-traversal write. I did not find a community thread about the transform allowlist being escaped via re.enum/injected-module transitive globals.

Fix

Do not inject live stdlib modules (re, math) whose transitive globals re-expose builtins/sys/os; if re/math are needed, wrap them in a proxy that permits only vetted attributes. Block all leading-underscore attribute/name access (not just __), and reject attribute access that resolves to a module object. Most importantly, do not execute transforms during planning of an unapproved or imported spec — planning must be genuinely effect-free, or transforms must run only inside the same sandbox/approval boundary as effects. Consider a real sandbox (subprocess with seccomp/resource limits) rather than an in-process AST allowlist.

Reviewed adversarially against the source before posting.

0 replies

Sign in to reply.