# Pwn2Own Whitepaper: DOOMArrayBuffer

Submitted by Seunghyun Lee (@0x10n) of KAIST Hacking Lab


## Target

Microsoft Edge (Chromium) Renderer Only RCE + Double Tap Addon


## Hashes

```text
$ sha256sum exp.html poc.html
123be9042e63e0e25bfafd6efd88dede3a96bd6ffda7d0a851419a2c52798a88  exp.html
fef41e946166935749afb1ec522f8bd2396e553bc30cd4b2d22df969d39e36d0  poc.html
```


## Repro (Minimal PoC)

1. Run a webserver to serve the given `poc.html` file (e.g. `python3 -m http.server -b 127.0.0.1 8000`)
2. Start Chrome or Edge
3. Browse to `http://127.0.0.1:8000/poc.html`

Result should be an immediate crash with `STATUS_ACCESS_VIOLATION`.


## Repro (RCE)

1. Run a webserver to serve the given `exp.html` file (e.g. `python3 -m http.server -b 127.0.0.1 8000`)
2. Start Chrome or Edge with `--no-sandbox` flag
3. Browse to `http://127.0.0.1:8000/exp.html`

Result should be a command prompt opening with arbitrary commands executed (`echo`ing of some ASCII art).


## Bug / Root Cause Analysis

`blink::DOMArrayBuffer`, the Blink-side data structure that represents `v8::JSArrayBuffer`, is shaped as the following:

```cpp
class CORE_EXPORT DOMArrayBuffer : public DOMArrayBufferBase {
  // ...
}

class CORE_EXPORT DOMArrayBufferBase : public ScriptWrappable {
  // ...
  ArrayBufferContents contents_;
  bool is_detached_ = false;
};

class CORE_EXPORT ArrayBufferContents {
  // ...
  std::shared_ptr<v8::BackingStore> backing_store_;
};
```

Note that `blink::DOMArrayBuffer` only holds a `std::shared_ptr` to `v8::BackingStore` but not the JSArrayBuffer itself - the object is fundamentally oblivious of any changes made from V8, including ArrayBuffer being detached. This results in a detached ArrayBuffer to reference its now-detached backing store for all Blink-side APIs that operate on ArrayBuffers, assuming that the detachment is not done from Blink but from V8.

`ArrayBuffer.prototype.transfer()` is one example of such, eventually reaching `JSArrayBuffer::Detach()`:

```cpp
Maybe<bool> JSArrayBuffer::Detach(Handle<JSArrayBuffer> buffer,
                                  bool force_for_wasm_memory,
                                  Handle<Object> maybe_key) {
  Isolate* const isolate = buffer->GetIsolate();
  // ...
  buffer->DetachInternal(force_for_wasm_memory, isolate);
  return Just(true);
}

void JSArrayBuffer::DetachInternal(bool force_for_wasm_memory,
                                   Isolate* isolate) {
  ArrayBufferExtension* extension = this->extension();

  if (extension) {
    DisallowGarbageCollection disallow_gc;
    isolate->heap()->DetachArrayBufferExtension(*this, extension);
    std::shared_ptr<BackingStore> backing_store = RemoveExtension();
    CHECK_IMPLIES(force_for_wasm_memory, backing_store->is_wasm_memory());
  }

  if (Protectors::IsArrayBufferDetachingIntact(isolate)) {
    Protectors::InvalidateArrayBufferDetaching(isolate);
  }

  DCHECK(!is_shared());
  set_backing_store(isolate, EmptyBackingStoreBuffer());
  set_byte_length(0);
  set_was_detached(true);    // [!] All the operations are done on JSArrayBuffer, not DOMArrayBuffer - we're within V8, not Blink
}
```

When a V8 object is passed on to Blink, it is converted to a Blink object. Below is the code for ArrayBuffers.

```cpp
DOMArrayBuffer* ToDOMArrayBuffer(v8::Isolate* isolate,
                                 v8::Local<v8::Value> value) {
  if (UNLIKELY(!value->IsArrayBuffer()))
    return nullptr;

  v8::Local<v8::ArrayBuffer> v8_array_buffer = value.As<v8::ArrayBuffer>();
  if (ScriptWrappable* array_buffer = ToScriptWrappable(v8_array_buffer)) {     // [!] cached
    return array_buffer->ToImpl<DOMArrayBuffer>();
  }

  // Transfer the ownership of the allocated memory to a DOMArrayBuffer without
  // copying.
  ArrayBufferContents contents(v8_array_buffer->GetBackingStore());
  DOMArrayBuffer* array_buffer = DOMArrayBuffer::Create(contents);
  v8::Local<v8::Object> wrapper = array_buffer->AssociateWithWrapper(
      isolate, array_buffer->GetWrapperTypeInfo(), v8_array_buffer);
  DCHECK(wrapper == v8_array_buffer);
  return array_buffer;
}

inline ScriptWrappable* ToScriptWrappable(v8::Local<v8::Object> wrapper) {
  return GetInternalField<ScriptWrappable, kV8DOMWrapperObjectIndex>(wrapper);
}
```

Note how once an ArrayBuffer is converted to a Blink object, it is cached in an internal field which is reused later on. Thus, with the following sequence of execution we can make a detached ArrayBuffer be considered non-detached from Blink side:
1. Create an ArrayBuffer `ab`
2. Convert it once to Blink object by calling any Web APIs, e.g. `new Blob([ab])`
   - This is the first conversion from V8 to Blink - a new DOMArrayBuffer is created and cached
3. Detach the ArrayBuffer from V8, e.g. `ab.transfer()`
4. Use it again as a Blink object through any Web APIs, e.g. `new Blob([ab])` again
   - This is the second conversion - the cached DOMArrayBuffer is reused, which is oblivious of the detachment

As `blink::DOMArrayBuffer` owns `blink::ArrayBufferContents` which in turn holds a shared pointer to the underlying BackingStore, it defers the underlying buffer's deallocation:

```cpp
BackingStore::~BackingStore() {
  GlobalBackingStoreRegistry::Unregister(this);
  // ...
  // JSArrayBuffer backing store. Deallocate through the embedder's allocator.
  auto allocator = get_v8_api_array_buffer_allocator();
  TRACE_BS("BS:free   bs=%p mem=%p (length=%zu, capacity=%zu)\n", this,
           buffer_start_, byte_length(), byte_capacity_);
  allocator->Free(buffer_start_, byte_length_);
}
```

This can still be manipulated into an exploitable state by using any APIs that transfer ArrayBuffers - notably, `structuredClone()`. Zero-copy transferring a DOMArrayBuffer via `structuredClone()` which has been detached directly from V8 results in yet another non-detached ArrayBuffer referencing the same underlying backing store buffer (`ArrayBufferContents` to be exact):

```cpp
SerializedScriptValue::TransferArrayBufferContents(
    v8::Isolate* isolate,
    const ArrayBufferArray& array_buffers,
    ExceptionState& exception_state) {
  ArrayBufferContentsArray contents;

  if (!array_buffers.size())
    return ArrayBufferContentsArray();

  for (auto* it = array_buffers.begin(); it != array_buffers.end(); ++it) {
    DOMArrayBufferBase* array_buffer = *it;
    if (array_buffer->IsDetached()) {             // [!] not detached, since we're checking DOMArrayBuffer
      // ...
    }
  }

  contents.Grow(array_buffers.size());
  // ...
  for (auto* it = array_buffers.begin(); it != array_buffers.end(); ++it) {
    DOMArrayBufferBase* array_buffer_base = *it;
    if (visited.Contains(array_buffer_base))
      continue;
    visited.insert(array_buffer_base);

    wtf_size_t index =
        static_cast<wtf_size_t>(std::distance(array_buffers.begin(), it));
    if (array_buffer_base->IsShared()) {
      // ...
    } else {
      DOMArrayBuffer* array_buffer =
          static_cast<DOMArrayBuffer*>(array_buffer_base);

      if (!array_buffer->IsDetachable(isolate)) { // [!] detachable, since this is a normal (DOM)ArrayBuffer
        // ...
      } else if (array_buffer->IsDetached()) {    // [!] not detached (again), since we're checking DOMArrayBuffer
        exception_state.ThrowDOMException(DOMExceptionCode::kDataCloneError,
                                          "ArrayBuffer at index " +
                                              String::Number(index) +
                                              " could not be transferred.");
        return ArrayBufferContentsArray();
      } else if (!array_buffer->Transfer(isolate, contents.at(index),
                                         exception_state)) {           // [!] successfully transferred
        return ArrayBufferContentsArray();
      }
    }
  }
  return contents;
}
```

Now, transferring one of the ArrayBuffer into a different non-zero size results in reallocation of the underlying backing store buffer:

```cpp
Tagged<Object> ArrayBufferTransfer(Isolate* isolate,
                                   Handle<JSArrayBuffer> array_buffer,
                                   Handle<Object> new_length,
                                   PreserveResizability preserve_resizability,
                                   const char* method_name) {
  // ...
  // 5. If IsDetachedBuffer(arrayBuffer) is true, throw a TypeError exception.
  if (array_buffer->was_detached()) {              // [!] pass, both of the duped ArrayBuffers are not detached
    THROW_NEW_ERROR_RETURN_FAILURE(
        isolate, NewTypeError(MessageTemplate::kDetachedOperation,
                              isolate->factory()->NewStringFromAsciiChecked(
                                  method_name)));
  }
  // ...
  // Case 1: We don't need a BackingStore.
  if (new_byte_length == 0) {
    // 15. Perform ! DetachArrayBuffer(arrayBuffer).
    JSArrayBuffer::Detach(array_buffer).Check();   // [!] shared_ptr retained on the other ArrayBuffer preventing UAF

    // 9. Let newBuffer be ? AllocateArrayBuffer(%ArrayBuffer%, newByteLength,
    //    newMaxByteLength).
    //
    // Nothing to do for steps 10-14.
    //
    // 16. Return newBuffer.
    return *isolate->factory()
                ->NewJSArrayBufferAndBackingStore(
                    0, new_max_byte_length, InitializedFlag::kUninitialized,
                    resizable)
                .ToHandleChecked();
  }

  // Case 2: We can reuse the same BackingStore.
  auto from_backing_store = array_buffer->GetBackingStore();
  if (from_backing_store && !from_backing_store->is_resizable_by_js() &&
      resizable == ResizableFlag::kNotResizable &&
      (new_byte_length == array_buffer->GetByteLength() ||
       from_backing_store->CanReallocate())) {
    // Reallocate covers steps 10-14.
    if (new_byte_length != array_buffer->GetByteLength() &&
        !from_backing_store->Reallocate(isolate, new_byte_length)) {    // [!] reallocated, UAF on the other ArrayBuffer
      THROW_NEW_ERROR_RETURN_FAILURE(
          isolate,
          NewRangeError(MessageTemplate::kArrayBufferAllocationFailed));
    }

    // 15. Perform ! DetachArrayBuffer(arrayBuffer).
    JSArrayBuffer::Detach(array_buffer).Check();

    // 9. Let newBuffer be ? AllocateArrayBuffer(%ArrayBuffer%, newByteLength,
    //    newMaxByteLength).
    // 16. Return newBuffer.
    return *isolate->factory()->NewJSArrayBuffer(std::move(from_backing_store));
  }

  // ...
}

bool BackingStore::Reallocate(Isolate* isolate, size_t new_byte_length) {
  CHECK(CanReallocate());
  auto allocator = get_v8_api_array_buffer_allocator();
  CHECK_EQ(isolate->array_buffer_allocator(), allocator);
  CHECK_EQ(byte_length_, byte_capacity_);
  void* new_start =
      allocator->Reallocate(buffer_start_, byte_length_, new_byte_length);    // [!] reallocate buffer in place
  if (!new_start) return false;
  buffer_start_ = new_start;
  byte_capacity_ = new_byte_length;
  byte_length_ = new_byte_length;
  max_byte_length_ = new_byte_length;
  return true;
}
```

Using the other ArrayBuffer now results in a use-after-free as its `byte_length`, `backing_store`, `was_detached`, etc. still retains its old value:

```cpp
class JSArrayBuffer
    : public TorqueGeneratedJSArrayBuffer<JSArrayBuffer,
                                          JSObjectWithEmbedderSlots> {
  // ...
  // [byte_length]: length in bytes
  DECL_PRIMITIVE_ACCESSORS(byte_length, size_t)

  // [max_byte_length]: maximum length in bytes
  DECL_PRIMITIVE_ACCESSORS(max_byte_length, size_t)

  // [backing_store]: backing memory for this array
  // It should not be assumed that this will be nullptr for empty ArrayBuffers.
  DECL_GETTER(backing_store, void*)
  inline void set_backing_store(Isolate* isolate, void* value);
  // ...
  // [was_detached]: true => the buffer was previously detached.
  DECL_BOOLEAN_ACCESSORS(was_detached)
}
```


## Exploit

> Most of the exploit steps below are almost eqivalent to that of the other bug since we construct the same exploit primitives. The remaining are just exploit techniques in the modern Chrome heap sandbox environment (especially with regards to PartitionAlloc).

### Obtaining (Almost) Arbitrary Address Write

The primitive we have is a use-after-free read/write on any chosen ArrayBuffer. Unfortunately this is caged in the 1TB heap sandbox, and furthermore is [allocated in the ArrayBuffer partition](https://chromium.googlesource.com/chromium/src/+/master/third_party/blink/renderer/platform/wtf/allocator/Allocator.md) of PartitionAlloc.

There have been exploits on ArrayBuffer UAF: CVE-2019-5786, CVE-2019-13270 a.k.a. Operation WizardOpium, and many more. All of these exploit techniques, especially freelist corruption to PartitionAlloc metadata, are no longer valid due to sanity checks:

```cpp
  PA_ALWAYS_INLINE static bool IsSane(const EncodedNextFreelistEntry* here,
                                      const EncodedNextFreelistEntry* next,
                                      bool for_thread_cache) {
    // Don't allow the freelist to be blindly followed to any location.
    // Checks two constraints:
    // - here and next must belong to the same superpage, unless this is in the
    //   thread cache (they even always belong to the same slot span).
    // - next cannot point inside the metadata area.
    //
    // Also, the lightweight UaF detection (pointer shadow) is checked.

    uintptr_t here_address = SlotStartPtr2Addr(here);
    uintptr_t next_address = SlotStartPtr2Addr(next);

#if PA_CONFIG(HAS_FREELIST_SHADOW_ENTRY)
    bool shadow_ptr_ok = here->encoded_next_.Inverted() == here->shadow_;
#else
    bool shadow_ptr_ok = true;
#endif

    bool same_superpage = (here_address & kSuperPageBaseMask) ==
                          (next_address & kSuperPageBaseMask);          // [!] checks superpage equality
#if BUILDFLAG(USE_FREESLOT_BITMAP)
    bool marked_as_free_in_bitmap =
        for_thread_cache ? true : !FreeSlotBitmapSlotIsUsed(next_address);
#else
    bool marked_as_free_in_bitmap = true;
#endif

    // This is necessary but not sufficient when quarantine is enabled, see
    // SuperPagePayloadBegin() in partition_page.h. However we don't want to
    // fetch anything from the root in this function.
    bool not_in_metadata =
        (next_address & kSuperPageOffsetMask) >= PartitionPageSize();   // [!] checks metadata inclusivity

    if (for_thread_cache) {
      return shadow_ptr_ok & not_in_metadata;
    } else {
      return shadow_ptr_ok & same_superpage & marked_as_free_in_bitmap &
             not_in_metadata;
    }
  }
```

However, as we have a use-after-free read and write on any ArrayBuffers, we can simply directly target the metadata region by using ArrayBuffers of size exceeding 0xf0000. This gets mapped directly ("Direct Map") on the PartitionAlloc region which could be unmapped and reclaimed as part of super pages, including metadata.

Also, freelist head on the metadata region is not validated if its next pointer is NULL:

```cpp
template <bool crash_on_corruption>
PA_ALWAYS_INLINE EncodedNextFreelistEntry*
EncodedNextFreelistEntry::GetNextInternal(size_t slot_size,
                                          bool for_thread_cache) const {
  // GetNext() can be called on discarded memory, in which case |encoded_next_|
  // is 0, and none of the checks apply. Don't prefetch nullptr either.
  if (IsEncodedNextPtrZero()) {             // [!] no checks if freelist head is the last entry, i.e. next == NULL
    return nullptr;
  }

  auto* ret = encoded_next_.Decode();
  // We rely on constant propagation to remove the branches coming from
  // |for_thread_cache|, since the argument is always a compile-time constant.
  if (PA_UNLIKELY(!IsSane(this, ret, for_thread_cache))) {
    if constexpr (crash_on_corruption) {
      // Put the corrupted data on the stack, it may give us more information
      // about what kind of corruption that was.
      PA_DEBUG_DATA_ON_STACK("first",
                             static_cast<size_t>(encoded_next_.encoded_));
#if PA_CONFIG(HAS_FREELIST_SHADOW_ENTRY)
      PA_DEBUG_DATA_ON_STACK("second", static_cast<size_t>(shadow_));
#endif
      FreelistCorruptionDetected(slot_size);
    } else {
      return nullptr;
    }
  }
  // ...
}

PA_ALWAYS_INLINE EncodedNextFreelistEntry* EncodedNextFreelistEntry::GetNext(
    size_t slot_size) const {
  return GetNextInternal<true>(slot_size, false);
}

PA_ALWAYS_INLINE PartitionFreelistEntry* SlotSpanMetadata::PopForAlloc(
    size_t size) {
  // Not using bucket->slot_size directly as the compiler doesn't know that
  // |bucket->slot_size| is the same as |size|.
  PA_DCHECK(size == bucket->slot_size);
  PartitionFreelistEntry* result = freelist_head;
  // Not setting freelist_is_sorted_ to false since this doesn't destroy
  // ordering.
  freelist_head = freelist_head->GetNext(size);
  num_allocated_slots++;
  return result;
}
```

Thus, we can forge metadata so that the next free chunk points to arbitrary address already containing NULL, which would be returned on the next ArrayBuffer partition allocation with the corresponding size. We also have controlled UAF read which trivially allows leaking binary base (of `chrome.dll` or `msedge.dll`) from bucket address and heap address from freelist head.

> We have now built an equivalent primitive to that of the other bug (fully controlled UAF on PartitionAlloc metadata) - the remaining exploits are the same boilerplates.

### Uncaging the V8 Heap Sandbox

This is now one step short of arbitrary allocation primitive on any address that already contains NULL. On the actual allocation routine, the returned address is checked so that it is within the V8 heap sandbox:

```cpp
// Allocate a backing store using the array buffer allocator from the embedder.
std::unique_ptr<BackingStore> BackingStore::Allocate(
    Isolate* isolate, size_t byte_length, SharedFlag shared,
    InitializedFlag initialized) {
  void* buffer_start = nullptr;
  auto allocator = isolate->array_buffer_allocator();
  CHECK_NOT_NULL(allocator);
  if (byte_length != 0) {
    // ...
    auto allocate_buffer = [allocator, initialized](size_t byte_length) {
      if (initialized == InitializedFlag::kUninitialized) {
        return allocator->AllocateUninitialized(byte_length);
      }
      return allocator->Allocate(byte_length);
    };

    buffer_start = isolate->heap()->AllocateExternalBackingStore(
        allocate_buffer, byte_length);
    // ...
#ifdef V8_ENABLE_SANDBOX
    // Check to catch use of a non-sandbox-compatible ArrayBufferAllocator.
    CHECK_WITH_MSG(GetProcessWideSandbox()->Contains(buffer_start),
                   "When the V8 Sandbox is enabled, ArrayBuffer backing stores "
                   "must be allocated inside the sandbox address space. Please "
                   "use an appropriate ArrayBuffer::Allocator to allocate "
                   "these buffers, or disable the sandbox.");
#endif
  }

  // ...
}

class V8_EXPORT_PRIVATE Sandbox {
  // ...
  /**
   * Returns true if the given address lies within the sandbox address space.
   */
  bool Contains(Address addr) const {
    return base::IsInHalfOpenRange(addr, base_, base_ + size_);
  }

  /**
   * Returns true if the given pointer points into the sandbox address space.
   */
  bool Contains(void* ptr) const {
    return Contains(reinterpret_cast<Address>(ptr));
  }
  // ...
};
```

This check can be avoided by using reallocation instead:

```cpp
bool BackingStore::Reallocate(Isolate* isolate, size_t new_byte_length) {
  CHECK(CanReallocate());
  auto allocator = get_v8_api_array_buffer_allocator();
  CHECK_EQ(isolate->array_buffer_allocator(), allocator);
  CHECK_EQ(byte_length_, byte_capacity_);
  void* new_start =
      allocator->Reallocate(buffer_start_, byte_length_, new_byte_length);  // [!] no check on this pointer
  if (!new_start) return false;
  buffer_start_ = new_start;
  byte_capacity_ = new_byte_length;
  byte_length_ = new_byte_length;
  max_byte_length_ = new_byte_length;
  return true;
}

void* v8::ArrayBuffer::Allocator::Reallocate(void* data, size_t old_length,
                                             size_t new_length) {
  if (old_length == new_length) return data;
  uint8_t* new_data =
      reinterpret_cast<uint8_t*>(AllocateUninitialized(new_length));        // [!] no check on this pointer
  if (new_data == nullptr) return nullptr;
  size_t bytes_to_copy = std::min(old_length, new_length);
  memcpy(new_data, data, bytes_to_copy);                                    // [!] arbitrary write
  if (new_length > bytes_to_copy) {
    memset(new_data + bytes_to_copy, 0, new_length - bytes_to_copy);
  }
  Free(data, old_length);
  return new_data;
}
```

We now have arbitrary write. However, the primitive still crashes the renderer before returning to our JS due to another check while setting the backing store address:

```cpp
void JSArrayBuffer::set_backing_store(Isolate* isolate, void* value) {
  Address addr = reinterpret_cast<Address>(value);
  WriteSandboxedPointerField(kBackingStoreOffset, isolate, addr);
}

void HeapObject::WriteSandboxedPointerField(size_t offset, Isolate* isolate,
                                            Address value) {
  i::WriteSandboxedPointerField(field_address(offset),
                                PtrComprCageBase(isolate), value);
}

V8_INLINE void WriteSandboxedPointerField(Address field_address,
                                          PtrComprCageBase cage_base,
                                          Address pointer) {
#ifdef V8_ENABLE_SANDBOX
  // The pointer must point into the sandbox.
  CHECK(GetProcessWideSandbox()->Contains(pointer));                        // [!] check crashes

  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
}
```

Recall again that this check is triggered AFTER the reallocation which have already `memcpy()`-ed attacker-controlled contents on to attacker-controlled address outside of the heap sandbox. We can simple overwrite the sandbox bounds as the full 64bit address space, completely bypassing any following sandbox address checks:

```cpp
class V8_EXPORT_PRIVATE Sandbox {
  // ...
  Address base_ = kNullAddress;     // [!] bounds later initialized on sandbox init, to be overwritten
  Address end_ = kNullAddress;
  size_t size_ = 0;
  // ...
};
```

Now we can continue our exploit with the sandbox region forged as the full 64bit address space, effectively uncaging the sandbox to allow our primitive to allocate arbitrary addresses with NULL already written on it.

Note that the sandbox is the whole 1TB cage which contains other partitions - pivoting to such partitions without overwriting the sandbox bounds would also be a valid exploit technique, although recent sandbox hardening changes from M123 and above are somewhat mitigating such primitives.

### Gaining Arbitrary Code Execution

We have the following primitives:
- Leak:
  - Binary base address (`chrome.dll` / `msedge.dll`)
  - ArrayBuffer partition address
- Primitives:
  - AAW on any address which already has NULL (i.e. 8 bytes of zeros) written on it

Obtaining PC control can be done in many ways, but as we're already overwriting the `Sandbox` object to change the cage bounds we might as well just overwrite something that's located in front of it.

What's in front of `Sandbox`? It's `CodePointerTable`:

```cpp
class V8_EXPORT_PRIVATE CodePointerTable
    : public ExternalEntityTable<CodePointerTableEntry,
                                 kCodePointerTableReservationSize> {
  // ...
};

template <typename Entry, size_t size>
class V8_EXPORT_PRIVATE ExternalEntityTable {
  // ...
  // The pointer to the base of the virtual address space backing this table.
  // All entry accesses happen through this pointer.
  // It is equivalent to |vas_->base()| and is effectively const after
  // initialization since the backing memory is never reallocated.
  Entry* base_ = nullptr;

  // The virtual address space backing this table.
  // This is used to manage the underlying OS pages, in particular to allocate
  // and free the segments that make up the table.
  VirtualAddressSpace* vas_ = nullptr;
};
```

So if we overwrite `base_` of the `CodePointerTable` to attacker-controlled region together with the `Sandbox` region, we can point the whole code table into attacker-controlled region. Triggering a call through a code pointer within `CodePointerTable` will now grant PC control - `JSEntry()` is one of the many examples of such, easily triggered by asynchronously resolving from `setTimeout()` or even with a simple `console.log({})`. Note that the address must be XORed with appropriate tags for versions affected with CodeEntrypointTag (commit [aae8ec28](https://chromium.googlesource.com/v8/v8/+/aae8ec28b2e2ba8be993cb372cc7c0907f602b1e)).

```cpp
V8_WARN_UNUSED_RESULT MaybeHandle<Object> Invoke(Isolate* isolate,
                                                 const InvokeParams& params) {
  // ...
  // Placeholder for return value.
  Tagged<Object> value;
  Handle<Code> code =
      JSEntry(isolate, params.execution_target, params.is_construct);
  {
    // ...
    if (params.execution_target == Execution::Target::kCallable) {
      // ...
      using JSEntryFunction = GeneratedCode<Address(
          Address root_register_value, Address new_target, Address target,
          Address receiver, intptr_t argc, Address** argv)>;
      // clang-format on
      JSEntryFunction stub_entry =
          JSEntryFunction::FromAddress(isolate, code->instruction_start());     // [!] using pointer from our forged table

      Address orig_func = (*params.new_target).ptr();
      Address func = (*params.target).ptr();
      Address recv = (*params.receiver).ptr();
      Address** argv = reinterpret_cast<Address**>(params.argv);
      RCS_SCOPE(isolate, RuntimeCallCounterId::kJS_Execution);
      value = Tagged<Object>(
          stub_entry.Call(isolate->isolate_data()->isolate_root(), orig_func,
                          func, recv, JSParameterCount(params.argc), argv));
    } else {
      // ...
      using JSEntryFunction = GeneratedCode<Address(
          Address root_register_value, MicrotaskQueue* microtask_queue)>;
      // clang-format on
      JSEntryFunction stub_entry =
          JSEntryFunction::FromAddress(isolate, code->instruction_start());     // [!] using pointer from our forged table

      RCS_SCOPE(isolate, RuntimeCallCounterId::kJS_Execution);
      value = Tagged<Object>(stub_entry.Call(
          isolate->isolate_data()->isolate_root(), params.microtask_queue));
    }
  }
  // ...
}

DEF_GETTER(Code, instruction_start, Address) {
#ifdef V8_ENABLE_SANDBOX
  return ReadCodeEntrypointViaCodePointerField(kSelfIndirectPointerOffset,
                                               entrypoint_tag());
#else
  return ReadField<Address>(kInstructionStartOffset);
#endif
}

constexpr int kCodeEntrypointTagShift = 48;
enum CodeEntrypointTag : uint64_t {
  // TODO(saelo): eventually, we'll probably want to remove the default tag.
  kDefaultCodeEntrypointTag = 0,
  // TODO(saelo): give these unique tags.
  kJSEntrypointTag = kDefaultCodeEntrypointTag,
  kWasmEntrypointTag = kDefaultCodeEntrypointTag,
  kBytecodeHandlerEntrypointTag = uint64_t{1} << kCodeEntrypointTagShift,
  kICHandlerEntrypointTag = uint64_t{2} << kCodeEntrypointTagShift,
  kRegExpEntrypointTag = uint64_t{3} << kCodeEntrypointTagShift,
  // TODO(saelo): create more of these tags.

  // Tag to use for code that will never be called indirectly via the CPT.
  kInvalidEntrypointTag = uint64_t{0xff} << kCodeEntrypointTagShift,            // [!] tag for JSEntry
  // Tag used internally by the code pointer table to mark free entries.
  kFreeCodePointerTableEntryTag = uint64_t{0xffff} << kCodeEntrypointTagShift,  // [!] tag for JSEntry (before commit 0c86ce84)
};
```

> Note that overwriting something in front of `Sandbox` is required for Chrome to exploit the vulnerability deterministically ("deterministically" meaning 100% exploit chance in a single run without crashing), as there are no leading NULL directly in front of the `Sandbox` object in memory. Alternatively one could bruteforce sandbox region until there are consecutive 8 zero bytes, but crashing in Windows sometimes take a long time to recover :(
>
> In Edge, there already is a NULL in front of `Sandbox`, but to make the exploit equivalent with Chrome the same technique is applied.

Although we have PC control, there are no controlled registers pointing to attacker-controlled ArrayBuffer partition address. We can use an intermediate gadget that loads a value from .data section and then calls/jumps from a pointer again on .data section. Chaining this with a stack pivot gadget we can trigger our ropchain.

The intermediate gadget that I've used looks like `mov rax, [rip+0x..]; mov rcx, [rax+(0x18 or 0x20)]; mov edx, ebx; mov r8, rdi; mov r9, rsi; call qword ptr [rip+0x..];`, and the pivot gadget called is `push rax ; pop rsp ; ret`. Both the `rip`-relative address points to .data, has NULL within several bytes in front of it, and can be overwritten without adverse side effects. Both the gadgets exist on all Windows x64 Chrome and Edge release builds that I have tested on.

This completes the arbitrary code execution - ROP to call `VirtualProtect()` and return to shellcode.


## Affected Version

From M114 up to latest. The API exposing the vulnerable pattern has existed for a long time (from commit [b5c917ee](https://chromium.googlesource.com/v8/v8.git/+/b5c917ee80cbf33b18a96bfcf67a4c598ea85722)), but has seemingly only surfaced in M105 gated behind `--harmony-rab-gsab-transfer` flag and later enabled by default in M114 due to the introduction of `ArrayBuffer.prototype.transfer` and its variants (commit [6387763c](https://chromium.googlesource.com/v8/v8.git/+/6387763c67f958f1b67b6da2e84c147dab0f4a78)).


## Fix

1. Synchronize DOMArrayBuffer with JSArrayBuffer, using any one of the approaches below:
   1. Wrap the JSArrayBuffer itself instead of BackingStore within ArrayBufferContents and use it to fetch the backing store
      - Once detached either from V8 or Blink, its detached state will be visible to both
   2. Verify detached state on `ToBlinkValue` (`ToDOMArrayBuffer()`), rejecting if detached before returning cached DOMArrayBuffer
   3. Proxy detach on BackingStores to its embedder's appropriate detach function, i.e. `DOMArrayBuffer::Transfer()`
      - This partially introduces embedder logic into V8, which may not be desirable. However the detachment logic of JSArrayBuffer and DOMArrayBuffer varies quite a bit (especially with detaching `AccumulateArrayBuffersForAllWorlds()`), so this may be a safer approach
2. (Mitigation) Mitigate UAF on PartitionAlloc by implementing read-only metadata, i.e. [ShadowMetadata](https://bugs.chromium.org/p/chromium/issues/detail?id=1362969)
   - As shown in the exploit section, metadata corruption is still an extremely strong technique that trivially bypasses all heap sandbox mitigations
   - For a stopgap patch, verify that freelist head points inside of the corresponding super span's non-metadata region. This is likely to incur overheads, but likely less than that of ShadowMetadata
     - Bypasses expected with this approach, for example by exploiting forged `bucket` or `next_slot_span` pointer
3. (Mitigation) Make `Sandbox`, `CodePointerTable` and a bunch of security-critical data structures read-only after init
   - This may incur heavy overhead for frequently changing data structures
   - Alternatively, for all pointers that are eventually CHECK()ed to be within the sandbox, hoist the CHECK() into (re)allocation code
