The V8 Sandbox: Pointer Compression Without Compressed Pointers

V8 reserves a terabyte of randomised address space, puts the heap at its base, and represents pointers as offsets so that a corrupted object cannot reach outside it.

TL;DR: The V8 sandbox reserves a terabyte of virtual address space at a random, 4 GB-aligned address, puts the pointer-compression cage at its base, and surrounds it with a 64 GB guard region in front and 288 GB behind. Inside the sandbox, pointers to other sandboxed objects are stored as offsets from the sandbox base, shifted into the top bits of a 64-bit word, so a corrupted object cannot name an address outside the sandbox. Pointers to things outside it - ArrayBuffer backing stores, embedder callbacks, code - are replaced by indices into a per-Isolate external pointer table whose entries carry a 7-bit type tag, so reading a handle of the wrong type yields an un-dereferenceable pointer rather than a wild one.

Every heap allocator eventually hits the same wall: a use-after-free gives the attacker an arbitrary read and write primitive, and from there the entire process is fair game. The sandbox is V8’s answer to that, and it is a different kind of answer from “use-after-free is hard” or “the allocator randomises”.

The sandbox says: assume the attacker can corrupt anything inside the sandbox, arbitrarily, from any thread. Then make sure that corrupting the sandbox cannot corrupt anything outside it.

The address space

The shape of the sandbox is drawn in the header:

cpp
// src/sandbox/sandbox.h:51
  // +-  ~~~  -+----------------------------------------  ~~~  -+-  ~~~  -+
  // |  32 GB  |                 (Ideally) 1 TB                 |  32 GB  |
  // |         |                                                |         |
  // | Guard   |      4 GB      :  ArrayBuffer backing stores,  | Guard   |
  // | Region  |    V8 Heap     :  WASM memory buffers, and     | Region  |
  // | (front) |     Region     :  any other sandboxed objects. | (back)  |
  // +-  ~~~  -+----------------+-----------------------  ~~~  -+-  ~~~  -+
  //           ^                                                ^
  //           base                                             end
  //           < - - - - - - - - - - - size - - - - - - - - - - >
  // < - - - - - - - - - - - - - reservation_size - - - - - - - - - - - - >

The size is a compile-time constant, and it varies by platform because the available virtual address space varies:

cpp
// include/v8-internal.h:248
// Everywhere else use a 1TB sandbox.
constexpr size_t kSandboxSizeLog2 = 40;  // 1 TB
#endif  // V8_TARGET_OS_ANDROID
constexpr size_t kSandboxSize = 1ULL << kSandboxSizeLog2;

The else branch of that #if chain is the 1 TB case. Android, ARM64 ChromeOS and RISC-V get 128 GB, because those platforms typically only have 39 bits of userspace address space; iOS gets 16 GB, because the “jumbo” extended virtual addressing entitlement only buys 64 GB in total.

The alignment is not arbitrary - it is the alignment of the pointer compression cage, because the cage lives at the start of the sandbox:

cpp
// include/v8-internal.h:258
constexpr size_t kSandboxAlignment = kPtrComprCageBaseAlignment;

and kPtrComprCageBaseAlignment is 4 GB. That is also why the pointer compression cage and the sandbox are the same object as far as V8 is concerned: the cage is simply the first 4 GB of the sandbox.

The base address is random

Reserving a terabyte is cheap; where you reserve it is what matters. If the sandbox always lived at the same address, an attacker could hard-code addresses and defeat the whole scheme. So the base is randomised:

cpp
// src/sandbox/sandbox.cc:463
  Address hint = RoundDown(vas->RandomPageAddress(), kSandboxAlignment);

  // There should be no executable pages mapped inside the sandbox since
  // those could be corrupted by an attacker and therefore pose a security
  // risk. Furthermore, allowing executable mappings in the sandbox requires
  // MAP_JIT on macOS, which causes fork() to become excessively slow
  // (multiple seconds or even minutes for a 1TB sandbox on macOS 12.X), in
  // turn causing tests to time out. As such, the maximum page permission
  // inside the sandbox should be read + write.
  const PagePermissions kSandboxMaxPermissions = PagePermissions::kReadWrite;

Two things to notice. The hint is rounded down to the 4 GB sandbox alignment, which means the low 32 bits of the base are always zero - a useful property, because a compressed pointer only has 32 bits of offset and the cage base is implied. And the sandbox subspace is mapped read+write only: V8’s design intent is that no executable page is ever mapped inside the sandbox, because if an attacker could put a ROP gadget in there, they could jump to it.

When the platform cannot reserve a full terabyte, V8 falls back to a partially reserved sandbox, and randomises the base itself:

cpp
// src/sandbox/sandbox.cc:549
    Address hint = rng.NextInt64() % highest_allowed_address;
    hint = RoundDown(hint, kSandboxAlignment);

A partially reserved sandbox is a security downgrade, and V8 says so out loud - --sandbox-prohibit-insecure-mode refuses to start in that configuration.

The guard regions

The interesting numbers are the guard regions. They are not there to catch ordinary bugs; they are there to catch the specific arithmetic an attacker can perform on a corrupted TypedArray:

cpp
// include/v8-internal.h:286
constexpr size_t kMaxSafeBufferSizeForSandbox = 32ULL * GB - 1;

constexpr size_t kBoundedSizeShift = 29;
static_assert(1ULL << (64 - kBoundedSizeShift) ==
                  kMaxSafeBufferSizeForSandbox + 1,
              "The maximum size of a BoundedSize must be synchronized with the "
              "kMaxSafeBufferSizeForSandbox");
constexpr size_t kBoundedSizeMask = (1ULL << (64 - kBoundedSizeShift)) - 1;

// Size of the guard regions surrounding the sandbox. This assumes a worst-case
// scenario of a 32-bit unsigned index used to access an array of 64-bit values
// with an additional 32GB (bounded size) offset. In particular, accesses to
// TypedArrays are effectively computed as
// `entry_pointer = array->base + array->offset + index * array->element_size`.
// See also https://crbug.com/40070746 for more details.
constexpr size_t kSandboxGuardRegionSize =
    32ULL * GB + (kMaxSafeBufferSizeForSandbox + 1);

So: a TypedArray can be at most 32 GB long, and its index is a 32-bit unsigned integer, and the element size can be 8 bytes. The worst case index * element_size is 32 GB × 8 = 256 GB, plus the 32 GB bounded-size offset, plus a 32 GB array. The front and back guard regions are 64 GB each, and on most platforms there is an additional 224 GB trailing region to catch the 8× overflow:

cpp
// include/v8-internal.h:324
constexpr size_t kAdditionalTrailingGuardRegionSize =
    288ULL * GB - kSandboxGuardRegionSize;

This is the kind of detail that makes the sandbox work rather than merely exist. A sandbox with a 64 KB guard region would be defeated by any arithmetic overflow in a typed-array access.

The very last page of the sandbox is also poisoned, so that the “empty backing store” pointer - which is one past the end of the sandbox - traps:

cpp
// src/sandbox/sandbox.cc:625
void Sandbox::InitializeConstants() {
  // Place the empty backing store buffer at the end of the sandbox, so that any
  // accidental access to it will most likely hit a guard page.
  constants_.set_empty_backing_store_buffer(end_ - 1);
}

Sandboxed pointers: offsets, not addresses

Here is where the sandbox earns its name. V8 has two kinds of pointer:

  • A regular heap pointer, which is a compressed 32-bit offset into the pointer compression cage. The cage is 4 GB and sits at the sandbox base.
  • A sandboxed pointer, which points at something inside the sandbox but outside the cage - an ArrayBuffer backing store, a WASM memory - and is stored as an offset from the sandbox base.

The encoding is a shift, and the shift amount is derived from the sandbox size.

For the 1 TB sandbox that is a shift of 24: the 40-bit offset is stored in the high half of the 64-bit word, and the low 24 bits are always zero. The read and write are:

cpp
// src/sandbox/sandboxed-pointer-inl.h:18
V8_INLINE Address ReadSandboxedPointerField(Address field_address,
                                            PtrComprCageBase cage_base) {
#ifdef V8_ENABLE_SANDBOX
  SandboxedPointer_t sandboxed_pointer =
      base::ReadUnalignedValue<SandboxedPointer_t>(field_address);

  Address offset = sandboxed_pointer >> kSandboxedPointerShift;
  Address pointer = cage_base.address() + offset;
  return pointer;
#else
  return ReadMaybeUnalignedValue<Address>(field_address);
#endif
}

and on the write side there is a check that the pointer really is inside the sandbox:

cpp
// src/sandbox/sandboxed-pointer-inl.h:32
V8_INLINE void WriteSandboxedPointerField(Address field_address,
                                          PtrComprCageBase cage_base,
                                          Address pointer) {
#ifdef V8_ENABLE_SANDBOX
  // The pointer must point into the sandbox.
  CHECK(Sandbox::current()->Contains(pointer));

  Address offset = pointer - cage_base.address();
  SandboxedPointer_t sandboxed_pointer = offset << kSandboxedPointerShift;
  base::WriteUnalignedValue<SandboxedPointer_t>(field_address,
                                                sandboxed_pointer);
#else
  WriteMaybeUnalignedValue<Address>(field_address, pointer);
#endif
}

The asymmetry is the whole security property. On read, a corrupted offset is simply added to the sandbox base, so the result is either inside the sandbox or in a guard region - it can never name an arbitrary address. On write, the check makes sure V8 itself never stores something that does not belong.

What the shift buys is a guarantee about the decoded value. The header says it plainly:

cpp
// include/v8-internal.h:260
// Sandboxed pointers are stored inside the heap as offset from the sandbox
// base shifted to the left. This way, it is guaranteed that the offset is
// smaller than the sandbox size after shifting it to the right again. This
// constant specifies the shift amount.
constexpr uint64_t kSandboxedPointerShift = 64 - kSandboxSizeLog2;

Because the offset is smaller than the sandbox size, the shifted-left value can never set the top bits of the word, and shifting it right again always yields an offset strictly inside the sandbox. A corrupted word therefore decodes to a sandbox address or to a guard-region address - never to an arbitrary one.

External pointers: a tagged table for everything outside

Anything outside the sandbox - the embedder’s callback pointers, an ArrayBuffer’s backing store, a WASM memory, a JIT entrypoint - cannot be stored as a raw pointer inside a sandboxed object. Instead V8 keeps a per-Isolate table and stores an index.

The table’s design is described in its own header:

cpp
// src/sandbox/external-pointer-table.h:201
 * A table's role for the V8 Sandbox:
 * --------------------------------
 * An external pointer table provides the basic mechanisms to ensure
 * memory-safe access to objects located outside the sandbox, but referenced
 * from within it. When an external pointer table is used, objects located
 * inside the sandbox reference outside objects through indices into the table.
 *
 * Type safety can be ensured by using type-specific tags for the external
 * pointers. These tags will be ORed into the unused top bits of the pointer
 * when storing them and will be ANDed away when loading the pointer later
 * again. If a pointer of the wrong type is accessed, some of the top bits will
 * remain in place, rendering the pointer inaccessible.

The type tag is the clever part. There are 7 bits of tag, stored in the top of the 64-bit entry:

cpp
// include/v8-internal.h:384
constexpr uint64_t kExternalPointerMarkBit = 1ULL << 48;
constexpr uint64_t kExternalPointerTagShift = 49;
constexpr uint64_t kExternalPointerTagMask = 0x00fe000000000000ULL;
constexpr uint64_t kExternalPointerTagAndMarkbitMask = 0x00ff000000000000ULL;
constexpr uint64_t kExternalPointerPayloadMask = 0xff00ffffffffffffULL;

(On x86-64 with LA57 - a 57-bit address space - the same constants live nine bits higher, at bit 56 and above.)

An entry is read with a tag range, and the tag is checked against the expected range. If it does not match, the tag bits stay in the pointer and the resulting address is un-mapped:

cpp
// src/sandbox/tagged-payload.h:60
  Address UntagDisallowNullHandle(TagRangeType tag_range) const {
    Address content = encoded_word_;
    SBXCHECK(CheckTag(content, tag_range));
    return (content & kPayloadMask) >> kPayloadShift;
  }

So an attacker who swaps an ArrayBuffer’s external pointer handle for a function-callback handle does not get a callable function pointer - they get a pointer with garbage in the top bits, which faults on dereference.

The same table provides temporal safety. Every entry has a marking bit; the GC marks entries that are still reachable and sweeps the rest, so an entry never refers to a freed object. And there is a third mechanism for objects whose lifetime is tied to the table entry:

cpp
// src/sandbox/external-pointer-table.h:391
  // A resource outside of the V8 heap whose lifetime is tied to something
  // inside the V8 heap. This class makes that relationship explicit.
  //
  // Knowing about such objects is important for the sandbox to guarantee
  // memory safety. In particular, it is necessary to prevent issues where the
  // external resource is destroyed before the entry in the
  // ExternalPointerTable (EPT) that references it is freed. In that case, the
  // EPT entry would then contain a dangling pointer which could be abused by
  // an attacker to cause a use-after-free outside of the sandbox.

An ArrayBuffer’s backing store is exactly such a resource, and the ArrayBufferExtension is registered as one.

The ArrayBuffer’s own extension pointer shows the whole pattern:

cpp
// src/objects/js-array-buffer-inl.h:138
void JSArrayBuffer::set_extension(ArrayBufferExtension* extension) {
#if V8_COMPRESS_POINTERS
  // TODO(saelo): if we ever use the external pointer table for all external
  // pointer fields in the no-sandbox-ptr-compression config, replace this code
  // here and above with the respective external pointer accessors.
  IsolateForPointerCompression isolate = Isolate::Current();
  constexpr ExternalPointerTag tag = kArrayBufferExtensionTag;
  Address value = reinterpret_cast<Address>(extension);
  ExternalPointerTable& table = isolate.GetExternalPointerTableFor(tag);
  ExternalPointerHandle current_handle =
      base::AsAtomic32::Relaxed_Load(extension_handle_location());
  if (current_handle == kNullExternalPointerHandle) {
    ExternalPointerHandle handle = table.AllocateAndInitializeEntry(
        isolate.GetExternalPointerTableSpaceFor(tag, address()), value, tag);
    base::AsAtomic32::Release_Store(extension_handle_location(), handle);
    WriteBarrier::ForExternalPointer(this, ExternalPointerSlot(&extension_),
                                     handle);
  } else {
    table.Set(current_handle, value, tag);
  }

Note that the handle is what lives in the heap object, and the handle is just an index.

The trusted space lives outside

Some objects must not be corruptible at all: bytecode containers, the metadata of JIT-generated code, the trusted range. V8 allocates them outside the sandbox and references them through the trusted pointer table, which is a mirror image of the external pointer table.

That means V8 needs to be able to ask “is this object outside the sandbox?”, and it needs to be sure that the answer is not forged. The check is in the marking visitor:

cpp
// src/heap/heap-visitor-inl.h:155
    DCHECK(InstanceTypeChecker::IsTrustedObject(map));                     \
    /* Trusted objects are protected from modifications by an attacker as  \
     * they are located outside of the sandbox. However, an attacker can   \
     * still craft their own fake trusted objects inside the sandbox. In   \
     * this case, bad things might happen if these objects are then        \
     * processed by e.g. an object visitor as they will typically assume   \
     * that these objects are trustworthy. The following check defends     \
     * against that by ensuring that the object is outside of the sandbox. \
     * See also crbug.com/c/1505089. */                                    \
    SBXCHECK(OutsideSandboxOrInReadonlySpace(object));                     \

Read the comment carefully: the attacker can craft a fake trusted object inside the sandbox, because the sandbox is corruptible by assumption. The defence is that the visitor refuses to process anything that claims to be trusted but is not actually outside the sandbox. This is a good example of V8’s sandbox philosophy in miniature: the check is not “trust the type”, it is “trust the address”.

Debug versus release

The sandbox is not a debug feature - it is on by default in 64-bit builds. But there are two tiers of checking.

In all sandbox-enabled builds, SBXCHECK is a hard CHECK with an extra twist:

cpp
// src/sandbox/check.h:12
// An SBXCHECK behaves like a CHECK, but indicates that the check is required
// for the sandbox, i.e. prevents a sandbox bypass.
//
// As an example, consider a scenario where an in-sandbox object stores an
// index into an out-of-sandbox array (or a similar data structure). While
// under normal circumstances it can be guaranteed that the index will always
// be in bounds, with the sandbox attacker model, we have to assume that the
// in-sandbox object can be corrupted by an attacker and so the access can go
// out-of-bounds. In that case, a SBXCHECK can be used to both prevent memory
// corruption outside of the sandbox and document that there is a
// security-critical invariant that may be violated when an attacker can
// corrupt memory inside the sandbox, but otherwise holds true.

and the macro itself blocks sandbox memory access while the check runs, because a check that reads sandbox memory is racy by construction:

cpp
// src/sandbox/check.h:37
#define SBXCHECK(condition)                                               \
  do {                                                                    \
    std::optional<v8::internal::DisallowSandboxAccess> no_sandbox_access; \
    if (!std::is_constant_evaluated()) {                                  \
      no_sandbox_access.emplace("No sandbox access during SBXCHECK");     \
    }                                                                     \
    CHECK(condition);                                                     \
  } while (false)

In debug builds, on top of that, there are DCHECKs everywhere - the DCHECK(OutsideSandbox(value_chunk->address())) in the slot recorder, the DCHECK(index == 0 || at(index).HasExternalPointer(tag_range)) in the external pointer table’s Get. Those catch bugs in V8 itself; they are not part of the attack surface.

There is a third, separate mechanism: the memory corruption API. Built with v8_enable_memory_corruption_api, it exposes a JavaScript Sandbox object that can read and write arbitrary sandbox memory, which lets fuzzers construct the exact primitive the sandbox is designed to contain. V8’s own crash filter then distinguishes “the attacker corrupted the sandbox” (fine) from “the attacker corrupted something else” (a sandbox bypass, a bug).

What the attacker actually gains

The sandbox does not prevent exploitation. It contains it. Concretely, an attacker with an arbitrary write inside the sandbox gains:

  • No absolute addresses. Every sandboxed pointer is an offset from a randomised base. To turn an offset into a usable address the attacker needs the base, which they can only learn by reading something inside the sandbox - and the only things they can read are other sandboxed objects, whose contents are also offsets. The base itself lives in the isolate’s data, outside the sandbox.
  • No executable pages in the sandbox. The sandbox subspace is read+write only, so a corrupted object cannot itself be turned into a jump target. V8’s code that must not be corruptible - bytecode containers, JIT metadata - lives in the trusted space, outside the sandbox.
  • No direct calls to embedder callbacks. A callback pointer is a table entry with a type tag; getting the wrong tag yields an un-dereferenceable address.
  • Bounded damage from overflow. The guard regions are sized for the worst arithmetic an attacker can perform, so a corrupted length or index runs into a guard page rather than into the heap.

How this differs from Chrome’s PartitionAlloc pointer compression

Chrome’s PartitionAlloc has its own “pointer compression” scheme, and it is easy to confuse the two.

PartitionAlloc’s scheme removes the pointer from the object entirely: the object stores a 32-bit offset within its pool, and the pool’s base is recovered from the address of the slot holding the object. This is the “pool pointer” trick, and it means the address of the object itself is the root of trust for the compressed pointer - which is why PartitionAlloc requires objects to live at a fixed offset within a slot, and why it needs a per-pool metadata table.

V8’s sandbox does something different:

PartitionAlloc PCV8 sandbox
GoalSmall objects, small metadataContain an attacker
Representation32-bit offset within a 2 GB pool32-bit offset within the 4 GB cage, or a shifted offset within the sandbox
Root of trustThe address of the containing slotA randomised, 4 GB-aligned sandbox base held outside the sandbox
ScopeOne poolThe whole V8 heap plus buffers
Effect of corruptionA wrong offset inside the poolA wrong offset inside the sandbox, or a guard-region fault

The key structural difference: PartitionAlloc’s pointer compression is a memory saving technique that happens to have a security side effect, while the sandbox is a security technique that reuses the cage machinery for address-space isolation. V8’s own pointer compression is the 4 GB cage - that is the mechanism the sandbox is built on top of, not a competitor to it.

And the sandbox adds things PartitionAlloc has no concept of: guard regions sized for typed-array arithmetic, external pointer tables with type tags, trusted space outside the sandbox, and a random base address.

Trade-offs

What it buys. A memory-corruption bug in V8 becomes a bug in V8, not a compromise of the browser. That is a huge change in the economics of exploit development: the attacker has to find a second bug, one that escapes the sandbox, and that bug has to be in the small amount of code that runs outside the sandbox.

What it costs.

  • Address space. A terabyte of reservation, plus 64 GB of guard region on each side and 224 GB more on most platforms. On 32-bit systems there is none of this, which is why the sandbox is 64-bit only.
  • An extra indirection for external pointers. Every ArrayBuffer backing store access is a table lookup.
  • The base must be kept secret. Any leak of the sandbox base (in a DevTools dump, in a crash report, in a --print-sandbox style diagnostic) weakens the whole scheme. V8 has to be careful about what it prints.
  • Partially reserved sandboxes are a downgrade. On old Windows, or on constrained Android devices, V8 may only be able to reserve 8 GB. In that mode unrelated mappings can land inside the sandbox region, and the security properties are weaker. V8 refuses to run at all under --sandbox-prohibit-insecure-mode.
  • Checks are not free. SBXCHECK is a CHECK, and CHECK in V8 is always on in sandbox-enabled builds.

The bottom line

The V8 sandbox is a containment mechanism, not a defence against corruption. It assumes the attacker owns everything inside the sandbox and makes sure that owning it does not help them own anything else.

Three mechanisms do the work. Sandboxed pointers are offsets from a randomised base, so no sandboxed object can name an address outside the sandbox. Guard regions sized for the worst-case typed-array arithmetic catch overflows. External pointer tables with 7-bit type tags replace every out-of-sandbox reference with an index, so the wrong kind of pointer degrades into a fault rather than a call.

The mental model to keep is: the sandbox is a jail with a hole in the wall where the jailer is allowed to talk to the outside world, and the hole is type-checked.

See also:

The thing people get wrong

The sandbox is not pointer compression, and pointer compression is not the sandbox. V8 has both, and they are different features with different goals.

Pointer compression is about size: it lets a tagged pointer be 32 bits instead of 64, which shrinks every object and every pointer field. The sandbox is about containment: it does not make anything smaller, it makes the heap a region of address space that can be fenced off.

The sandbox does not stop use-after-free. It does not stop type confusion. It does not stop an attacker from reading and writing arbitrary sandbox memory - V8 assumes that. What it does is make sure that the next step, the step where the attacker tries to turn a heap bug into code execution outside V8, requires a second, separate vulnerability.

V8InternalsSandboxMemorySecurity