vm2 sandbox breakout via indirect-call dangerous-mutator shadowing — operator validation¶
Date reviewed: 2026-08-31 Advisory: GHSA-cfcw-xp6x-25gj / CVE-2026-47698 — critical, CVSS 9.8 Affected: vm2 <= 3.11.5 (patched in 3.11.6)
Follow-up to the 2026-05-29 vm2 / NodeVM batch and the 2026-05-07 vm2 sandbox-escape batch. The fix for GHSA-v6mx-mf47-r5wg (CVE-2026-47131) is insufficient and bypassable: the dangerous-mutator deny-list keys off the direct call shape indirectcall.call(dangerousmutator, ...), and swapping the receiver to the indirect-call object itself — indirectcall.call(indirectcall, dangerousmutator, ...) — slips through because indirect calls are not classified as dangerous.
The primitive¶
Inside a vm2 VM context the attacker:
- Reaches
Buffer.call(the nativeFunction.prototype.callexposed via theBufferintrinsic). - Lifts
__proto__getters/setters off any host object via__lookupGetter__/__lookupSetter__:Buffer.call.call(Buffer.call, {}.__lookupGetter__, Buffer, "__proto__"). - Forces a host-originated exception (
await WebAssembly.compileStreaming()in a context without that API) and re-points the exception object's prototype to the lifted host__proto__chain, soe.constructor.constructorisFunction. - Executes
e.constructor.constructor("return process")()and then.mainModule.require('child_process').execSync(...)for host RCE.
Confirmed by the advisory's PoC (touch pwned). The durable lesson: a deny-list of "dangerous" host objects is only as strong as the call shapes it recognizes; an attacker who can invoke any of those objects through a different receiver (the indirect-call object, a proxy, a lifted intrinsic) bypasses the shape check.
Durable operator value¶
Reusable across any in-process JS sandbox (vm2, RestrictedPython-style guards, WASI wrappers, agent tool executors, CI sandbox plugins):
- Classify the guard, not the payload. Find the exact predicate (e.g. "receiver is the dangerous object" vs "callee is in the deny-list"). Then enumerate receiver-swapping variants:
X.call(X, y, ...),Reflect.apply,fn.bind/fn.callvia lifted intrinsics, proxyapplytraps,Symbol.toPrimitivecoercion paths. - Assume every reachable intrinsic is load-bearing.
Function.prototype.call,__lookupGetter__/__lookupSetter__,constructor.constructor, andWebAssemblypromise/exception channels are the recurring vm2 escape seams (see both May batches). If any one crosses the boundary, the "sandbox" is a suggestion. - Exception objects are bridges. Host-originated errors that cross into the sandbox retain host
__proto__/constructorchains; re-prototyping the exception is the classic last step. Test whether sandbox code can reade.constructor.constructoron a deliberately triggered error. - Version-gate the check. This bypass works up to
vm2 3.11.5;3.11.6is the first safe release. Record thevm2version in any sandbox-boundary report.
Replayable validation boundaries¶
- Preconditions: an authorized lab host running a product that evaluates user-supplied JavaScript via
vm2 <= 3.11.5(or a local harness that imports the target'svm2/NodeVMconfiguration from its lockfile). - Version/config proof first: capture
vm2version +VM/NodeVMoptions (require,nesting,produce/consumeproxies) from manifests or container images before touching payloads. - Benign host-execution marker only: in the lab, replace the advisory PoC's
execSync('touch pwned')withid, a canary file in a disposable temp directory, or reading a synthetic environment variable. Positive evidence is the marker, plus the exact receiver-swap line that bypassed the guard. - Do not target production multi-tenant deployments with escape payloads; do not read host secrets, environment files, or cloud credentials; do not leave executed content on the host.
- Negative control: run the same payload on
vm2 >= 3.11.6(or with the feature disabled) and confirm denial — this isolates the deny-list shape check as the differential.
Reporting heuristics¶
- Lead with the trust boundary: user-supplied JS evaluated in-process as a security boundary — state why in-process isolation is relied upon (tenant code, plugin system, workflow engine, agent tool).
- Include:
vm2version, Node runtime version,VM/NodeVMconfiguration, the exact guard predicate being bypassed, the receiver-swapping line, and the benign marker proof. - Distinguish host RCE from capability bypass (network builtins, file writes) as in the May batches.
- Frame the finding as "sandbox deny-list shape bypass," not a generic version finding — the reusable pattern (receiver/callee shape confusion) is what makes it actionable on other products.