Severity
Details
- CVSS score
- 6.9
- CVSS vector
- CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:L/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X
Overview
About vulnerability
Summary
NodeVM exposes the host util module to the sandbox through an unfiltered shallow copy (Object.assign({}, util)). On Node.js >= 22.9 this hands sandboxed code util.getCallSites(), a programmatic stack-introspection API that returns the host process’s full call stack — absolute file paths, function names, and line numbers — including vm2 bridge internals and the embedding application’s entrypoint. This bypasses the host-frame redaction established in GHSA-v27g-jcqj-v8rw, which only covers the Error.prepareStackTrace channel.
Details
- Root cause —
defaultBuiltinLoaderUtilcopies every static member of the hostutilmodule and wraps the copy invm.readonly()without filtering any member, so newly added Node APIs land in the sandbox automatically: https://github.com/patriksimek/vm2/blob/7a1f5100b96f48d34e0fe104ab37c0acc5944f92/lib/builtin.js#L25-L38 - Second equivalent channel — the deprecated
sysbuiltin (an alias of hostutil) goes through the generic builtin loadervm.readonly(hostRequire(key)), which also carriesgetCallSites: https://github.com/patriksimek/vm2/blob/7a1f5100b96f48d34e0fe104ab37c0acc5944f92/lib/builtin.js#L230 - Bypassed protection — GHSA-v27g’s redaction (
isHostFrameFileName+applyCallSiteGetters) rewrites host-frame metadata getters tonullonly when the sandbox realm formats an error stack: https://github.com/patriksimek/vm2/blob/7a1f5100b96f48d34e0fe104ab37c0acc5944f92/lib/setup-sandbox.js#L818-L870
util.getCallSites() produces its data host-side and never passes through that formatter, so frames such as lib/bridge.js @apply (the bridge apply trap), lib/nodevm.js @run, the embedder’s own entry file, and node:internal/* frames reach the sandbox verbatim as data properties (scriptName, functionName, lineNumber, columnNumber, scriptId). A repository-wide grep (source, docs/ATTACKS.md, CHANGELOG, tests) shows no occurrence of getCallSites; the member was never considered.
PoC
Prerequisites: Node.js >= 22.9 (verified on v22.23.1); run npm ci --no-audit --no-fund at the repository root.
One-line reproducer (prints /workspace/repo/lib/bridge.js, i.e. a vm2-internal host path):
node -e “const {NodeVM}=require(’/workspace/repo’); console.log(new NodeVM({require:{builtin:[‘util’]}}).run("module.exports = require(‘util’).getCallSites(4)[0].scriptName"))”
Observed frames inside the sandbox, with both require: { builtin: ['util'] } and require: { builtin: ['*'] }:
/workspace/repo/lib/bridge.js @apply:1664 vm.js @:5 /workspace/repo/lib/bridge.js @apply:1664 /workspace/repo/lib/nodevm.js @run:563 /workspace/out/<embedder entrypoint>.js @main:29 node:internal/modules/cjs/loader @:1781 node:internal/modules/cjs/loader @:1913 … (8 node:internal/* frames in total)
Impact
Information disclosure. Any NodeVM configuration that allows the util (or sys) builtin — including the wildcard builtin: ['*'], both typical configurations from the README — lets untrusted sandboxed code programmatically read the host call stack: the vm2 installation path, the embedding application’s file layout and entrypoint, and internal function names and line numbers. This is the same information category redacted by GHSA-v27g-jcqj-v8rw (Defense Invariant #5 in docs/ATTACKS.md) and breaks defense-in-depth assumptions of embedders that rely on host frames being invisible to the sandbox. The API returns only strings/numbers (no host object references); no escalation to code execution was identified.
Suggested fix: filter members in defaultBuiltinLoaderUtil (allowlist, or at minimum drop getCallSites), apply the same treatment to the generic loader path used by sys, and extend the GHSA-v27g regression tests to cover programmatic stack-introspection APIs.
Details
- Affected product:
- vm2
- Affected packages:
- vm2 @ 3.11.5 (+2 more)