· 8 min read ·

How V8 Built a Sandbox Inside the Sandbox

Source: v8

Looking back at the V8 Sandbox announcement from April 2024, what stands out is not that Google finally shipped it, but how long the foundation had been quietly accumulating in the codebase. The design document was written almost three years before the feature graduated from experimental status, and the memory model that makes it possible was already partly in place since Chrome 80. That gap between concept and shipping is worth understanding, because it explains what kind of engineering problem this actually is.

Why a Second Sandbox?

Chrome already sandboxes renderer processes at the OS level. The renderer that runs JavaScript in a tab is isolated from the rest of the system: it has no direct access to the filesystem, the network stack, or other processes without going through a privileged broker. This architecture is well-understood and works reasonably well.

The problem is that exploiting a V8 bug does not require escaping the process sandbox. It only requires achieving arbitrary code execution within the renderer process, which is where most of the browser’s attack surface lives anyway. From there, a second-stage exploit can target the process sandbox escape separately. Exploits chaining a V8 memory corruption with a sandbox escape have been the dominant class of Chrome zero-days for years.

The V8 Sandbox does not replace the process sandbox. It sits below it, adding a layer specifically designed to contain what happens when an attacker corrupts V8’s heap. The threat model is precise: assume the attacker can read and write arbitrary memory within the V8 sandbox region. The goal is to prevent that from becoming arbitrary access to memory outside that region.

Pointer Compression Did the Heavy Lifting First

The architectural precondition for the V8 Sandbox was V8’s pointer compression feature, shipped in Chrome 80 in early 2020. Pointer compression changed how V8 represents heap pointers on 64-bit systems. Rather than storing 64-bit absolute addresses, V8 began storing 32-bit offsets relative to a base address for the heap region.

The motivation was memory savings. On a 64-bit system, most heap pointers in typical V8 workloads pointed into a relatively small region, so a 32-bit offset was sufficient. The savings were significant: roughly 40% reduction in heap size for typical web pages.

But pointer compression had a structural side effect that turned out to matter enormously for the sandbox: it made the provenance of every heap pointer explicit. A compressed pointer is not an arbitrary 64-bit value; it is an offset that only makes sense relative to the heap base. The runtime knows exactly where valid heap memory begins and ends.

The V8 Sandbox extends this logic. Instead of a small heap region, V8 reserves a large contiguous virtual address space, typically 1TB on 64-bit systems, as the sandbox boundary. All of V8’s heap, JIT-compiled code, and associated metadata live within this region. Pointers within the sandbox are stored as 32-bit sandbox-relative offsets, exactly as pointer compression worked, but now the boundary is the sandbox rather than just the heap.

The Pointer Tables

The harder problem is what to do with pointers that legitimately reference memory outside the sandbox. V8 objects frequently hold references to external resources: the backing buffers for ArrayBuffer objects, native C++ callbacks registered by the embedder, handles to I/O resources. You cannot simply prohibit these; V8 would stop working.

The V8 Sandbox addresses this with indirection tables. Rather than storing a raw pointer to an external resource inside a sandbox object, V8 stores an index into an External Pointer Table (EPT). The EPT itself lives outside the sandbox, in memory that sandbox code cannot directly corrupt. An entry in the EPT maps an index to an actual address.

// Conceptual representation of an EPT entry
struct ExternalPointerTableEntry {
    uintptr_t pointer : 48;  // actual address
    uint16_t  type_tag : 16; // type discriminator
};

The type tag is the critical part. Each kind of external pointer (ArrayBuffer backing store, native callback, typed array data pointer, and so on) has its own type tag. When V8 resolves an external pointer, it checks that the tag in the EPT entry matches the expected type. An attacker who overwrites an index stored in a sandbox object can redirect the lookup to a different EPT entry, but only to another entry of the same type. They cannot use an index meant for an ArrayBuffer backing store to redirect through an entry meant for a code pointer.

This is not perfect isolation, but it dramatically narrows the attacker’s options. The space of valid external pointers at any given type is small and does not include arbitrary addresses.

Code pointers get similar treatment through a separate Code Pointer Table (CPT). JIT-compiled code objects are referenced through the CPT rather than via direct pointers. This matters because code pages in V8 are marked executable, and an attacker who can redirect a code pointer to a controlled memory region can execute arbitrary code. With the CPT, the attacker can only redirect to other entries in the CPT, which point to actual compiled code objects, not to arbitrary writable memory.

What Attackers Can Still Do

The V8 Sandbox explicitly does not claim to be an impenetrable barrier. The design goals from the V8 team are conservative: the sandbox is intended to prevent V8 memory corruption from trivially translating into arbitrary process memory access. It does not claim to prevent all exploitation.

A few important limitations are worth understanding.

First, the sandbox shares the process address space with the rest of the renderer. On a 64-bit system with a 1TB sandbox region, the sandbox occupies a contiguous block, but other memory including the process heap, stack, and renderer code sits in the same address space. An attacker who finds a bug that produces an absolute 64-bit address rather than a sandbox-relative offset could still access arbitrary process memory. The sandbox mitigates the common case where bugs produce values interpretable as sandbox offsets.

Second, the sandbox does not protect against logical bugs. A use-after-free that corrupts type information within the sandbox might still be exploitable via the type confusion it creates, depending on what operations the confused type enables. The goal is to keep such bugs confined to the sandbox’s address range, but the damage achievable within that range is still substantial in many cases.

Third, the EPT and CPT are only as good as their implementation. If V8 stores an index in a location the attacker can overwrite with an arbitrary value, and if the attacker can find a same-type EPT entry that still provides useful redirection, exploitation remains feasible. These are harder requirements to satisfy than a plain memory corruption, but not impossible.

The VRP Scope Change

One concrete signal of Google’s confidence in the sandbox is the change to the Chrome Vulnerability Reward Program that accompanied the announcement. V8 bugs demonstrably confined within the sandbox now receive a lower severity rating than bugs that escape it. This is a meaningful policy shift: it acknowledges that the sandbox provides genuine containment while incentivizing researchers to find the more serious class of bugs that cross the sandbox boundary.

For the security research community, this also creates a cleaner taxonomy. The question is no longer just “is this V8 memory corruption exploitable?” but “does this bug escape the sandbox?” A bug achieving arbitrary read/write within the 1TB sandbox region is treated differently from one achieving arbitrary read/write in process memory. That distinction is new, and it has real consequences for how V8 vulnerabilities get triaged and prioritized.

Three Years From Document to Stable

The note in the announcement that the initial design document dates to roughly mid-2021 is a useful reminder of how long serious security infrastructure takes to ship correctly. The V8 Sandbox required changes throughout the engine: the garbage collector needed to understand sandbox-relative pointers, the EPT required a complete lifecycle management system, the JIT compiler needed to emit code that respected sandbox boundaries, and embedder APIs needed updates to interact correctly with the pointer tables.

Looking at the V8 changelog over those three years, the sandbox work lands in hundreds of incremental commits, many of them unglamorous: updating a specific object type to use an EPT handle instead of a raw pointer, fixing a GC issue with pointer table liveness, adjusting sandbox size handling on different platforms. The design is interesting; the execution is meticulous.

The pointer compression precedent is instructive here too. That feature shipped for performance reasons and turned out to provide structural benefits for the sandbox. The V8 team was able to extend an existing constraint (pointers are offsets, not absolutes) rather than introducing an entirely new memory model. Good security properties sometimes come for free from constraints introduced for other reasons, and that is a pattern worth recognizing.

What This Means for Embedders

For anyone writing software that embeds V8, the sandbox changes what embedder code needs to think about. The V8 embedder guide documents the interfaces through which host applications interact with the engine. With the sandbox active, callbacks registered with V8, external backing stores, and other resources that cross the sandbox boundary now participate in the pointer table infrastructure.

That is more complexity for embedders, but it is complexity that exists because the boundary is real and maintained on both sides. If you are building on top of V8 via Node.js or a custom embedder, the practical impact is mostly hidden behind V8’s API. The bigger implication is that assumptions about raw pointer access to V8 internals, never a good idea, are now structurally broken rather than merely discouraged.

Defense in Depth at the Runtime Level

The V8 Sandbox represents a specific philosophy: instead of trying to write V8 with zero memory safety bugs, accept that bugs will occur and architect the runtime so that bugs cannot easily escape a bounded region. This is defense in depth applied at the language runtime level, below the process level.

WebAssembly’s linear memory model achieves something structurally similar at the application level. Wasm code can only access memory within its own linear memory region by construction, which is why Wasm memory isolation is considered stronger than what V8 provides for JavaScript. The JVM and CLR have offered containment properties for managed code for decades, though their JIT compilers have had type confusion bugs too.

The V8 Sandbox’s particular challenge is applying this boundary inside a runtime that also runs native JIT code and integrates deeply with a C++ host. That integration is exactly what makes the EPT and CPT necessary: JavaScript needs to reach outside the sandbox to be useful, and the tables are the controlled interface through which that reach is permitted. Building a controlled bridge is harder than building no bridge at all, and that is most of what the three years of development were spent on.

Was this interesting?