The Call Is Coming From Inside The House: Breaking vm2 with Method Hijacking
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:
- Pollute the Prototype: We overwrite
Function.prototype.callwith a function that captures the execution context. - Trigger the Bridge: We create a
Promiseand attach a.then()handler. This forcesvm2to run its vulnerable wrapper code. - 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()). - 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.
- Constructor hopping: This host exception is caught by
vm2and passed back to us. Crucially, this exception object belongs to the host context. We accessexception.constructor(Host Error), thenexception.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.
Official Patches
Fix Analysis (1)
Technical Appendix
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:HAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
vm2 Patrik Simek | <= 3.10.1 | 3.10.2 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-250 (Execution with Unnecessary Privileges) |
| Attack Vector | Network |
| CVSS | 10.0 (Critical) |
| Impact | Remote Code Execution (RCE) |
| Exploit Status | PoC Available |
| Patch Date | 2026-01-17 |
MITRE ATT&CK Mapping
The product performs a calculation or logic based on a critical value (Function.prototype) that can be modified by an untrusted actor.
Known Exploits & Detection
Vulnerability Timeline
Subscribe to updates
Get the latest CVE analysis reports delivered to your inbox.