The archive/CVE-2023-37903
patriksimek/vm2Disclosed 2023

CVE-2023-37903:vm2: Sandbox escape to host RCE per GHSA-g644-9gfx-q4q4

A reconstruction of the flaw, and the evidence path Lachesis follows to make it visible.

Historical record3.9.18 (affected: all versions <= 3.9.19)
WeaknessCWE-78 → CWE-95 · Sandbox escape to host RCE per GHSA-g644-9gfx-q4q4
ImpactA sandbox escape to host rce per ghsa-g644-9gfx-q4q4 in vm2, reachable from attacker-controlled input.
Read the reconstruction
01What happened

A custom util.inspect.custom method on a sandbox object runs code in the host realm -- a vm2 escape.

That is the shape of the failure. The rest of this case file follows the evidence behind it.

Separate the record from the run

What was known.
What Lachesis established.

01Historical record

Known before Lachesis ran

The CVE and vulnerable release were selected from public history. We are not claiming novel discovery.

CVE
CVE-2023-37903
Vulnerable target
3.9.18 (affected: all versions <= 3.9.19) · vulnerable
Prior knowledge
CVE-2023-37903 · 3.9.18 (affected: all versions <= 3.9.19) is vulnerable
then
02Lachesis run
Verified captured run

Independent re-detection

The experiment graph was built around the vm2 library tree at the vulnerable release (npm vm2 3.9.18, in the affected range <= 3.9.19), rather than starting from arbitrary source. We did not point the tool at the CVE. Enumeration ran over the whole candidate registry (31 constructors, every family) with the pass-3 ranker ordering leads; 559 candidates were enumerated and the injection.exec (code-injection) family surfaced on its own alongside the lifecycle, navigation, object-integrity and resource families -- 68 injection.exec.escaping observations, the top-ranked of which is the new Script(...) compilation site. Every field under sast_output is the enumerator's own emission (candidate capsule plus sources_of value-flow cone); everything under adjudication is my reading of the source and the advisory and is labelled as such. Note the honesty caveat below: vm2's whole purpose is executing untrusted code, so the flagged code-exec sink is a design primitive, not the escape.

Seeded inputs
None
Run timestamp
2026-09-02T13:36:02Z
Evidence artifact
vm2_lib.kuzu

The commands and outputs below come from this recorded Lachesis run.

The reconstruction, in one frame

What Lachesis reconstructed.

One historical repository. Four captured queries. A complete source-to-sink argument.

Lachesis Casefiles · Unbound ComputeVerified runCVE-2023-37903
A custom util.inspect.custom method on a sandbox object runs code in the host realm -- a vm2 escape.
01Sourcelet scriptCode = this._compiler(code, unresolvedFilename);
02Sinknew Script(prefix + scriptCode + MODULE_SUFFIX, { __proto__: null, filename: unresolvedFilename, displayErrors: false })
03Missing guard(objects crossing from sandbox to the host inspect/error path are not fully decontextualized before their custom-inspect method can run in the host realm)
$ lachesis sources_of → reaches → guard_dominance
patriksimek/vm2 · 3.9.18 (affected: all versions <= 3.9.19) · vulnerableunboundcompute.com
This was known history. Your code is not.

Lachesis follows the same evidence path through unfamiliar repositories.

The Lachesis reconstruction

Here is how the path becomes visible.

These are the recorded questions Lachesis asked of the historical vulnerable code, followed by the raw result and source location each query returned.

Repositorypatriksimek/vm2
Revision3.9.18 (affected: all versions <= 3.9.19) · vulnerable
EngineLachesis

Captured runThis is a real replay of Lachesis over the graph we built for this case (vm2 3.9.18, lib/ tree). Every command below was run against that graph and every result is captured output. We did not point the tool at a file or a family. The hunt enumerated the whole taxonomy (559 candidates) and the code-injection family came out of that ordering. No payload was seeded; the finding was discovered from graph structure, so the mode is independent-redetection. The honest limitation -- that the anchored sink is the sandbox's design primitive rather than the escape -- is recorded above and adjudicated below.

01 · load_graph01 / 05
Lachesis ran
load_graph --path vm2_lib.kuzu --profile all && candidate_census

Load the graph and list every bug family

First we load the graph and ask the tool to list every bug family it knows, with nothing chosen ahead of time. It reports all 8 domains and 31 constructors and enumerates 559 candidates. injection.exec (code-injection) is one family among many, not a target we selected.

Captured resultCaptured · load_graph
raw captured outputload_graph
[lachesis-mcp] loaded the graph (published 9117 nodes and 30908 edges, typescript-compiler-api frontend)

CANDIDATE_CENSUS
taxonomy (8): lifecycle, memory, injection, navigation, object-integrity, filesystem, crypto-config, resource
constructors (31): complete_for_observable_graph=True
559 candidates enumerated; families_that_fired includes injection.exec.escaping=68
lib/nodevm.jsline 0
Code location returned for this evidence step.
The repair

A small line with a large consequence.

vm2 mediates host globals but fails to neutralize a sandbox object's nodejs.util.inspect.custom method before the host invokes it during error formatting, so the attacker's function executes with host privileges and reaches the host Function constructor.

Before
(objects crossing from sandbox to the host inspect/error path are not fully decontextualized before their custom-inspect method can run in the host realm)
After the patch
// none -- vm2 discontinued, no patch (GHSA-g644-9gfx-q4q4); migrate off vm2.
Sources & further reading

The original record.

Read the historical advisory and vulnerability record behind this reconstruction.

Open the Lachesis quickstart