VYPR
Critical severityNVD Advisory· Published Oct 18, 2021· Updated Sep 17, 2024

Sandbox Bypass

CVE-2021-23449

Description

This affects the package vm2 before 3.9.4 via a Prototype Pollution attack vector, which can lead to execution of arbitrary code on the host machine.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

vm2 before 3.9.4 allows prototype pollution leading to sandbox escape and arbitrary code execution on the host.

Vulnerability

The vulnerability affects vm2 versions prior to 3.9.4 [1][2]. It is caused by a prototype pollution attack vector that allows an attacker to modify the prototype of built-in objects within the sandbox environment [2]. This can be leveraged to bypass the sandbox restrictions and execute arbitrary code on the host machine [2]. The sandbox uses a complex system of Proxies to intercept interactions, but prototype pollution undermines these protections [1].

Exploitation

An attacker must be able to execute untrusted JavaScript code within the vm2 sandbox [1]. By crafting a payload that pollutes the prototype chain (e.g., of Object.prototype), the attacker can inject malicious properties or methods that are subsequently accessed by the sandbox internals [2]. In version 3.9.4, the fix includes hardening of Function.prototype.bind and Reflect methods to prevent this pattern [3]. The exploit does not require authentication or network access beyond the ability to supply code to the sandbox [1].

Impact

Successful exploitation allows an attacker to escape the vm2 sandbox entirely and execute arbitrary code on the host Node.js process [1][2]. This results in full compromise of the confidentiality, integrity, and availability of the host system, as the attacker can read/write files, spawn processes, and access network resources [1].

Mitigation

Users should upgrade to vm2 version 3.9.4 or later, which was released on 2021-10-18 [4]. The commit b4f6e2b addresses the vulnerability by removing the curry function and properly uncursing native methods, as well as adding additional protections around Error.prepareStackTrace [3]. There is no effective workaround; running untrusted code with vm2 versions before 3.9.4 is unsafe [1]. This CVE is not listed in CISA's Known Exploited Vulnerabilities (KEV) catalog.

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
vm2npm
< 3.9.43.9.4

Affected products

2

Patches

1
b4f6e2bd2c4a

Security Fixes

https://github.com/patriksimek/vm2XmiliaHOct 12, 2021via ghsa
4 files changed · +201 51
  • lib/contextify.js+128 3 modified
    @@ -24,11 +24,11 @@ local.Reflect.isExtensible = Reflect.isExtensible;
     local.Reflect.preventExtensions = Reflect.preventExtensions;
     local.Reflect.getOwnPropertyDescriptor = Reflect.getOwnPropertyDescriptor;
     
    -function curry(func) {
    +function uncurryThis(func) {
     	return (thiz, args) => local.Reflect.apply(func, thiz, args);
     }
     
    -const FunctionBind = curry(Function.prototype.bind);
    +const FunctionBind = uncurryThis(Function.prototype.bind);
     
     // global is originally prototype of host.Object so it can be used to climb up from the sandbox.
     Object.setPrototypeOf(global, Object.prototype);
    @@ -69,7 +69,7 @@ const Contextified = new host.WeakMap();
     const Decontextified = new host.WeakMap();
     
     // We can't use host's hasInstance method
    -const ObjectHasInstance = curry(local.Object[Symbol.hasInstance]);
    +const ObjectHasInstance = uncurryThis(local.Object[Symbol.hasInstance]);
     function instanceOf(value, construct) {
     	try {
     		return ObjectHasInstance(construct, [value]);
    @@ -1001,6 +1001,131 @@ BufferOverride.inspect = function inspect(recurseTimes, ctx) {
     };
     const LocalBuffer = global.Buffer = Contextify.readonly(host.Buffer, BufferMock);
     Contextify.connect(host.Buffer.prototype.inspect, BufferOverride.inspect);
    +Contextify.connect(host.Function.prototype.bind, Function.prototype.bind);
    +
    +const oldPrepareStackTraceDesc = Reflect.getOwnPropertyDescriptor(Error, 'prepareStackTrace');
    +
    +let currentPrepareStackTrace = Error.prepareStackTrace;
    +const wrappedPrepareStackTrace = new host.WeakMap();
    +if (typeof currentPrepareStackTrace === 'function') {
    +	wrappedPrepareStackTrace.set(currentPrepareStackTrace, currentPrepareStackTrace);
    +}
    +
    +let OriginalCallSite;
    +Error.prepareStackTrace = (e, sst) => {
    +	OriginalCallSite = sst[0].constructor;
    +};
    +new Error().stack;
    +if (typeof OriginalCallSite === 'function') {
    +	Error.prepareStackTrace = undefined;
    +
    +	function makeCallSiteGetters(list) {
    +		const callSiteGetters = [];
    +		for (let i=0; i<list.length; i++) {
    +			const name = list[i];
    +			const func = OriginalCallSite.prototype[name];
    +			callSiteGetters[i] = {__proto__: null,
    +				name,
    +				propName: '_' + name,
    +				func: (thiz) => {
    +					return local.Reflect.apply(func, thiz, []);
    +				}
    +			};
    +		}
    +		return callSiteGetters;
    +	}
    +
    +	function applyCallSiteGetters(callSite, getters) {
    +		const properties = {__proto__: null};
    +		for (let i=0; i<getters.length; i++) {
    +			const getter = getters[i];
    +			properties[getter.propName] = {
    +				__proto__: null,
    +				value: getter.func(callSite)
    +			};
    +		}
    +		return properties;
    +	}
    +
    +	const callSiteGetters = makeCallSiteGetters([
    +		'getTypeName',
    +		'getFunctionName',
    +		'getMethodName',
    +		'getFileName',
    +		'getLineNumber',
    +		'getColumnNumber',
    +		'getEvalOrigin',
    +		'isToplevel',
    +		'isEval',
    +		'isNative',
    +		'isConstructor',
    +		'isAsync',
    +		'isPromiseAll',
    +		'getPromiseIndex'
    +	]);
    +
    +	class CallSite {
    +		constructor(callSite) {
    +			Object.defineProperties(this, applyCallSiteGetters(callSite, callSiteGetters));
    +		}
    +		getThis() {return undefined;}
    +		getFunction() {return undefined;}
    +		toString() {return 'CallSite {}';}
    +	}
    +
    +	(function setupCallSite() {
    +		for (let i=0; i<callSiteGetters.length; i++) {
    +			const name = callSiteGetters[i].name;
    +			const propertyName = callSiteGetters[i].propName;
    +			const func = {func() {
    +				return this[propertyName];
    +			}}.func;
    +			const nameProp = Object.getOwnPropertyDescriptor(func, 'name');
    +			nameProp.value = name;
    +			Object.defineProperty(func, 'name', nameProp);
    +			const funcProp = Object.getOwnPropertyDescriptor(OriginalCallSite.prototype, name);
    +			funcProp.value = func;
    +			Object.defineProperty(CallSite.prototype, name, funcProp);
    +		}
    +	})();
    +
    +	Object.defineProperty(Error, 'prepareStackTrace', {
    +		configurable: false,
    +		enumerable: false,
    +		get() {
    +			return currentPrepareStackTrace;
    +		},
    +		set(value) {
    +			if (typeof(value) !== 'function') {
    +				currentPrepareStackTrace = value;
    +				return;
    +			}
    +			const wrapped = wrappedPrepareStackTrace.get(value);
    +			if (wrapped) {
    +				currentPrepareStackTrace = wrapped;
    +				return;
    +			}
    +			const newWrapped = (error, sst) => {
    +				if (host.Array.isArray(sst)) {
    +					for (let i=0; i<sst.length; i++) {
    +						const cs = sst[i];
    +						if (typeof cs === 'object' && local.Reflect.getPrototypeOf(cs) === OriginalCallSite.prototype) {
    +							sst[i] = new CallSite(cs);
    +						}
    +					}
    +				}
    +				return value(error, sst);
    +			};
    +			wrappedPrepareStackTrace.set(value, newWrapped);
    +			wrappedPrepareStackTrace.set(newWrapped, newWrapped);
    +			currentPrepareStackTrace = newWrapped;
    +		}
    +	});
    +} else if (oldPrepareStackTraceDesc) {
    +	Reflect.defineProperty(Error, 'prepareStackTrace', oldPrepareStackTraceDesc);
    +} else {
    +	Reflect.deleteProperty(Error, 'prepareStackTrace');
    +}
     
     
     const exportsMap = host.Object.create(null);
    
  • lib/main.js+59 28 modified
    @@ -544,17 +544,17 @@ function doWithTimeout(fn, timeout) {
     	}
     }
     
    -/**
    - * Creates the hook to check for the use of async.
    - * 
    - * @private
    - * @param {*} internal - The internal vm object.
    - * @return {*} The hook function
    - */
    -function makeCheckAsync(internal) {
    +function tryCompile(args) {
    +	const code = args[args.length - 1];
    +	const params = args.slice(0, -1);
    +	vm.compileFunction(code, params);
    +}
    +
    +function makeCheckHook(checkAsync, checkImport) {
    +	if (!checkAsync && !checkImport) return null;
     	return (hook, args) => {
    -		if (hook === 'function' || hook === 'generator_function' || hook === 'eval' || hook === 'run') {
    -			const funcConstructor = internal.Function;
    +		if (hook === 'function' || hook === 'generator_function' || hook === 'eval' || hook === 'run' ||
    +			(!checkAsync && (hook === 'async_function' || hook === 'async_generator_function'))) {
     			if (hook === 'eval') {
     				const script = args[0];
     				args = [script];
    @@ -563,28 +563,41 @@ function makeCheckAsync(internal) {
     				// Next line throws on Symbol, this is the same behavior as function constructor calls
     				args = args.map(arg => `${arg}`);
     			}
    -			if (args.findIndex(arg => /\basync\b/.test(arg)) === -1) return args;
    -			const asyncMapped = args.map(arg => arg.replace(/async/g, 'a\\u0073ync'));
    +			const hasAsync = checkAsync && args.findIndex(arg => /\basync\b/.test(arg)) !== -1;
    +			const hasImport = checkImport && args.findIndex(arg => /\bimport\b/.test(arg)) !== -1;
    +			if (!hasAsync && !hasImport) return args;
    +			const mapped = args.map(arg => {
    +				if (hasAsync) arg = arg.replace(/async/g, 'a\\u0073ync');
    +				if (hasImport) arg = arg.replace(/import/g, 'i\\u006dport');
    +				return arg;
    +			});
     			try {
    -				// Note: funcConstructor is a Sandbox object, however, asyncMapped are only strings.
    -				funcConstructor(...asyncMapped);
    +				tryCompile(mapped);
     			} catch (u) {
    -				// u is a sandbox object
    -				// Some random syntax error or error because of async.
    +				// Some random syntax error or error because of async or import.
     
     				// First report real syntax errors
    -				try {
    -					// Note: funcConstructor is a Sandbox object, however, args are only strings.
    -					funcConstructor(...args);
    -				} catch (e) {
    -					throw internal.Decontextify.value(e);
    +				tryCompile(args);
    +
    +				if (hasAsync && hasImport) {
    +					const mapped2 = args.map(arg => arg.replace(/async/g, 'a\\u0073ync'));
    +					try {
    +						tryCompile(mapped2);
    +					} catch (e) {
    +						throw new VMError('Async not available');
    +					}
    +					throw new VMError('Dynamic Import not supported');
    +				}
    +				if (hasAsync) {
    +					// Then async error
    +					throw new VMError('Async not available');
     				}
    -				// Then async error
    -				throw new VMError('Async not available');
    +				throw new VMError('Dynamic Import not supported');
     			}
     			return args;
     		}
    -		throw new VMError('Async not available');
    +		if (checkAsync) throw new VMError('Async not available');
    +		return args;
     	};
     }
     
    @@ -708,7 +721,7 @@ class VM extends EventEmitter {
     		// Create the bridge between the host and the sandbox.
     		const _internal = CACHE.contextifyScript.runInContext(_context, DEFAULT_RUN_OPTIONS).call(_context, require, HOST);
     
    -		const hook = fixAsync ? makeCheckAsync(_internal) : null;
    +		const hook = makeCheckHook(fixAsync, true);
     
     		// Define the properties of this object.
     		// Use Object.defineProperties here to be able to
    @@ -1172,7 +1185,22 @@ class NodeVM extends VM {
     		let script;
     
     		if (code instanceof VMScript) {
    -			script = this.options.strict ? code._compileNodeVMStrict() : code._compileNodeVM();
    +			if (this._hook) {
    +				const prefix = this.options.strict ? STRICT_MODULE_PREFIX : MODULE_PREFIX;
    +				const scriptCode = prefix + code.getCompiledCode() + MODULE_SUFFIX;
    +				const changed = this._hook('run', [scriptCode])[0];
    +				if (changed === scriptCode) {
    +					script = this.options.strict ? code._compileNodeVMStrict() : code._compileNodeVM();
    +				} else {
    +					script = new vm.Script(changed, {
    +						filename: code.filename,
    +						displayErrors: false,
    +						importModuleDynamically
    +					});
    +				}
    +			} else {
    +				script = this.options.strict ? code._compileNodeVMStrict() : code._compileNodeVM();
    +			}
     			resolvedFilename = pa.resolve(code.filename);
     			dirname = pa.dirname(resolvedFilename);
     		} else {
    @@ -1185,8 +1213,11 @@ class NodeVM extends VM {
     				dirname = null;
     			}
     			const prefix = this.options.strict ? STRICT_MODULE_PREFIX : MODULE_PREFIX;
    -			script = new vm.Script(prefix +
    -					this._compiler(code, unresolvedFilename) + MODULE_SUFFIX, {
    +			let scriptCode = prefix + this._compiler(code, unresolvedFilename) + MODULE_SUFFIX;
    +			if (this._hook) {
    +				scriptCode = this._hook('run', [scriptCode])[0];
    +			}
    +			script = new vm.Script(scriptCode, {
     				filename: unresolvedFilename,
     				displayErrors: false,
     				importModuleDynamically
    
  • lib/sandbox.js+3 1 modified
    @@ -68,8 +68,10 @@ return ((vm, host) => {
     
     					const code = host.STRICT_MODULE_PREFIX + contents + host.MODULE_SUFFIX;
     
    +					const ccode = vm._hook('run', [code]);
    +
     					// Precompile script
    -					script = new Script(code, {
    +					script = new Script(ccode, {
     						__proto__: null,
     						filename: filename || 'vm.js',
     						displayErrors: false,
    
  • test/vm.js+11 19 modified
    @@ -910,31 +910,23 @@ describe('VM', () => {
     	});
     
     	if (NODE_VERSION >= 10) {
    -		it('Dynamic import attack', (done) => {
    -			process.once('unhandledRejection', (reason) => {
    -				assert.strictEqual(reason.message, 'process is not defined');
    -				done();
    -			});
    +		it('Dynamic import attack', () => {
     
     			const vm2 = new VM();
     
    -			vm2.run(`
    -				(async () => {
    -					try {
    -						await import('oops!');
    -					} catch (ex) {
    -						// ex is an instance of NodeError which is not proxied;
    -						const process = ex.constructor.constructor('return process')();
    -						const require = process.mainModule.require;
    -						const child_process = require('child_process');
    -						const output = child_process.execSync('id');
    -						process.stdout.write(output);
    -					}
    -				})();
    -			`);
    +			assert.throws(()=>vm2.run(`
    +				const process = import('oops!').constructor.constructor('return process')();
    +			`), /VMError: Dynamic Import not supported/);
     		});
     	}
     
    +	it('Error.prepareStackTrace attack', () => {
    +		const vm2 = new VM();
    +		const sst = vm2.run('Error.prepareStackTrace = (e,sst)=>sst;const sst = new Error().stack;Error.prepareStackTrace = undefined;sst');
    +		assert.strictEqual(vm2.run('sst=>Object.getPrototypeOf(sst)')(sst), vm2.run('Array.prototype'));
    +		assert.throws(()=>vm2.run('sst=>sst[0].getThis().constructor.constructor')(sst), /TypeError: Cannot read property 'constructor' of undefined/);
    +	});
    +
     	after(() => {
     		vm = null;
     	});
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

8

News mentions

0

No linked articles in our index yet.