CVE-2026-22709

The Call Is Coming From Inside The House: Breaking vm2 with Method Hijacking

Amit Schendel
Amit Schendel
Senior Security Researcher

Jan 26, 2026·6 min read·76 visits

Executive Summary (TL;DR)

If you are running untrusted code using vm2 versions <= 3.10.1, you are owned. Attackers can overwrite `Function.prototype.call` inside the sandbox. When the host tries to sanitize a Promise, it accidentally runs the attacker's code with host privileges, leading to a complete sandbox escape.

A critical sandbox escape in the vm2 library allows attackers to break out of the isolated environment by hijacking Function.prototype.call. This enables Remote Code Execution (RCE) on the host machine.

The Hook: A Prison Made of Cheese

Let's talk about vm2. Ideally, it's a Fort Knox for running untrusted Node.js code. In reality, it's more like a prison constructed entirely out of Swiss cheese, where the inmates are given lockpicks as welcome gifts. The library attempts to solve an impossible problem: running hostile JavaScript inside the V8 engine while trying to prevent that JavaScript from accessing the host process. It relies on a complex web of Proxies and context sanitization to keep the bad guys in.

The problem with complexity is that it breeds bugs. CVE-2026-22709 isn't just a simple buffer overflow; it's a logic flaw in how the "warden" (the host context) speaks to the "inmates" (the sandbox). Specifically, the bridge that handles JavaScript Promises trusted the environment too much. And in the world of browser/node exploitation, trust is death.

This vulnerability is particularly spicy because it exploits one of the most fundamental features of JavaScript: the prototype chain. The developers of vm2 made a classic mistake—they assumed that when they called a function, the language would behave the way they expected. They were wrong.

The Flaw: Trusting the Untrustable

The root cause here is a failure to understand the mutability of the JavaScript environment. In lib/setup-sandbox.js, the vm2 library sets up wrappers for global objects like Promise. When you use a Promise inside the sandbox, vm2 intercepts the .then() and .catch() methods to ensure that any data passed back and forth is sanitized (proxied).

Here is the fatal mistake: inside these wrappers, the host code used the standard Function.prototype.call method to invoke the original Promise logic. It looked something like globalPromiseThen.call(this, onFulfilled, onRejected).

The developer assumed that .call refers to the native, unpolluted Function.prototype.call. But here is the kicker: the code is running in a context where the attacker controls the global scope. If the attacker overwrites Function.prototype.call with their own malicious function, the host code—running with high privileges—will blindly execute it. It’s the digital equivalent of a bank manager walking into the vault and handing the keys to a robber because the robber is wearing a name tag that says 'Security Guard'.

The Code: The Smoking Gun

Let's look at the diff. It is subtle, but it makes all the difference in the world. The vulnerability exists in lib/setup-sandbox.js.

The Vulnerable Code (v3.10.1):

// Inside the sandbox setup logic
globalPromise.prototype.then = function then(onFulfilled, onRejected) {
    // [..Sanitization Logic..]
    
    // CRITICAL FAIL: Using .call() on the prototype
    return globalPromiseThen.call(this, onFulfilled, onRejected);
};

When globalPromiseThen.call executes, the JavaScript engine looks up the .call property on the function object. Since the attacker controls the environment, they can define what .call is.

The Fixed Code (v3.10.2):

// The fix uses a safe reference to Reflect.apply
globalPromise.prototype.then = function then(onFulfilled, onRejected) {
    // [..Sanitization Logic..]
    
    // SECURE: Using a frozen reference to apply
    return apply(globalPromiseThen, this, [onFulfilled, onRejected]);
};

The fix replaces the dynamic method lookup with Reflect.apply (aliased here as apply). Reflect.apply ignores the object's own properties and prototype chain, forcing the execution of the target function using the real execution mechanics. It bypasses the attacker's trap completely.

The Exploit: Climbing Out of the Box

So, how do we turn "hijacking .call" into "Remote Code Execution"? It requires a bit of finesse. We can't just run exec('/bin/sh') directly inside the hijacked call because we are still technically within the sandbox's logical flow. We need a handle on a host object.

The attack chain works like this:

  1. Pollute the Prototype: We overwrite Function.prototype.call with a function that captures the execution context.
  2. Trigger the Bridge: We create a Promise and attach a .then() handler. This forces vm2 to run its vulnerable wrapper code.
  3. The Crash Trick: Inside our hijacked code, we need to confuse the host. We use a known trick involving Symbol(). We set an error's name to a Symbol (e.g., err.name = Symbol()).
  4. Leak Host Exception: When Node.js tries to generate a stack trace for that error, it throws a host-side exception because it expects a string for the error name, not a Symbol.
  5. Constructor hopping: This host exception is caught by vm2 and passed back to us. Crucially, this exception object belongs to the host context. We access exception.constructor (Host Error), then exception.constructor.constructor (Host Function), and boom—we have a Function constructor that runs outside the sandbox.

Here is a simplified PoC:

const { VM } = require('vm2');
const vm = new VM();
 
const code = `
    // 1. The Trap
    const _call = Function.prototype.call;
    Function.prototype.call = function(...args) {
        // We are now intercepting internal calls
        // The actual exploit involves throwing a specific error
        // to leak the host constructor, but this proves the hijack.
        _call.apply(console.log, ["[+] Intercepted call from host!"]);
        return _call.apply(this, args);
    };
 
    // 2. The Trigger
    Promise.resolve().then(() => {});
`;
 
vm.run(code);

The Impact: Why You Should Panic

This is a CVSS 10.0 for a reason. If you are using vm2 to run user-submitted scripts (for example, in a "Functions as a Service" platform, a discord bot, or a rules engine), you are vulnerable to complete server compromise.

An attacker doesn't just crash your app; they gain the ability to require child_process, read your environment variables (AWS keys, database passwords), and pivot to your internal network. Because vm2 is often deployed specifically because developers are worried about security, the impact is ironic and devastating. You installed a blast door, but it turns out the blast door was made of C4.

The Fix: Abandon Ship

If you are using vm2, stop. Seriously. The library has been historically plagued by these kinds of escapes because the architecture of wrapping V8 constructs in Proxies is fundamentally fragile. It is a game of whack-a-mole that the maintainers cannot win indefinitely.

Immediate Mitigation: If you absolutely cannot migrate, update to version 3.10.2 immediately. This version introduces the Reflect.apply fix that neutralizes the specific Function.prototype.call hijack vector.

Long-Term Fix: Migrate to isolated-vm. It uses V8 Isolates (a lower-level primitive) rather than JavaScript proxies, providing a much harder security boundary. Alternatively, run untrusted code in ephemeral Docker containers or Firecracker microVMs. Do not trust a JavaScript library to sandbox JavaScript code.

Fix Analysis (1)

Technical Appendix

CVSS Score
10.0/ 10
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H

Affected Systems

Node.js applications using vm2 <= 3.10.1Serverless function implementations relying on vm2Rule engines executing user-defined JavaScript

Affected Versions Detail

Product
Affected Versions
Fixed Version
vm2
Patrik Simek
<= 3.10.13.10.2
AttributeDetail
CWE IDCWE-250 (Execution with Unnecessary Privileges)
Attack VectorNetwork
CVSS10.0 (Critical)
ImpactRemote Code Execution (RCE)
Exploit StatusPoC Available
Patch Date2026-01-17
CWE-1321
Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution')

The product performs a calculation or logic based on a critical value (Function.prototype) that can be modified by an untrusted actor.

Vulnerability Timeline

Vulnerability Discovered
2026-01-17
Patch Released (v3.10.2)
2026-01-17
CVE Assigned
2026-01-23

Subscribe to updates

Get the latest CVE analysis reports delivered to your inbox.