TL;DR
We discovered a critical vulnerability (GHSA-864f-rcv7-6rh4; pending CVE assignment) in isolated-vm, a widely used library for running untrusted JavaScript inside a V8 Isolate.
A type confusion in ExternalCopy's handling of the transferList option lets code running inside the sandbox corrupt memory in the host process. Starting from nothing but a single ivm.Reference, the standard way hosts hand a sandbox any capability at all, we escalated the bug from a controlled-address crash all the way to hijacking the host's control flow, demonstrating a full guest-to-host sandbox escape.
V8 is the JavaScript engine behind Chrome and Node.js, and an Isolate is its unit of separation: an instance with its own heap and its own copy of the built-ins, sharing no object graph with any other Isolate. isolated-vm is the Node.js package that gives each sandbox one of its own, which is why untrusted code running inside cannot touch anything in the host process unless the embedder deliberately passes it across. That boundary is what a sandbox escape has to defeat.
The most important takeaway is that what was not broken was the isolation primitive itself. V8's Isolate boundary held. What failed was the C++ glue code that marshals values across that boundary. A perfectly sound building block was undermined by the binding layer wrapped around it.
Affected versions
Introduction
Running untrusted JavaScript safely is one of the hardest problems in the Node.js ecosystem, and its history is littered with failures. vm2, for years the default answer, accumulated more than twenty documented breakouts before being deprecated. We wrote about one of the most recent ones. The reason vm2 kept failing is architectural: it tried to build a security boundary inside a single V8 context using JavaScript-level tricks (proxies, prototype scrubbing), and untrusted code shares the same heap, the same prototypes, and the same Function constructor as the sandbox itself. Every escape was a variation on reaching back across a boundary that was never really there.
isolated-vm takes a fundamentally stronger approach. Instead of partitioning one context, it gives each sandbox its own V8 Isolate, i.e., a separate heap, a separate set of built-ins, and no shared object graph with the host. This is the same primitive Chrome uses to separate tabs. Guest code gets no require, no host globals, and no references to host objects unless the embedder explicitly hands them over. That is a real, OS-and-VM-enforced boundary, and it is why isolated-vm is trusted to run genuinely adversarial code.
That trust is well-earned, and that is precisely why this finding is interesting. We did not break the V8 Isolate. We broke the code that carries data into it.
isolated-vm is the sandbox of record for a wide range of production systems. The project's own documentation lists Screeps (an MMO that runs player-supplied code), Fly.io (edge compute), Algolia (its Custom Crawler), and TripAdvisor (server-side rendering).
Its role has only grown with the rise of AI agents and automation platforms, where the core requirement is executing model- or user-generated code safely:
- n8n (200k GitHub stars), the popular workflow-automation platform, runs untrusted Code-node scripts in sandboxed task runners and recommends isolated-vm for that isolation.
- Activepieces (23k GitHub stars) uses isolated-vm to sandbox the JavaScript in its automation "pieces", restricting them to browser-like semantics with no Node.js APIs.
- Mastra (27k GitHub stars), an AI agent framework, uses isolated-vm in its "code mode" to run model-generated tool-orchestration code inside a real V8 isolate.
- Budibase (28k GitHub stars), the open-source low-code platform for internal tools and workflow automation, migrated its entire JavaScript engine off the deprecated vm2 to isolated-vm in v2.20.0. User-authored JS in app bindings, automation steps, and formulas all run inside a V8 isolate under memory and CPU-time limits.
- Sim.ai (29k GitHub stars), an open-source platform to build, deploy, and monitor AI agents and workflows (self-described as used by 100,000+ builders), runs the code in its Function/Code workflow blocks through isolated-vm to sandbox user- and model-generated JavaScript and block SSRF and host access.
- Directus (37k GitHub stars), open-source data platform / headless CMS. In v10.6.0, it replaced the deprecated vm2 with isolated-vm to sandbox the "Run Script" operation in Flows (its automation feature). The isolate is deliberately locked down to input/output value sharing only — no filesystem, no network.
- Rocket.Chat (46k GitHub stars) adopted isolated-vm to execute integration scripts.
For each of these, isolated-VM's guest/host boundary is a load-bearing security control. A guest-to-host escape is a worst-case outcome.
Background: crossing the isolate boundary
Because a V8 Isolate shares no objects with the host, moving data in or out cannot be done by passing a reference; i.e., the value has to be copied across. isolated-vm exposes this as ExternalCopy: it serializes a value in one isolate and reconstructs it in another, using V8's structured-clone machinery (ValueSerializer).
ExternalCopy also supports a performance optimization borrowed from the postMessage API: a transferList. Instead of copying large ArrayBuffers byte for byte, you list them in transferList, and their underlying memory is transferred, i.e., the buffer is detached from the source and handed to the destination with no copy. It is exactly the kind of low-level, pointer-shuffling operation where a marshaling layer must be careful, and where the bug lives.

The Vulnerability
When ExternalCopy serializes an object with a transferList, the constructor of ExternalCopySerialized (src/external_copy/serializer.cc) iterates over that list twice.
The first walk validates every element and registers it with the serializer:
// walk 1 - validates
for (auto handle : transfer_list) {
if (handle->IsArrayBuffer()) {
serializer.TransferArrayBuffer(ii++, handle.As<ArrayBuffer>());
} else {
throw RuntimeTypeError("Non-ArrayBuffer passed in `transferList`");
}
}The second walk actually transfers each element, and here it does not re-validate:
// walk 2 - no type check
for (auto handle : transfer_list {
array_buffers.emplace_back(ExternalCopyArrayBuffer::Transfer(handle.As<ArrayBuffer>()));
// <-- unchecked cast
}As <ArrayBuffer>() is not a checked conversion. It is a bare reinterpret-cast that tells V8, "trust me, this is an ArrayBuffer." The code assumes it is safe because walk 1 already checked, but that assumption only holds if the two walks see the same values.
They don't have to. transfer_list is a JavaScript array, and iterating it does not read a snapshot. Every element access goes through array->Get(context, index) (src/isolate/generic/array.h:42), a real property read that invokes JavaScript accessors. So an element defined as a getter is called once per walk and can return a different value each time.
That is a textbook time-of-check/time-of-use (TOCTOU) gap. The attacker registers a stateful getter that hands a genuine ArrayBuffer to the validating walk and something else to the unchecked walk:
let reads = 0;
Object.defineProperty(transferList, 0, {
enumerable: true, get() { return ++reads === 1 ? real : 0x41414141; },
// valid on read #1, confused on read #2
}
);Walk 1 sees real, passes IsArrayBuffer(), and proceeds. Walk 2 receives 0x41414141, casts it to ArrayBuffer*, and ExternalCopyArrayBuffer::Transfer (src/external_copy/external_copy.cc:397) immediately dereferences it via IsDetachable() and GetBackingStore(). The result is a dereference of an attacker-influenced pointer.
Reachability from inside the sandbox. transferList is accepted only by the ExternalCopy constructor, which appears to be accessible only on the host side. But the guest does not need the entire ivm module; it only needs a single ivm.Reference: the ordinary mechanism a host uses to expose anything to a sandbox. The externalCopy transfer option pulls the ExternalCopy constructor across the boundary as a live, callable class:
const ExternalCopy = ref.getSync('anyKey', { externalCopy: true }).constructor;From there, the guest builds the malicious transferList and triggers the bug entirely from inside the isolate.

Proof of Concept
The following is a complete, self-contained crash. The host shares exactly one Reference and nothing else; every other line runs inside the sandbox:
const ivm = require('isolated-vm');
const isolate = new ivm.Isolate();
const context = isolate.createContextSync();
context.global.setSync('ref', new ivm.Reference({ x: 1 }));
context.evalSync(`
const ExternalCopy = ref.getSync('x', { externalCopy: true }).constructor;
const real = new ArrayBuffer(8);
let reads = 0;
const transferList = [];
Object.defineProperty(transferList, 0, {
enumerable: true,
get() { return ++reads === 1 ? real : 0x41414141; },
});
new ExternalCopy({}, { transferList });
`);
Running this against isolated-vm ≤ 7.0.0 crashes the host process with SIGSEGV, faulting inside v8::ArrayBuffer::IsDetachable at address 0x4141414100000047: the getter's integer, tagged as a V8 small integer. The fault address is derived from attacker input, not an incidental null dereference, which is the signature of a controlled memory-safety bug rather than a mere robustness issue.
From crash to control-flow hijack. A controlled-address crash is already a guest-triggered denial-of-service attack against the host. We took it further. Because the confused object can be a JavaScript string, an attacker controls not just that a bad pointer is dereferenced but which bytes are read as the object's fields. Transfer's destruction path ends in an indirect call through a vtable pointer read from that memory, a classic control-flow-hijack primitive.
Working entirely from inside the sandbox, with only the single Reference endowment and no debugger, we built a proof of concept that (1) recovers the host's ASLR base from a pointer leaked through ordinary buffer operations, (2) forges a fake control block and vtable in heap memory it sprays, and (3) drives the indirect call to an address of its choosing, demonstrated by making the host process invoke a chosen libc function. We are withholding the full exploit and shared it privately with the maintainer; the point of this write-up is that the primitive is strong enough to redirect host execution, not merely to crash it.
Impact
This is a guest-to-host sandbox escape. The Isolate boundary which is isolated-vm's entire reason for existing can be crossed by untrusted code, given only the near-universal precondition that the host has shared one Reference into the sandbox.
- Minimum demonstrated impact: a reliable, attacker-controlled crash of the host process, a denial of service triggerable by any guest.
- Maximum demonstrated impact: hijacking the host's control flow, i.e., a path toward remote code execution in the host, outside the sandbox.
Any embedder running untrusted or semi-trusted code, e.g., AI agents executing model-generated snippets, automation platforms running user workflows, multi-tenant script runners, and sharing even one Reference into the isolate is affected. Host code that forwards a caller-influenced array as transferList is affected directly, without any guest at all.
The Fix
The maintainer responded quickly and shipped a fix in versions 7.0.1 and 6.2.0. The patch wraps ExternalCopy::Copy in a v8::Isolate::DisallowJavascriptExecutionScope, which prevents any user JavaScript (getters, proxies, interceptors) from running during the copy. That is the shared prerequisite for the type confusion (the transferList getter). Users on any affected version should upgrade immediately.
The lesson here is different from the usual sandbox-escape story, and it is worth stating plainly. Unlike sandboxes like vm2, isolated-vm did not choose a fragile isolation model. Its foundation, the V8 Isolate, is exactly the right primitive, and it did its job. Nothing in this attack broke V8's memory isolation between contexts.
The vulnerability lived in the native glue code: the C++ binding that serializes values across the boundary. That layer is written in a memory-unsafe language; it manipulates raw V8 handles and backing-store pointers, and it re-reads attacker-controlled JavaScript objects in the middle of a security-sensitive operation. A single unchecked cast on a re-read value was enough to turn a correct isolation primitive into a full escape.
This is a general and under-appreciated risk. When a safe building block is wrapped in native binding code, the security of the whole system is reduced to that of the binding. Our prior academic research has repeatedly found the boundary layer, not the core primitive, to be where sandboxes actually break:
- In SandDriller: A Fully-Automated Approach for Testing Language-Based JavaScript Sandboxes (USENIX Security 2023), we showed that JavaScript sandboxes are systematically undermined by the seams where trusted and untrusted worlds meet, and we built automated tooling to find those seams.
- In Bilingual Problems: Studying the Security Risks Incurred by Native Extensions in Scripting Languages (USENIX Security 2023), we studied how native extensions to scripting languages import C/C++ memory unsafety into otherwise memory-safe ecosystems, exactly the failure mode on display here.
isolated-vm is a well-engineered library and remains the best available option for its job. But "the isolation primitive is sound" is not the same as "the library is safe," because there is always glue code between your untrusted input and that primitive, and that glue code is where you should look.
Mitigations and Takeaways
1. Upgrade now. Move to isolated-vm 7.0.1 (or 6.2.0 on the 6.x line). The fix is small and directly closes both the type confusion and the timeout bypass.
2. Treat every capability you share into a sandbox as an attack surface. A single Reference was enough to reach the vulnerable constructor. Share the absolute minimum, and assume anything you expose can be turned back on you.
3. Scrutinize the glue, not just the boundary. A correct isolation model does not make a library safe on its own. The marshaling layer, especially when it is native code handling attacker-controlled objects, deserves the same scrutiny as the primitive it wraps.
4. Know your dependencies' real security posture with reachability. Whether this bug is exploitable for you depends on whether untrusted code reaches ExternalCopy with attacker-influenced input. Software composition analysis with reachability lets you answer that instead of guessing.
Conclusion
isolated-vm did almost everything right: it chose the strongest available isolation primitive and used it correctly. And yet a guest could still escape, not by defeating V8, but by exploiting the native code that ferries data across V8's boundary. That gap between "the primitive is sound" and "the system is safe" is where modern sandbox escapes increasingly live, and it is exactly the gap our research has been mapping. As AI agents and automation platforms make untrusted-code execution a mainstream requirement, the binding layer around your sandbox deserves first-class security attention.
We thank Marcel Laverdet for the prompt, professional response and the quick fix.
What's next?
When you're ready to take the next step in securing your software supply chain, here are 3 ways Endor Labs can help:









