V8 Sandbox - Sandboxed Pointers

Author: saelo@

First Published: February 2022

Last Updated: February 2022

Status: Living Doc

Tracking Bug: crbug/1218005

Visibility: PUBLIC

This document is part of the V8 Sandbox Project and covers the sandboxed pointer mechanism and how it is used to prevent ArrayBuffers and related objects from being used for an arbitrary memory read/write primitive by an exploit. For a general overview of the sandbox design see the high-level design document.

Background

The goal of the V8 sandbox is to prevent an attacker with the ability to corrupt V8 objects from constructing an arbitrary memory read/write primitive. One way in which this is achieved is by placing objects accessed directly by V8 into a large region of virtual address space - the sandbox - and referencing them through offsets instead of raw pointers. These offsets are called SandboxedPointers and are described in this document. The layout of the sandbox in memory is discussed in a separate document.

As a motivating example for a SandboxedPointer consider the following diagram:

In this example, if the ArrayBuffer backing store at address 0xa44d667df000 is referenced through a raw pointer, an attacker could corrupt that pointer to obtain a process-wide arbitrary memory read/write primitive by accessing the ArrayBuffer in JavaScript. However, if the buffer is instead referenced through a 40-bit offset (0xc0667df000) from the start of the sandbox (0xa38d00000000) - a SandboxedPointer - then an attacker corrupting the pointer is only able to access other memory inside the sandbox, but not outside of it. This mechanism is thus similar in nature to JSC’s GigaCage or typical implementations of Wasm.

It should also be noted that most V8 heap objects already effectively use pointer caging: due to pointer compression, V8 HeapObjects reference each other through 32-bit compressed pointers, which are, in effect, also SandboxedPointers.

Objective  

The goal of SandboxedPointers is to prevent an attacker from abusing references between objects inside the sandbox to access memory outside of the sandbox. This must be achieved with minimal performance overhead. An explicit non-goal is to prevent an attacker from corrupting or leaking data located inside the sandbox but owned by a different component, for example code running under a different origin (this should instead be prevented by site-isolation).

Design

Given a one-terabyte sandbox as described in this document, SandboxedPointers are implemented as 40-bit offsets shifted to the left by 24. This ensures that the offset will always be less than 1TB after shifting it to the right again.

Consider the following image, showing a hypothetical sandbox in memory and the objects contained inside it:

In this example, a backing store at address 0xa44d667df000 (inside the ArrayBuffer Partition) would be referenced as 0xc0667df000000000, which is its 40-bit offset from the base of the sandbox (0xc0667df000) shifted to the left by 24. Upon access, the SandboxedPointer is “decoded” into a raw pointer by first shifting it to the right, then adding it to the base of the sandbox.

Performance

The base of the sandbox is available in the kPtrComprCageBaseRegister register. As such, decoding a SandboxedPointer is generally possible with two additional instructions on x64:

    movq(destination, field_operand);

    shrq(destination, Immediate(kSandboxedPointerShift));

    addq(destination, kPtrComprCageBaseRegister);

And with a single instruction on arm64:

  Ldr(destination, field_operand);

  Add(destination, kPtrComprCageBaseRegister,

      Operand(destination, LSR, kSandboxedPointerShift));

Benchmarks suggest that the overhead is negligible on all but very specific ArrayBuffer intensive workloads. See this document (internal only) for more information.

Nullptr Values

Under certain circumstances (e.g. zero-length ArrayBuffers), having a nullptr backing storage pointer is valid and/or expected. Unfortunately, the address zero is generally not part of the sandbox, and thus nullptr cannot be expressed as a SandboxedPointer. Instead, a null value would represent the base address of the sandbox.

While it is theoretically possible to support special NULL values for offsets which are then converted to real nullptr on access, doing so is problematic both from a performance point of view (additional branches required) and from a security perspective (it might allow access to memory mapped in the first few GB of the address space if a large index is used).

Instead, nullptr values are forbidden and all users of nullptr values are modified to instead use a special “empty” object such as the EmptyBackingStoreBuffer constant.

Bounded Indices

When referencing array-like data structures through SandboxedPointers, it must be guaranteed that the maximum index does not result in an access outside of the sandbox. Given the 32GB guard regions around the sandbox and a maximum element size of 8 bytes (e.g. Float64Array), indices must be represented as 32 bit integers. If larger indices are required, they would likely also need to be stored shifted to the left to guarantee an upper bound. A prototype suggests that this would not cause significant performance overhead.

Alternatives Considered

This section briefly discusses alternative options for implementing SandboxedPointers.

Masked Pointers

Instead of storing the backing storage pointers shifted to the left, this approach stores them as full 64-bit values but applies a bit mask when loading them to clear the top bits.

Pseudocode for SandboxedPointer access:

Address ptr = ReadField<Address>(...);

Address offset = ptr & kSandboxedPointerMask;

return sandbox_base + offset;

Likely due to the need to load the mask, this approach performs slightly worse than the shifted offsets. However, it should be reconsidered on CPUs that provide special hardware support, such as RISC-V’s pointer masking extension.

CHECKed Pointers

This approach stores the pointer in full, but essentially performs a CHECK(pointer_is_within_sandbox()) on every access. The intuition behind this was to allow the CPU to speculate over the CHECK and start loading from the data pointer.

Pseudocode for SandboxedPointer access:

Address ptr = ReadField<Address>(...);

CHECK((ptr >> kSandboxedPointerShift) == shifted_sandbox_base);

return ptr;

Judging from benchmarks, this approach does not appear to outperform the shifted offset approach.

Pointer Authentication (PAC)

Pointer Authentication (PAC) cannot generally answer the question “is this a valid pointer to an object of the expected type?” but only “was this at some point a valid pointer to an object of the expected type?” (i.e. the object might have been freed in the meantime). However, if ArrayBuffer backing stores are always allocated inside the sandbox, then the second question is in fact enough to ensure that ArrayBuffer accesses always stay inside the sandbox. As such, in a PAC-based approach, the external pointers into backing store memory would simply be signed with a constant context value and then authenticated before every use:

Address ptr = ReadField<Address>(...);

PAC_authenticate(ptr, kSandboxedPointerContext);

return ptr;

This approach could then be used instead of shifted pointers on devices that support PAC once there is a sufficiently high number of those available.