Overview
FastGPT's workflow Code node (codeType: "py") runs user-supplied Python in the rewritten projects/code-sandbox worker, which confines code entirely with in-process Python guards: an import allowlist, a restricted open, replacement of the object builtin, and a pre-execution AST static check _validate_user_code that blocks the classic object.subclasses() reflection escape. The AST check is incomplete: it matches only a literal .subclasses attribute node or a getattr(obj, 'subclasses') whose second argument is an ast.Constant. A trivial string-concatenation -- getattr((1).class.base, "subcl" + "asses")() -- is an ast.BinOp, not a constant, so it passes validation, reaches the real object.subclasses(), walks to an already-loaded module exposing os, and calls os.system(...). An authenticated low-privilege team member who can add a Code node therefore obtains OS command execution inside the code-sandbox container, defeating the confinement.
Impact
Any authenticated user who can create or edit a workflow containing a Python Code node can bypass the worker's in-process Python language confinement. The bypass evades _validate_user_code, reaches the real object.subclasses(), obtains an already-loaded os reference, and reaches os.system / os.popen under the same restricted-builtins model used by the worker. The confinement (AST check + restricted builtins + import allowlist + restricted filesystem access) exists precisely to prevent Code-node Python from reaching OS-level effects -- process execution, filesystem access, network access -- from inside the sandbox container; this report defeats that boundary.
Confirmed (worker-level end-to-end, real worker as the container sandbox user). Running the byte-identical real worker.py via the real spawn command and IPC protocol, inside a container that mirrors the runtime Dockerfile and executes as the non-root sandbox user, the payload bypasses _validate_user_code, evades the _SafeObject replacement of the object builtin, reaches the real object.subclasses(), harvests an already-loaded os reference, and executes os.popen("id") -- returning uid=100(sandbox) gid=101(sandbox), the container's sandbox account. An attacker substitutes any command and can read files and environment variables accessible to that user and initiate outbound network connections from the container (SSRF / lateral movement to internal services reachable from it). This is OS command execution inside the sandbox container, not a Docker host escape.
The only layer not exercised is the Hono HTTP front-end at POST /sandbox/python; based on the traced source path (see Technical Details -> HTTP layer) it forwards the {code, variables} JSON to the same worker IPC that was exercised here and does not itself implement the Python confinement (see Reproduction -> Verification).
Potential additional impact: worker reuse
The Python worker is a persistent, pooled process (SANDBOX_POOL_SIZE defaults to 20 preheated workers), and recycleAfterTask is false by default -- it is enabled only if SANDBOX_PYTHON_ALLOWED_MODULES is configured to include subprocess/multiprocessing/threading/concurrent, none of which are in the default allowlist (projects/code-sandbox/src/pool/python-process-pool.ts:20-21, env.ts:73). So by default an escaped worker is not torn down after the attacker's task and is reused for subsequent executions, which may allow in-process persistence across tasks: if the attacker can modify worker-level state or hooks reused by later executions, this could lead to interception or tampering of subsequent Code-node payloads or results handled by that same worker. This cross-task impact has not been independently demonstrated and should be treated as a follow-up hardening concern rather than a confirmed impact; it depends on the deployment's task-routing and per-task state-reset behaviour.
The vulnerable Code node is a standard, user-selectable workflow node (SandboxCodeTypeEnum.py), not an administrator-only feature; calibrate the required privilege to the product's permission model (see the Severity note).
Technical Details
Reachability -- the Code node dispatches to the Python worker
authed user edits an app workflow, adds a Code node (codeType="py", code=<payload>)
-> packages/service/core/workflow/dispatch/tools/codeSandbox.ts:27 dispatchCodeSandbox
-> packages/service/thirdProvider/codeSandbox/index.ts:57-80 runCode: codeType==py -> POST {CODE_SANDBOX_URL}/python
-> projects/code-sandbox/src/index.ts:208 app.post('/sandbox/python')
-> projects/code-sandbox/src/pool/worker.py main_loop (executes the user code)
Running or chat-testing the app (e.g. POST /api/core/chat/chatTest) dispatches the node, so the attacker triggers their own payload.
HTTP layer -- does not implement the confinement
The /sandbox/python HTTP handler validates only the request shape (executeSchema = {code, variables, queueId}); it does not inspect or sanitize the Python code. It hands the parsed body straight to the worker pool, which writes it verbatim to the worker's stdin:
// src/index.ts:208 -- the route does no code analysis, only shape validation
app.post('/sandbox/python', async (c) => {
const raw = await readLimitedJsonBody(c);
const parsed = executeSchema.safeParse(raw); // {code, variables, queueId} shape only
...
const result = await queueIdLimiter.run(parsed.data.queueId, () =>
pythonPool.execute(parsed.data as ExecuteOptions)); // -> pool -> worker
});
// src/pool/base-process-pool.ts:362 -- pool forwards the task to the worker verbatim
worker.proc.stdin!.write(JSON.stringify(task) + '\n');
The entire Python confinement (AST check + restricted builtins + import allowlist + restricted open) lives inside worker.py, downstream of this forwarding. That is the boundary the verification below exercises directly via the same IPC line the pool writes; the HTTP/pool layers above it add no code analysis.
The confinement, and the gap in _validate_user_code
The new code-sandbox replaced the old seccomp-based projects/sandbox/ with pure in-process Python guards (projects/code-sandbox/src/pool/worker.py). Before exec, it builds a restricted builtins dict and runs an AST check:
# worker.py:577-621 (abridged) -- the entire confinement around exec
safe_builtins = { ... } # public builtins only
safe_builtins['__import__'] = safe_import # module allowlist
safe_builtins['open'] = restricted_open # no filesystem
class _SafeObject(object): # H3: hide object.__subclasses__
__subclasses__ = None
safe_builtins['object'] = _SafeObject
exec_globals = {'__builtins__': safe_builtins, ...}
_validate_user_code(code) # static reflection-chain check
exec(code, exec_globals) # worker.py:621
# worker.py:375-397 -- the static check, verbatim
def _validate_user_code(code: str):
try:
tree = _ast.parse(code)
except SyntaxError:
return
for node in _ast.walk(tree):
# direct attribute access: obj.__subclasses__
if isinstance(node, ast.Attribute) and node.attr == '__subclasses__':
raise RuntimeError("Access to __subclasses__ is not allowed in sandbox")
# dynamic access: getattr(obj, '__subclasses__')
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
and node.func.id == 'getattr' and len(node.args) >= 2
and isinstance(node.args[1], _ast.Constant) # <-- only a CONSTANT
and node.args[1].value == '__subclasses__'):
raise RuntimeError("Access to __subclasses__ is not allowed in sandbox")
Two gaps make the check trivially evadable, and the H3 object replacement does not help:
-
String-built attribute name. getattr(x, "subcl" + "asses") -- the second argument is an ast.BinOp, not an ast.Constant, so neither branch matches. No literal .subclasses attribute node exists either.
-
No block on class/base. The check blocks only subclasses; class, base, init, globals are all permitted. (1).class.base resolves to the real object type via the int's type chain -- it does not go through the object name, so _safe_builtins['object'] = _SafeObject (whose subclasses is None) is irrelevant.
The escape is the classic CPython reflection chain, expressed to dodge both:
def main():
base = (1).__class__.__base__ # real object
for c in getattr(base, "__subcl" + "asses__")(): # object.__subclasses__()
g = getattr(getattr(c, "__init__", None), "__globals__", None)
if g and "system" in g and "popen" in g: # a module that imported os
g["system"]("id") # os.system -- OS command execution
return {"ok": True}
import being allow-listed is irrelevant: the payload never imports os; it harvests an already-loaded os reference from a subclass's init.globals (the worker process loads os/socket/subprocess for its own operation). No project-level OS isolation (seccomp, nsjail, Landlock, gVisor, microVM) is configured for the Python worker -- the code-sandbox Dockerfile (node:24-alpine) only creates the sandbox user and runs node, so the in-process AST check + restricted builtins are the entire boundary. (Docker's default seccomp profile, if a deployment applies it, must not be treated as a Python code-execution sandbox: it does not generally block process/file/network syscalls such as those behind os.system/os.popen/open inside the container.)
Root-cause context -- new sink and component, known obfuscation class
The vulnerable sink and the affected component are new; the obfuscation technique is not. CVE-2026-32128 / GHSA-6hw6-mxrm-v6wj (published 2026-03-11), filed against the predecessor seccomp-based projects/sandbox, documented as Root-Cause #4, verbatim: "Static checks are bypassable with basic obfuscation (e.g., getattr + string concatenation)", with PoCs such as getattr(os, 'o'+'pen'). The security issue reported here is that the rewritten projects/code-sandbox Python worker reintroduced the same architectural weakness at a new protection point: _validate_user_code specifically attempts to block object.subclasses() reflection, but is bypassed by the same class of string-built attribute access (getattr(x, "subcl"+"asses")), at a different file, a different sink, and a different control than the prior advisory. In short -- the technique is known, but the rewrite did not carry the architectural lesson of CVE-2026-32128 (that an in-process static blocklist cannot confine Python) into the new component. It is also the Python-worker sibling of the already-fixed JS-worker escape (GHSA-f5mq, an import() regex bypassed by import/**/(...)).
Severity note (calibration)
Scored PR:L (an authenticated app editor), S:U (command execution within the code-sandbox container's authority), C:H/I:H/A:H (full control of that container). The score assumes a non-admin authenticated team member can create or edit a workflow containing a Python Code node; if a deployment restricts Code-node editing to administrators, PR:H is more appropriate. The project rated the impact-equivalent JS-worker escape GHSA-f5mq (command execution as the sandbox user) as Medium; the blast radius here is likewise the isolated code-sandbox container, and the maintainer may calibrate to that precedent. The defense-in-depth value of the container does not change that the confinement the report defeats is a real, intended security boundary. The score treats the Python language confinement as the security boundary exposed to untrusted Code-node authors: the container limits the blast radius, but it does not make OS command execution inside the sandbox an intended outcome. If the project instead treats arbitrary command execution inside the code-sandbox container as an expected or accepted outcome for Code-node authors, the severity may be lower; this report assumes the Python in-process restrictions are intended to prevent such OS-level effects. The key security question is not whether the container limits the blast radius, but whether a Python Code-node author is intended to obtain arbitrary process-execution primitives inside that container despite the explicit import, open, builtin, and AST restrictions placed around the Code node. Note, however, the novelty context above: because the bypass class is already public (CVE-2026-32128), triage may treat this as a known-class hardening item rather than a fresh vulnerability.
Reproduction
Verification
Confirmed worker-level end-to-end against the real worker in a faithful container (the worker is the whole confinement; the HTTP/pool layers above it add no code analysis -- see Technical Details -> HTTP layer). The worker.py used is byte-identical (verified by sha256) to the audited source at HEAD 04c50f7. It was run via the real pool spawn command (python3 -u worker.py, from python-process-pool.ts) and driven over the real stdin/stdout IPC protocol (the {"type":"init",...} line then the {"code":...,"variables":{}} task line that base-process-pool.ts sends), inside an Alpine container that mirrors the runtime Dockerfile: python3, a non-root sandbox system user created with adduser -S, and HOME=/tmp.
Observed:
-
Control -- a Code node using direct .subclasses access is blocked by _validate_user_code ({"success": false, "message": "Access to subclasses is not allowed in sandbox"}), confirming the control is real.
-
Bypass -- the string-concatenated getattr Code node below passes validation, reaches object.subclasses(), finds an already-loaded os, and runs os.popen("id"), returning uid=100(sandbox) gid=101(sandbox) groups=101(sandbox) -- OS command execution as the container's non-root sandbox user (an Alpine system account, so not uid 1000).
The only layer not exercised is the Hono HTTP front-end at POST /sandbox/python; it is a thin JSON pass-through of {code, variables} to the same worker IPC and carries no part of the security boundary.
Expected product-level reproduction path
This is the product-level path (UI / API) that drives the same worker confirmed below; the worker-level execution itself is verified in Verification. In the FastGPT UI (as a team member who can edit an app), add a Code node with Code type = Python, paste the body below, and run the workflow (or chat-test the app). Equivalently, drive it via the API: POST /api/core/app/update to store the node, then POST /api/core/chat/chatTest to dispatch it.
# Code node, codeType = "py" -- escapes the sandbox confinement.
# Note: the worker's calling convention passes the whole variables dict
# positionally to a single-parameter main(); use a zero-parameter main()
# (NOT def main(**kwargs), which the worker would call with one positional arg).
def main():
base = (1).__class__.__base__ # real object, bypassing the H3 name swap
subs = getattr(base, "__subcl" + "asses__")() # BinOp arg -> _validate_user_code misses it
for c in subs:
g = getattr(getattr(c, "__init__", None), "__globals__", None)
if g and "system" in g and "popen" in g: # a stdlib module that imported os
return {"result": g["popen"]("id").read()} # OS command output, exfiltrated via the node return
return {"result": "os not found"}
Expected node output (the Code node returns the command's stdout). The id output reflects the Dockerfile's sandbox user -- an Alpine system account created via adduser -S sandbox, so not uid 1000 (node:24-alpine already assigns uid 1000 to the node user). This is the exact value observed end-to-end against the real worker in a container mirroring the runtime image:
{ "result": "uid=100(sandbox) gid=101(sandbox) groups=101(sandbox)\n" }
Returning the output of id proves OS command execution escaped the Python confinement. An attacker substitutes any command; popen(...).read() exfiltrates output in-band through the node's normal return value.
Supporting evidence (real worker in a container, as the sandbox user)
The real worker.py (byte-identical to the audited source) is driven over its real IPC protocol -- the init line then the task line -- exactly as the worker pool drives it, inside a container that mirrors the runtime Dockerfile and runs as the non-root sandbox user:
# control: direct .__subclasses__ -> blocked by the validator
{"type": "ready"}
{"success": false, "message": "Access to __subclasses__ is not allowed in sandbox"}
# bypass: string-concat getattr -> passes validation, os.popen("id") runs
{"type": "ready"}
{"success": true, "data": {"codeReturn": {"result": "uid=100(sandbox) gid=101(sandbox) groups=101(sandbox)\n"}, "log": ""}}
The uid=100(sandbox) output is the container's non-root sandbox account, confirming OS command execution escaped the Python confinement end-to-end (validator + _SafeObject evaded -> object.subclasses() -> os.popen), not merely on the researcher's host.
Suggested Fix
A denylist of attribute-name shapes cannot confine Python -- string building, attribute chains (class/base/mro/globals), and decode tricks all evade any AST blocklist, and the object name swap is bypassed by (1).class.base.
-
Restore OS-level isolation as the boundary. Run the Python worker under seccomp-bpf, nsjail, gVisor, a Firecracker/microVM, or an equivalent OS-level sandbox (as the deleted projects/sandbox/ did), so that even if the in-process guards are bypassed, os.system/execve is denied below the Python layer. The AST check should be defense-in-depth only.
-
If in-process checks are retained, they must operate on a positive model rather than enumerating specific forbidden strings. At minimum, deny all dunder attribute access and all dynamic getattr/setattr/delattr paths unless the attribute name is statically resolved (an ast.Constant) and on an explicit allowlist -- a getattr with a non-constant name argument (ast.BinOp, ast.Name, ast.Call, etc.) must be rejected outright, not skipped. This in-process check should be treated as defense-in-depth only and never as a complete sandbox.
References
- projects/code-sandbox/src/pool/worker.py:375-397 -- _validate_user_code incomplete AST check
- projects/code-sandbox/src/pool/worker.py:577-621 -- _safe_builtins / _SafeObject / exec
- projects/code-sandbox/src/index.ts:208 -- POST /sandbox/python route
- packages/service/core/workflow/dispatch/tools/codeSandbox.ts:27 and packages/service/thirdProvider/codeSandbox/index.ts:57-80 -- Code node -> Python worker dispatch
- projects/code-sandbox/Dockerfile -- node:24-alpine, no project-level seccomp/nsjail; USER sandbox
- projects/code-sandbox/src/pool/python-process-pool.ts:20-21 and src/env.ts:73 -- persistent pooled worker; recycleAfterTask off by default for the default Python allowlist
- CVE-2026-32128 / GHSA-6hw6-mxrm-v6wj -- prior FastGPT advisory (predecessor projects/sandbox) that already documented "static checks are bypassable with getattr + string concatenation"
- GHSA-f5mq-qxm4-5mvc (JS-worker import() regex escape, same component, sibling class)
- Verified against source at HEAD 04c50f7 (app 4.15.0-4)
- CWE-693, CWE-184, CWE-94
Overview
FastGPT's workflow Code node (codeType: "py") runs user-supplied Python in the rewritten projects/code-sandbox worker, which confines code entirely with in-process Python guards: an import allowlist, a restricted open, replacement of the object builtin, and a pre-execution AST static check _validate_user_code that blocks the classic object.subclasses() reflection escape. The AST check is incomplete: it matches only a literal .subclasses attribute node or a getattr(obj, 'subclasses') whose second argument is an ast.Constant. A trivial string-concatenation -- getattr((1).class.base, "subcl" + "asses")() -- is an ast.BinOp, not a constant, so it passes validation, reaches the real object.subclasses(), walks to an already-loaded module exposing os, and calls os.system(...). An authenticated low-privilege team member who can add a Code node therefore obtains OS command execution inside the code-sandbox container, defeating the confinement.
Impact
Any authenticated user who can create or edit a workflow containing a Python Code node can bypass the worker's in-process Python language confinement. The bypass evades _validate_user_code, reaches the real object.subclasses(), obtains an already-loaded os reference, and reaches os.system / os.popen under the same restricted-builtins model used by the worker. The confinement (AST check + restricted builtins + import allowlist + restricted filesystem access) exists precisely to prevent Code-node Python from reaching OS-level effects -- process execution, filesystem access, network access -- from inside the sandbox container; this report defeats that boundary.
Confirmed (worker-level end-to-end, real worker as the container sandbox user). Running the byte-identical real worker.py via the real spawn command and IPC protocol, inside a container that mirrors the runtime Dockerfile and executes as the non-root sandbox user, the payload bypasses _validate_user_code, evades the _SafeObject replacement of the object builtin, reaches the real object.subclasses(), harvests an already-loaded os reference, and executes os.popen("id") -- returning uid=100(sandbox) gid=101(sandbox), the container's sandbox account. An attacker substitutes any command and can read files and environment variables accessible to that user and initiate outbound network connections from the container (SSRF / lateral movement to internal services reachable from it). This is OS command execution inside the sandbox container, not a Docker host escape.
The only layer not exercised is the Hono HTTP front-end at POST /sandbox/python; based on the traced source path (see Technical Details -> HTTP layer) it forwards the {code, variables} JSON to the same worker IPC that was exercised here and does not itself implement the Python confinement (see Reproduction -> Verification).
Potential additional impact: worker reuse
The Python worker is a persistent, pooled process (SANDBOX_POOL_SIZE defaults to 20 preheated workers), and recycleAfterTask is false by default -- it is enabled only if SANDBOX_PYTHON_ALLOWED_MODULES is configured to include subprocess/multiprocessing/threading/concurrent, none of which are in the default allowlist (projects/code-sandbox/src/pool/python-process-pool.ts:20-21, env.ts:73). So by default an escaped worker is not torn down after the attacker's task and is reused for subsequent executions, which may allow in-process persistence across tasks: if the attacker can modify worker-level state or hooks reused by later executions, this could lead to interception or tampering of subsequent Code-node payloads or results handled by that same worker. This cross-task impact has not been independently demonstrated and should be treated as a follow-up hardening concern rather than a confirmed impact; it depends on the deployment's task-routing and per-task state-reset behaviour.
The vulnerable Code node is a standard, user-selectable workflow node (SandboxCodeTypeEnum.py), not an administrator-only feature; calibrate the required privilege to the product's permission model (see the Severity note).
Technical Details
Reachability -- the Code node dispatches to the Python worker
Running or chat-testing the app (e.g. POST /api/core/chat/chatTest) dispatches the node, so the attacker triggers their own payload.
HTTP layer -- does not implement the confinement
The /sandbox/python HTTP handler validates only the request shape (executeSchema = {code, variables, queueId}); it does not inspect or sanitize the Python code. It hands the parsed body straight to the worker pool, which writes it verbatim to the worker's stdin:
The entire Python confinement (AST check + restricted builtins + import allowlist + restricted open) lives inside worker.py, downstream of this forwarding. That is the boundary the verification below exercises directly via the same IPC line the pool writes; the HTTP/pool layers above it add no code analysis.
The confinement, and the gap in _validate_user_code
The new code-sandbox replaced the old seccomp-based projects/sandbox/ with pure in-process Python guards (projects/code-sandbox/src/pool/worker.py). Before exec, it builds a restricted builtins dict and runs an AST check:
Two gaps make the check trivially evadable, and the H3 object replacement does not help:
String-built attribute name. getattr(x, "subcl" + "asses") -- the second argument is an ast.BinOp, not an ast.Constant, so neither branch matches. No literal .subclasses attribute node exists either.
No block on class/base. The check blocks only subclasses; class, base, init, globals are all permitted. (1).class.base resolves to the real object type via the int's type chain -- it does not go through the object name, so _safe_builtins['object'] = _SafeObject (whose subclasses is None) is irrelevant.
The escape is the classic CPython reflection chain, expressed to dodge both:
import being allow-listed is irrelevant: the payload never imports os; it harvests an already-loaded os reference from a subclass's init.globals (the worker process loads os/socket/subprocess for its own operation). No project-level OS isolation (seccomp, nsjail, Landlock, gVisor, microVM) is configured for the Python worker -- the code-sandbox Dockerfile (node:24-alpine) only creates the sandbox user and runs node, so the in-process AST check + restricted builtins are the entire boundary. (Docker's default seccomp profile, if a deployment applies it, must not be treated as a Python code-execution sandbox: it does not generally block process/file/network syscalls such as those behind os.system/os.popen/open inside the container.)
Root-cause context -- new sink and component, known obfuscation class
The vulnerable sink and the affected component are new; the obfuscation technique is not. CVE-2026-32128 / GHSA-6hw6-mxrm-v6wj (published 2026-03-11), filed against the predecessor seccomp-based projects/sandbox, documented as Root-Cause #4, verbatim: "Static checks are bypassable with basic obfuscation (e.g., getattr + string concatenation)", with PoCs such as getattr(os, 'o'+'pen'). The security issue reported here is that the rewritten projects/code-sandbox Python worker reintroduced the same architectural weakness at a new protection point: _validate_user_code specifically attempts to block object.subclasses() reflection, but is bypassed by the same class of string-built attribute access (getattr(x, "subcl"+"asses")), at a different file, a different sink, and a different control than the prior advisory. In short -- the technique is known, but the rewrite did not carry the architectural lesson of CVE-2026-32128 (that an in-process static blocklist cannot confine Python) into the new component. It is also the Python-worker sibling of the already-fixed JS-worker escape (GHSA-f5mq, an import() regex bypassed by import/**/(...)).
Severity note (calibration)
Scored PR:L (an authenticated app editor), S:U (command execution within the code-sandbox container's authority), C:H/I:H/A:H (full control of that container). The score assumes a non-admin authenticated team member can create or edit a workflow containing a Python Code node; if a deployment restricts Code-node editing to administrators, PR:H is more appropriate. The project rated the impact-equivalent JS-worker escape GHSA-f5mq (command execution as the sandbox user) as Medium; the blast radius here is likewise the isolated code-sandbox container, and the maintainer may calibrate to that precedent. The defense-in-depth value of the container does not change that the confinement the report defeats is a real, intended security boundary. The score treats the Python language confinement as the security boundary exposed to untrusted Code-node authors: the container limits the blast radius, but it does not make OS command execution inside the sandbox an intended outcome. If the project instead treats arbitrary command execution inside the code-sandbox container as an expected or accepted outcome for Code-node authors, the severity may be lower; this report assumes the Python in-process restrictions are intended to prevent such OS-level effects. The key security question is not whether the container limits the blast radius, but whether a Python Code-node author is intended to obtain arbitrary process-execution primitives inside that container despite the explicit import, open, builtin, and AST restrictions placed around the Code node. Note, however, the novelty context above: because the bypass class is already public (CVE-2026-32128), triage may treat this as a known-class hardening item rather than a fresh vulnerability.
Reproduction
Verification
Confirmed worker-level end-to-end against the real worker in a faithful container (the worker is the whole confinement; the HTTP/pool layers above it add no code analysis -- see Technical Details -> HTTP layer). The worker.py used is byte-identical (verified by sha256) to the audited source at HEAD 04c50f7. It was run via the real pool spawn command (python3 -u worker.py, from python-process-pool.ts) and driven over the real stdin/stdout IPC protocol (the {"type":"init",...} line then the {"code":...,"variables":{}} task line that base-process-pool.ts sends), inside an Alpine container that mirrors the runtime Dockerfile: python3, a non-root sandbox system user created with adduser -S, and HOME=/tmp.
Observed:
Control -- a Code node using direct .subclasses access is blocked by _validate_user_code ({"success": false, "message": "Access to subclasses is not allowed in sandbox"}), confirming the control is real.
Bypass -- the string-concatenated getattr Code node below passes validation, reaches object.subclasses(), finds an already-loaded os, and runs os.popen("id"), returning uid=100(sandbox) gid=101(sandbox) groups=101(sandbox) -- OS command execution as the container's non-root sandbox user (an Alpine system account, so not uid 1000).
The only layer not exercised is the Hono HTTP front-end at POST /sandbox/python; it is a thin JSON pass-through of {code, variables} to the same worker IPC and carries no part of the security boundary.
Expected product-level reproduction path
This is the product-level path (UI / API) that drives the same worker confirmed below; the worker-level execution itself is verified in Verification. In the FastGPT UI (as a team member who can edit an app), add a Code node with Code type = Python, paste the body below, and run the workflow (or chat-test the app). Equivalently, drive it via the API: POST /api/core/app/update to store the node, then POST /api/core/chat/chatTest to dispatch it.
Expected node output (the Code node returns the command's stdout). The id output reflects the Dockerfile's sandbox user -- an Alpine system account created via adduser -S sandbox, so not uid 1000 (node:24-alpine already assigns uid 1000 to the node user). This is the exact value observed end-to-end against the real worker in a container mirroring the runtime image:
Returning the output of id proves OS command execution escaped the Python confinement. An attacker substitutes any command; popen(...).read() exfiltrates output in-band through the node's normal return value.
Supporting evidence (real worker in a container, as the sandbox user)
The real worker.py (byte-identical to the audited source) is driven over its real IPC protocol -- the init line then the task line -- exactly as the worker pool drives it, inside a container that mirrors the runtime Dockerfile and runs as the non-root sandbox user:
The uid=100(sandbox) output is the container's non-root sandbox account, confirming OS command execution escaped the Python confinement end-to-end (validator + _SafeObject evaded -> object.subclasses() -> os.popen), not merely on the researcher's host.
Suggested Fix
A denylist of attribute-name shapes cannot confine Python -- string building, attribute chains (class/base/mro/globals), and decode tricks all evade any AST blocklist, and the object name swap is bypassed by (1).class.base.
Restore OS-level isolation as the boundary. Run the Python worker under seccomp-bpf, nsjail, gVisor, a Firecracker/microVM, or an equivalent OS-level sandbox (as the deleted projects/sandbox/ did), so that even if the in-process guards are bypassed, os.system/execve is denied below the Python layer. The AST check should be defense-in-depth only.
If in-process checks are retained, they must operate on a positive model rather than enumerating specific forbidden strings. At minimum, deny all dunder attribute access and all dynamic getattr/setattr/delattr paths unless the attribute name is statically resolved (an ast.Constant) and on an explicit allowlist -- a getattr with a non-constant name argument (ast.BinOp, ast.Name, ast.Call, etc.) must be rejected outright, not skipped. This in-process check should be treated as defense-in-depth only and never as a complete sandbox.
References