-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"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.\n\n## `nesting: true` injects vm2. `require: false` is decided after the injection.\n\nThe bug is two lines of code in two files, separated by a flag.\n\n`lib/nodevm.js` defines the override object that `nesting: true` installs:\n\n```javascript\nconst NESTING_OVERRIDE = Object.freeze({\n __proto__: null,\n vm2: vm2NestingLoader\n});\n```\n\nAnd then, deeper in the same file, the NodeVM constructor hands that override to the resolver factory:\n\n```javascript\nconst resolver = customResolver\n ? requireOpts\n : makeResolverFromLegacyOptions(\n requireOpts,\n nesting && NESTING_OVERRIDE,\n this._compiler\n );\n```\n\n`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.\n\n`lib/resolver-compat.js` decides what to do with the two arguments:\n\n```javascript\nfunction makeResolverFromLegacyOptions(options, override, compiler) {\n if (!options) {\n if (!override) return DENY_RESOLVER;\n const builtins = makeBuiltinsFromLegacyOptions(\n undefined, defaultRequire, undefined, override\n );\n return new Resolver(DEFAULT_FS, [], builtins);\n }\n // ... normal path\n}\n```\n\n`require: false` is falsy, so the function enters `if (!options)`. From there, two branches:\n\n1. 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.\n2. 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.\n\n`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.\n\nThe 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.\n\nThe exploit walks out of that in three lines. From inside `vm.run(...)`:\n\n```javascript\nconst { NodeVM: NVM } = require('vm2');\nconst inner = new NVM({ require: { builtin: ['child_process'] } });\ninner.run('require(\"child_process\").execSync(\"id\")');\n```\n\nThe 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.\n\n## The patch is a refusal. The injection is unchanged.\n\nvm2 3.11.1 added one block at the top of the NodeVM constructor:\n\n```javascript\nconstructor(options = {}) {\n if (options.nesting === true && options.require === false) {\n throw new VMError(\n 'NodeVM `nesting: true` is incompatible with `require: false`. '\n + '`nesting: true` is an escape hatch that lets sandbox code '\n + '`require(\\'vm2\\')` and construct nested VMs unconstrained '\n + 'by the outer config, which contradicts `require: false`. '\n + 'To deny all requires, remove `nesting: true`. To allow '\n + 'nested VMs, replace `require: false` with an explicit '\n + 'config (e.g. `require: { builtin: [] }`) so the tradeoff '\n + 'is visible. See README \"`nesting: true` is an escape hatch\". '\n + 'Context: GHSA-8hg8-63c5-gwmx.'\n );\n }\n // ... rest of constructor unchanged\n}\n```\n\nThe 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.\n\nThe patch closes one configuration. Every other configuration that reaches the permissive-resolver branch reaches it unchanged.\n\nThe patch's own test suite confirms which configurations remain open:\n\n```javascript\nit('accepts { nesting: true, require: { builtin: [] } } (explicit empty allowlist)', () => {\n assert.doesNotThrow(() => new NodeVM({ nesting: true, require: { builtin: [] } }));\n});\n\nit('accepts { nesting: true } alone (default require, escape-hatch use, documented)', () => {\n assert.doesNotThrow(() => new NodeVM({ nesting: true }));\n});\n```\n\nThese 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.\"\n\nWhat 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.\n\n## The maintainer named the actual fix. The maintainer chose not to ship it.\n\nThe commit message ends with a section labeled \"What this fix does NOT close\":\n\n> `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.\n\n\"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`.\n\nThe 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.\n\nvm2 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:\n\n```js\nconst vm = new NodeVM({ nesting: true, require: { builtin: [] } });\nvm.run(`\n const { NodeVM: NVM } = require('vm2');\n // Inner VM's config is whatever the sandbox writes here:\n const inner = new NVM({ require: { builtin: ['child_process'] } });\n inner.run('require(\"child_process\").execSync(\"id\")'); // RCE\n`);\n```\n\nThe 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:\n\n> 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.**\n\nThe README is the patch for every configuration the constructor block does not refuse. The recommendation is: don't.\n\n## Eighteen advisories in three months. This is the one where the patch is documentation.\n\nvm2 is the [design-debt driver](/patterns/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.\n\n| Tag | Date | What changed |\n|-----|------|--------------|\n| v3.10.5 | 2026-02-17 | One advisory (CVE-2026-24120) plus a `WebAssembly.JSTag` block; both in `lib/setup-sandbox.js` |\n| v3.11.0 | 2026-05-01 | Thirteen advisories; sandbox internals, `FileSystem` realpath probe, `bufferAllocLimit` |\n| v3.11.1 | 2026-05-01 | One advisory (this one); one if-statement in `lib/nodevm.js` plus a new README section |\n| v3.11.2 | 2026-05-03 | Three advisories; sandbox internals, bridge state binding, generator throw capture |\n\n3.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`.\n\nThe 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.\n\nThis is the configuration-trap variant of vm2's [unpatchable primitive](/posts/dotnetnuke-cve-2017-9822-deserializer-still-public). 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](/posts/vm2-cve-2026-24120-readme-already-admits) on CVE-2026-24120 walks the species-defense lineage that produces the other class of patch.\n\n## `require: false` is a setting that was advisory.\n\n`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.\n\nThis is the [setting-was-advisory](/posts/sglang-trust-remote-code-was-a-suggestion) 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.\n\nPoC: [seclists.org/oss-sec/2026/q2/412](https://seclists.org/oss-sec/2026/q2/412), [GHSA-8hg8-63c5-gwmx](https://github.com/advisories/GHSA-8hg8-63c5-gwmx).","closing_line":"The patch closes one pair. The mechanism the pair reached has not changed.","hook_md":"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.","post_id":331,"slug":"vm2-cve-2026-44007-readme-is-the-patch-for-the-rest","title":"CVE-2026-44007: vm2 3.11.1 Rejects One Configuration. The README Is the Patch for the Rest.","type":"initial","unreadable_sentence":"The fix vm2 considered and deferred is the one that would have made `require: false` mean what it says."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCaihfvwAKCRDeZjl4jgkQ JqOeAQCXpHPnYf+9gfcpf5w13hwzcyIfICvakqR0Xi+vjf1R5AEA/cY+KCq3mfdz 3A7FgB37C5I0i2x4/CxwpOCMyJQCTwk= =H4l8 -----END PGP SIGNATURE-----