← All reports

[6] WebXRSystem re-posts a completion handler past the reference that protected it

MediumWebCore WebXRUAF

2b68a68

Medium. The read is real, and the permission path adds a one-byte store into freed storage — but reaching either requires the iframe detached and its wrapper collected inside the window between the reply lambda's release and the queued main-thread task, a window the page influences without directly timing.

Object lifetime across an asynchronous callback boundary is normally kept safe by having the callback carry its own strong reference to the receiver, so the object cannot be destroyed while a reply is in flight. WebXRSystem — the implementation of navigator.xr, installed as a supplement on a frame's Navigator — brokers device enumeration and session requests to the platform XR stack through asynchronous ChromeClient calls. Its enumeration reply lambda captures protectedThis, a strong reference that keeps the system alive for exactly as long as the reply body runs.

The angle: a page that detaches an iframe mid-enumeration and lets its navigator.xr wrapper be collected can have the re-posted continuation read device state out of freed memory, and on the session-request path write into it.

Three call sites and one guard change. obtainCurrentDevice and isSessionSupported, which call ensureImmersiveXRDeviceIsSelected directly, previously captured only raw this in the completion handlers they supply; both now carry their own strong reference. requestSession reaches the same re-post one level deeper — the lambda it holds a protectedThis for is the completion handler it hands to obtainCurrentDevice, which is the callback that obtainCurrentDevice's continuation forwards — and gets the same treatment. Separately, the resolveFeaturePermissions reply lambda's combined if (!weakThis || !requestedFeatures) guard is split, so the receiver-dead branch no longer falls into the body that performs m_pendingImmersiveSession = false.

A strong reference scoped to the reply body rather than to the deferred task that reply body creates, so ownership stops one hop short of the code that dereferences the receiver.

navigator.xr and supplements. WebXRSystem is an ActiveDOMObject and EventTarget installed as a Navigator supplement through NavigatorWebXR. The strong owner is NavigatorWebXR::m_xr, a RefPtr<WebXRSystem> per NavigatorWebXR.h, reachable from a frame's navigator.xr.

Device selection flow. ensureImmersiveXRDeviceIsSelected dispatches an enumerateImmersiveXRDevices call through Chrome::client() and receives a reply lambda when enumeration finishes.

makeScopeExit and callOnMainThread. makeScopeExit runs a callable when the enclosing scope unwinds, whatever the exit path. callOnMainThread posts a callable onto the main run loop as a separate task — it does not run inline, so the posting scope has already unwound by the time the task executes.

Ref, WeakPtr, and ThreadSafeWeakPtr. A Ref/RefPtr keeps its referent alive; a WeakPtr does not and evaluates false once the referent is destroyed. ThreadSafeWeakPtr<PlatformXR::Device> — the type of m_activeImmersiveDevice — is the cross-thread variant, promoted to a RefPtr at the point of use.

The root cause is that ownership was propagated one hop short. The reply lambda for enumerateImmersiveXRDevices captures protectedThis = protect(*this), guaranteeing the WebXRSystem is alive for the duration of the reply. But inside that reply, a makeScopeExit does callOnMainThread(WTF::move(callback)) — the caller-supplied CompletionHandler is moved out and re-posted as its own task. Once the reply lambda is destroyed, its protectedThis releases, and the re-posted task runs with whatever ownership the caller's callback happens to carry. Both direct callers carried raw this.

  ensureImmersiveXRDeviceIsSelected()
    +- ChromeClient::enumerateImmersiveXRDevices(reply)
         reply lambda: [protectedThis = protect(*this)]  -- strong --+
  reply runs on main thread                                          |
    +- makeScopeExit: callOnMainThread(WTF::move(callback))          |
         (caller's CompletionHandler re-posted as its own task)      |
  reply lambda destroyed ---------------------------------------------+
    protectedThis released              <-- failure window opens
  iframe detached; wrapper collected
    NavigatorWebXR::m_xr RefPtr dropped -> WebXRSystem freed
  queued task runs
    reads m_activeImmersiveDevice out of freed storage

The missing invariant is general: a callable forwarded past the lifetime of the frame that protects the receiver must carry its own strong reference. The Ref in the reply lambda protects the wrong scope — it covers the reply body, not the deferred task the reply body creates.

Detaching the iframe and collecting its JS wrapper is expected to destroy the Navigator supplement and drop the RefPtr<WebXRSystem>. If that happens after the reply lambda's protectedThis releases but before the queued callOnMainThread task runs, the task dereferences freed storage. Concretely: isSessionSupported's continuation read m_activeImmersiveDevice out of the dead object and constructed a RefPtr from it, and obtainCurrentDevice's continuation read the same member and passed it onward.

The resolveFeaturePermissions defect is independent and sits in the same function. That reply lambda held weakThis = WeakPtr { *this } and raw this. The combined guard if (!weakThis || !requestedFeatures) funnelled both the receiver-is-dead case and the permission-denied case into one body, and that body executed m_pendingImmersiveSession = false — a store at a fixed offset into the WebXRSystem allocation, performed precisely in the branch where the null weakThis proves the object no longer exists. The null check was present; its failure branch still touched members.

So the reachable primitives are a read of freed storage on the enumeration paths and a one-byte write at a fixed offset into a freed allocation on the permission path. Exploiting either requires landing a controlled allocation in the freed storage between the release and the queued task, which the page can attempt through allocation pressure but cannot directly synchronise with — the interval is bounded by main-run-loop scheduling rather than by anything script sequences. The fixed-offset write is the more useful of the two if the reclaiming object's layout can be arranged.

This vulnerability weakens the lifetime guarantee that page script expects across the XR device-selection boundary: an object whose owning frame has been torn down and collected remains addressable, and writable, from work the frame scheduled before it died.