← Chrome — 2026-W03

29670ccd49b957beff8ab112471bf540c9bd6c0f  Reduce reallocations of Xr depth buffers

severity medium class detached-arraybuffer-access CVSS 6.5 confidence 0.5 signal score 3.8 third_party/blink/renderer AI review CONFIRMED
Primitive: The depth buffer became a longer-lived cached object; without a detach check, script could obtain a DOMArrayBuffer that was already detached (backing store gone). Fix throws on IsDetached() and guards recreate-vs-copy.
Triage note: Potential use of a detached ArrayBuffer backing store, but gated behind WebXR depth-sensing (immersive session + permission), not default-reachable. watch.

The bug at a glance

Honestly watch-tier. The primary purpose of this CL is a memory/GC optimization: stop reallocating the WebXR CPU-depth DOMArrayBuffer every frame and instead copy pixel bytes into a retained buffer. The optimization introduces a new lifetime property\u2014the DOMArrayBuffer is now long-lived and handed to script across frames\u2014so the same CL adds the guards that keep that new property safe: an IsDetached() throw in the getter and an IsDetached()/size-mismatch recreate check before the in-place copy. Crucially, the risky state (reusing a possibly-detached buffer) never shipped without its guard: the old code recreated the buffer unconditionally, so no detached-access bug existed in released Chrome. And the worst case for the copy path if a guard were absent is a base::span size-checked copy_from that CHECK-fails on a zero-length detached backing store\u2014a controlled crash, not memory corruption. The getter returning a detached buffer to script is a spec/logic correctness issue (should throw InvalidStateError), not memory unsafety. Per the tasking, this is logic/policy with no demonstrated memory unsafety: severity low, cvss null.

The interesting move is that a performance change converts a per-frame throwaway object into a cached, script-observable object, and script can detach a DOMArrayBuffer it holds (via structured-clone transfer). Any optimization that starts reusing a buffer previously recreated each frame must re-establish detach and size invariants that the throwaway design got for free. This CL is a clean example of doing that defensively in the same change; the audit angle is to find the cases where such an optimization landed without the accompanying guard.

Root cause

XRDepthManager holds depth_data_ (raw Mojo pixel data for the frame) and data_ (a cached DOMArrayBuffer exposing those bytes to script via XRCPUDepthInformation). Previously ProcessDepthInformation reset both depth_data_ and data_ to null on every update, and EnsureData recreated data_ = DOMArrayBuffer::Create(depth_data_->pixel_data) from scratch whenever data_ was null\u2014so every access to the depth data allocated a fresh buffer, causing the heavy GC churn developers reported. The new design adds a data_dirty_ flag: ProcessDepthInformation sets data_dirty_ when the underlying depth data actually changes (new buffer, or transitioning away from valid data) and leaves data_ intact otherwise. EnsureData now, when dirty, reuses data_ by copying bytes in place (data_->ByteSpan().copy_from(depth_data_->pixel_data)) unless data_ is null, detached, or a different size, in which case it recreates. Because data_ is now retained and returned to script across frames, script can transfer/detach it; the getter in xr_cpu_depth_information.cc therefore adds a data_->IsDetached() check that throws kArrayBufferDetached (InvalidStateError) instead of returning a dead buffer, and EnsureData’s IsDetached() branch prevents copying into a detached backing store.

Key code

Reuse-vs-recreate guard added to EnsureData when data_ became a long-lived, script-detachable buffer

-  // Copy the pixel data into ArrayBuffer:
-  data_ = DOMArrayBuffer::Create(depth_data_->pixel_data);
+  // Recreate `data_` if needed, otherwise just copy the bits over to prevent
+  // a reallocation.
+  if (!data_ || data_->IsDetached() ||
+      data_->ByteSpan().size() != depth_data_->pixel_data.size()) {
+    data_ = DOMArrayBuffer::Create(depth_data_->pixel_data);
+  } else {
+    data_->ByteSpan().copy_from(depth_data_->pixel_data);
+  }
+
+  data_dirty_ = false;

Patch walkthrough

  • third_party/blink/renderer/modules/xr/xr_cpu_depth_information.cc — Adds a detached-buffer guard to the accessor that returns data_ to script: if data_->IsDetached(), throw a kInvalidStateError DOMException with kArrayBufferDetached and return nullptr, instead of handing back a detached (zero-length) buffer. This is the script-facing correctness/policy half of the change and is only necessary because data_ is now retained across frames and can be transferred/detached by the page.
  • third_party/blink/renderer/modules/xr/xr_depth_manager.cc — ProcessDepthInformation is restructured: instead of unconditionally nulling depth_data_ and data_ up front, it now sets a data_dirty_ flag only when the depth data actually changes\u2014kDataStillValid marks dirty if there was prior data then drops depth_data_, kUpdatedDepthData stores the new Mojo buffer and marks dirty, and the no-data else branch marks dirty if there was prior data. EnsureData upgrades DCHECK(depth_data_) to CHECK(depth_data_), returns early when !data_dirty_ (the reuse fast path), and otherwise recreates data_ only if it is null, detached, or size-mismatched, else copies bytes in place via ByteSpan().copy_from; it clears data_dirty_ at the end.
  • third_party/blink/renderer/modules/xr/xr_depth_manager.h — Adds bool data_dirty_ = true; next to Member data_. Default-true ensures the first EnsureData after construction always materializes the buffer. This member is the mechanism that lets data_ persist across frames without serving stale bytes.

Background

XRCPUDepthInformation — WebXR interface exposing per-frame CPU-readable depth data to script as an ArrayBuffer (the .data attribute), used by AR apps for occlusion/hit-testing.

DOMArrayBuffer detach — A DOMArrayBuffer can be neutered/detached when its backing store is transferred (e.g. postMessage with a transfer list) or otherwise given away. A detached buffer has a zero-length ByteSpan and must not be read/written as if live. IsDetached() reports this state.

base::span::copy_from — A bounds-checked copy that CHECK-fails if source and destination sizes differ. Copying depth pixels into a detached (size-0) buffer via copy_from would abort rather than corrupt memory, which is why the missing-guard worst case here is a crash, not OOB.

data_dirty_ — New bool tracking whether the cached DOMArrayBuffer’s contents are stale relative to depth_data_. It replaces the old ‘data_ == null means recompute’ signal so the buffer can be retained while still being refreshed only when the underlying data changes.

Vulnerability window

  1. motivation — Developers reported large GC pressure when accessing WebXR depth data, because the DOMArrayBuffer was fully recreated every frame (issue 475599515).
  2. change — 2026-01-15, main@{#1570008}: Alexander Cooper reworks XRDepthManager to reuse the buffer via data_dirty_ and in-place copy, and\u2014because the buffer is now long-lived\u2014adds detach guards in both the getter and EnsureData. Reviewed by Brandon Jones.
  3. assessment — No exploit, no test for the detach path, no CVE. The detach handling is preventive: the unguarded reuse path never shipped, so this closes a would-be regression rather than a live bug.

Proof of concept

No test shipped and no exploit is demonstrable, because the vulnerable configuration (reusing a detached buffer) never existed in released code\u2014the guards land in the same CL that introduces reuse. A conceptual reachability check on a hypothetically unguarded build: in a WebXR AR session with the depth-sensing feature, on an animation frame obtain XRCPUDepthInformation.data, transfer it via postMessage(buf, [buf.buffer]) to detach it, then on a subsequent frame trigger EnsureData again; without the IsDetached()/size check the code would attempt data_->ByteSpan().copy_from(pixel_data) into the detached buffer, which\u2014because copy_from is size-checked against a zero-length span\u2014would CHECK-fail (renderer abort), not corrupt memory. The getter without its guard would instead return a detached, zero-length buffer to script (a spec violation). Neither is a memory-corruption primitive and no exploit code is provided or warranted.

Exploitation

  1. prerequisite — Requires an active WebXR immersive session with depth-sensing granted (user gesture + XR permission + capable device/emulator). This is a high-friction, permission-gated surface, further limiting practical impact.
  2. trigger (hypothetical, unshipped) — Script detaches the depth ArrayBuffer between frames. On a build lacking the guards, the reuse path would copy into a detached span (CHECK/crash) or return a dead buffer (logic bug). Shipped builds throw InvalidStateError from the getter and recreate on detach in EnsureData.
  3. impact — At most a renderer DoS (controlled CHECK) or a spec-conformance glitch; no path to corruption is shown. With the guards in place there is no impact.

Detection & hunting

For defenders and detection engineers:

  • detached-buffer exceptions in XR —
  • copy_from CHECK crashes —

Audit directions

  • other per-frame-to-cached buffer optimizations —
  • XRDepthManager copy correctness —
  • script-side detach races —

Before / after

Loading diff…