//nefariousplan

CVE-2026-44007: vm2 3.11.1 Rejects One Configuration. The README Is the Patch for the Rest.

patterns

cve

proof of concept

CVE-2026-44007 is a sandbox escape in vm2 that depends on a specific configuration pair. The pair is { nesting: true, require: false }. With those two options set, sandbox code reaches child_process.execSync in three lines: require('vm2') (which succeeds despite require: false), construct a second NodeVM with require: { builtin: ['child_process'] }, and call inner.run('require("child_process").execSync("id")'). The 3.11.1 patch is one if-statement at the top of the NodeVM constructor. The if-statement throws VMError when both options are set explicitly. The mechanism the if-statement guards against is unchanged. The README of vm2 3.11.1 adds a new section about every other configuration that reaches the same primitive, and the section's recommendation is: do not use nesting: true with untrusted code.

GHSA-8hg8-63c5-gwmx published on May 1, 2026, the same day vm2 maintainer Patrik Simek committed 46cbbdd and cut release 3.11.1. The reporter, Akshat Sinha, posted the disclosure to oss-security on May 5. The CVE-2026-44007 record landed alongside. CVSS 9.1, scope-changing, network-reachable. None of those numbers describe what the fix did, because what the fix did was refuse a configuration the library used to accept.

nesting: true injects vm2. require: false is decided after the injection.

The bug is two lines of code in two files, separated by a flag.

lib/nodevm.js defines the override object that nesting: true installs:

const NESTING_OVERRIDE = Object.freeze({
    __proto__: null,
    vm2: vm2NestingLoader
});

And then, deeper in the same file, the NodeVM constructor hands that override to the resolver factory:

const resolver = customResolver
    ? requireOpts
    : makeResolverFromLegacyOptions(
        requireOpts,
        nesting && NESTING_OVERRIDE,
        this._compiler
    );

requireOpts is the user's require config. With require: false, requireOpts === false. With nesting: true, nesting && NESTING_OVERRIDE evaluates to NESTING_OVERRIDE, an object whose only entry is the vm2 loader.

lib/resolver-compat.js decides what to do with the two arguments:

function makeResolverFromLegacyOptions(options, override, compiler) {
    if (!options) {
        if (!override) return DENY_RESOLVER;
        const builtins = makeBuiltinsFromLegacyOptions(
            undefined, defaultRequire, undefined, override
        );
        return new Resolver(DEFAULT_FS, [], builtins);
    }
    // ... normal path
}

require: false is falsy, so the function enters if (!options). From there, two branches:

  1. With no nesting, override is false. !override is true. The function returns DENY_RESOLVER, which actually denies every require. This is what require: false was documented to do.
  2. With nesting: true, override is NESTING_OVERRIDE. !override is false. The function builds a Resolver containing the override. The override carries vm2. From inside the sandbox, require('vm2') succeeds.

require: false's deny path and nesting: true's injection path are decided in the same branch of the same function. The branch's condition reads override first. nesting: true writes to the override slot. require: false writes to the options slot. The function reads override first and overrides what the options-slot value would have meant if it had been read alone.

The reporter's phrase for that asymmetry, quoted verbatim in the patch's commit message, is "mental-model mismatch." A developer who writes both options assumes they compose. They do not compose. The override is read first, and the deny path becomes unreachable.

The exploit walks out of that in three lines. From inside vm.run(...):

const { NodeVM: NVM } = require('vm2');
const inner = new NVM({ require: { builtin: ['child_process'] } });
inner.run('require("child_process").execSync("id")');

The inner NodeVM is constructed by sandbox code. Its require config is whatever the sandbox writes. There is no propagation from the outer NodeVM's config; the outer NodeVM's require: false was already discarded once nesting: true installed the override. execSync('id') runs on the host.

The patch is a refusal. The injection is unchanged.

vm2 3.11.1 added one block at the top of the NodeVM constructor:

constructor(options = {}) {
    if (options.nesting === true && options.require === false) {
        throw new VMError(
            'NodeVM `nesting: true` is incompatible with `require: false`. '
            + '`nesting: true` is an escape hatch that lets sandbox code '
            + '`require(\'vm2\')` and construct nested VMs unconstrained '
            + 'by the outer config, which contradicts `require: false`. '
            + 'To deny all requires, remove `nesting: true`. To allow '
            + 'nested VMs, replace `require: false` with an explicit '
            + 'config (e.g. `require: { builtin: [] }`) so the tradeoff '
            + 'is visible. See README "`nesting: true` is an escape hatch". '
            + 'Context: GHSA-8hg8-63c5-gwmx.'
        );
    }
    // ... rest of constructor unchanged
}

The block runs before any of the lines that would build the permissive resolver. NESTING_OVERRIDE is untouched. makeResolverFromLegacyOptions is untouched. The line nesting && NESTING_OVERRIDE is untouched. The branch where !options && !override returns DENY_RESOLVER is untouched. The branch where !options && override builds a permissive resolver containing vm2 is untouched.

The patch closes one configuration. Every other configuration that reaches the permissive-resolver branch reaches it unchanged.

The patch's own test suite confirms which configurations remain open:

it('accepts { nesting: true, require: { builtin: [] } } (explicit empty allowlist)', () => {
    assert.doesNotThrow(() => new NodeVM({ nesting: true, require: { builtin: [] } }));
});

it('accepts { nesting: true } alone (default require, escape-hatch use, documented)', () => {
    assert.doesNotThrow(() => new NodeVM({ nesting: true }));
});

These are regression guards in test/ghsa/GHSA-8hg8-63c5-gwmx/repro.js. The first one is the configuration the patch's own error message recommends as the "explicit config" alternative for callers who want nested VMs. The comment above the test reads: "Legitimate use: nesting enabled, no other host modules. Developer has explicitly acknowledged that vm2 will be requireable."

What the developer acknowledged was that vm2 would be requireable. What the developer did not necessarily acknowledge was that a requireable vm2 plus nesting: true means the sandbox can construct a second NodeVM with require: { builtin: ['child_process'] } and execute arbitrary host commands. That part is in the README.

The maintainer named the actual fix. The maintainer chose not to ship it.

The commit message ends with a section labeled "What this fix does NOT close":

nesting: true with any non-false require config remains an escape hatch. Constraint propagation from outer to inner NodeVM was considered and deferred. It would change documented nesting semantics substantially, a major-version-shaped change.

"Constraint propagation from outer to inner NodeVM" is the actual fix. The outer NodeVM's require config would constrain the inner NodeVM's require config. The sandbox's call to new NodeVM({ require: { builtin: ['child_process'] } }) would either fail or be silently narrowed to the outer's allowlist. With { nesting: true, require: false }, the outer's allowlist is empty, so the third line of the exploit (inner.run('require("child_process").execSync("id")')) would never reach child_process.

The fix was "considered and deferred." The deferral reason is in the same paragraph: it would change nesting: true's documented semantics. The documented semantics are that the inner NodeVM's config is chosen by the sandbox. That documentation is what produces the escape. The fix vm2 considered and deferred is the one that would have made require: false mean what it says.

vm2 3.11.1's README adds a new section titled "nesting: true is an escape hatch." The section contains this example, copied verbatim from the patch:

const vm = new NodeVM({ nesting: true, require: { builtin: [] } });
vm.run(`
  const { NodeVM: NVM } = require('vm2');
  // Inner VM's config is whatever the sandbox writes here:
  const inner = new NVM({ require: { builtin: ['child_process'] } });
  inner.run('require("child_process").execSync("id")');  // RCE
`);

The comment at the end of the code block reads // RCE. The README of vm2 3.11.1, published by the vm2 maintainer, demonstrates an RCE from a configuration the patch explicitly does not close. The next paragraph reads:

If you set nesting: true, you have effectively granted the sandbox the same trust level you have. Do not enable nesting: true for untrusted code.

The README is the patch for every configuration the constructor block does not refuse. The recommendation is: don't.

Eighteen advisories in three months. This is the one where the patch is documentation.

vm2 is the design-debt driver of the JavaScript sandboxing space. The same vm2 maintainer cut four releases between February 17 and May 3, 2026. Across those releases, eighteen GitHub Security Advisories closed.

Tag Date What changed
v3.10.5 2026-02-17 One advisory (CVE-2026-24120) plus a WebAssembly.JSTag block; both in lib/setup-sandbox.js
v3.11.0 2026-05-01 Thirteen advisories; sandbox internals, FileSystem realpath probe, bufferAllocLimit
v3.11.1 2026-05-01 One advisory (this one); one if-statement in lib/nodevm.js plus a new README section
v3.11.2 2026-05-03 Three advisories; sandbox internals, bridge state binding, generator throw capture

3.11.0 and 3.11.1 cut on the same day, forty-two minutes apart. The other seventeen advisories all modify lib/setup-sandbox.js or related runtime paths: proxying additional V8-internal carriers, adding sanitization layers, deleting WebAssembly.JSTag, rewrapping rejection sanitizers. The 3.11.1 patch is the outlier. lib/setup-sandbox.js is unchanged in 3.11.1. The diff touches lib/nodevm.js (one if-statement), README.md (one new section), docs/ATTACKS.md (one new category), CHANGELOG.md, and package.json.

The asymmetry's reason is the same commit-message paragraph: the actual fix is "a major-version-shaped change." vm2's other recent sandbox-escape fixes have all been runtime sanitization edits, tightening what the Proxy bridge does when a host-realm carrier reaches sandbox code. They do not touch the bridge's contract. 3.11.1 touches the contract directly: it adds a refusal to a specific configuration. Constraint propagation, the deeper version of the same fix, would be a bigger contract change, and the maintainer declines to ship it in a patch release.

This is the configuration-trap variant of vm2's unpatchable primitive. The other variants of the same primitive live inside V8's C++ implementations of language features (Promise species, WebAssembly.JSTag, SuppressedError, %AsyncGeneratorPrototype%), and vm2's Proxy bridge cannot trap them. Those variants get sanitization layers, one per V8 feature. This variant lives inside vm2's own configuration semantics. Refactoring those semantics is on the table; the maintainer has chosen documentation instead. The companion post on CVE-2026-24120 walks the species-defense lineage that produces the other class of patch.

require: false is a setting that was advisory.

require: false is documented as the deny-all configuration. The 3.11.1 patch's own error message says, of the contradictory pair, "the deny-all expectation cannot be honored." That phrase, in the patch the maintainer shipped, is the patch's name for what require: false does when nesting: true is also set. Cannot be honored is the maintainer's word for does not work.

This is the setting-was-advisory shape applied to a sandbox library. A flag exposed as a security control runs with the runtime behavior of a hint when a second flag is also set, and the library does not log, warn, or throw. The patch's response is to refuse the specific pair, not to make the control enforced. Every other pair that produces the same outcome ({ nesting: true, require: { builtin: [] } }, { nesting: true } alone, { nesting: true, require: { builtin: ['fs'] } }) is left running, with the README recommending against it.

PoC: seclists.org/oss-sec/2026/q2/412, GHSA-8hg8-63c5-gwmx.

The patch closes one pair. The mechanism the pair reached has not changed.