Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in WebXR
DescriptionUse after free in WebXR
ComponentWebXR
Bug ClassUAF
Tracker523477987
Fix commit83cf1954a93e (chromium/src) +13/-11
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Files Changed

  • third_party/blink/renderer/modules/xr/xr_frame_request_callback_collection.cc
  • third_party/blink/renderer/modules/xr/xr_frame_request_callback_collection.h
  • third_party/blink/renderer/modules/xr/xr_session.cc
  • third_party/blink/renderer/modules/xr/xr_session.h
  • third_party/blink/renderer/modules/xr/xr_session.idl
From 83cf1954a93e31ff76a5979fafcc3b80eefab572 Mon Sep 17 00:00:00 2001
From: Alexander Cooper <[email protected]>
Date: Thu, 18 Jun 2026 10:23:45 -0700
Subject: [PATCH] Fix WebXR callback ID wrap-around behavior

Implement a safe incrementing loop in `XRFrameRequestCallbackCollection`
to avoid using reserved hash map keys (0 and Max) which could lead to
corruption when the ID wraps around.

While investigating the proper fix for this, it was noticed that per the
WebXR spec the callback ID type should be `unsigned long` in the IDL
instead of `long`. This CL also updates the IDL to use `unsigned long`
and the corresponding C++ code to use `uint32_t` instead of `int`.

Fixed: 523477987
Change-Id: I99e329b7ee2d2d7a601aa8f061be5f706625a468
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7959900
Commit-Queue: Brandon Jones <[email protected]>
Reviewed-by: Brandon Jones <[email protected]>
Auto-Submit: Alexander Cooper <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1649153}
---

diff --git a/third_party/blink/renderer/modules/xr/xr_frame_request_callback_collection.cc b/third_party/blink/renderer/modules/xr/xr_frame_request_callback_collection.cc
index 8450c8b2..69cf88f 100644
--- a/third_party/blink/renderer/modules/xr/xr_frame_request_callback_collection.cc
+++ b/third_party/blink/renderer/modules/xr/xr_frame_request_callback_collection.cc
@@ -22,7 +22,9 @@
 XRFrameRequestCallbackCollection::CallbackId
 XRFrameRequestCallbackCollection::RegisterCallback(
     V8XRFrameRequestCallback* callback) {
-  CallbackId id = ++next_callback_id_;
+  while (!IsValidCallbackId(++previous_callback_id_)) {
+  }
+  CallbackId id = previous_callback_id_;
   TRACE_EVENT_BEGIN("xr", "frameRequest", perfetto::Track(trace_id_base_ + id));
   auto add_result_frame_request = callback_frame_requests_.Set(id, callback);
   auto add_result_async_task = callback_async_tasks_.Set(
diff --git a/third_party/blink/renderer/modules/xr/xr_frame_request_callback_collection.h b/third_party/blink/renderer/modules/xr/xr_frame_request_callback_collection.h
index 66618ce..5695239d 100644
--- a/third_party/blink/renderer/modules/xr/xr_frame_request_callback_collection.h
+++ b/third_party/blink/renderer/modules/xr/xr_frame_request_callback_collection.h
@@ -32,7 +32,7 @@
   explicit XRFrameRequestCallbackCollection(ExecutionContext*);
   ~XRFrameRequestCallbackCollection() override = default;
 
-  using CallbackId = int;
+  using CallbackId = uint32_t;
   CallbackId RegisterCallback(V8XRFrameRequestCallback*);
   void CancelCallback(CallbackId);
   void ExecuteCallbacks(XRSession*, double timestamp, XRFrame*);
@@ -48,7 +48,7 @@
   }
 
  private:
-  bool IsValidCallbackId(int id) {
+  bool IsValidCallbackId(CallbackId id) {
     using Traits = HashTraits<CallbackId>;
     return !IsHashTraitsEmptyOrDeletedValue<Traits, CallbackId>(id);
   }
@@ -66,7 +66,7 @@
   CallbackFrameRequestMap current_callback_frame_requests_;
   CallbackAsyncTaskMap current_callback_async_tasks_;
 
-  CallbackId next_callback_id_ = 0;
+  CallbackId previous_callback_id_ = 0;
 
   // Trace IDs need to be unique for any outstanding frames. While we can only
   // have one immersive session at a time, that is not the case for inline
diff --git a/third_party/blink/renderer/modules/xr/xr_session.cc b/third_party/blink/renderer/modules/xr/xr_session.cc
index f9b8e1c..f7be96a 100644
--- a/third_party/blink/renderer/modules/xr/xr_session.cc
+++ b/third_party/blink/renderer/modules/xr/xr_session.cc
@@ -983,7 +983,7 @@
     std::move(callback).Run(timestamp);
 }
 
-int XRSession::requestAnimationFrame(V8XRFrameRequestCallback* callback) {
+uint32_t XRSession::requestAnimationFrame(V8XRFrameRequestCallback* callback) {
   DVLOG(3) << __func__;
 
   TRACE_EVENT0("gpu", "requestAnimationFrame");
@@ -991,12 +991,12 @@
   if (ended_)
     return 0;
 
-  int id = callback_collection_->RegisterCallback(callback);
+  uint32_t id = callback_collection_->RegisterCallback(callback);
   MaybeRequestFrame();
   return id;
 }
 
-void XRSession::cancelAnimationFrame(int id) {
+void XRSession::cancelAnimationFrame(uint32_t id) {
   callback_collection_->CancelCallback(id);
 }
 
diff --git a/third_party/blink/renderer/modules/xr/xr_session.h b/third_party/blink/renderer/modules/xr/xr_session.h
index e0df056..76d7503e 100644
--- a/third_party/blink/renderer/modules/xr/xr_session.h
+++ b/third_party/blink/renderer/modules/xr/xr_session.h
@@ -253,8 +253,8 @@
   // available, the method returns nullopt.
   std::optional<ReferenceSpaceInformation> GetStationaryReferenceSpace() const;
 
-  int requestAnimationFrame(V8XRFrameRequestCallback* callback);
-  void cancelAnimationFrame(int id);
+  uint32_t requestAnimationFrame(V8XRFrameRequestCallback* callback);
+  void cancelAnimationFrame(uint32_t id);
 
   XRInputSourceArray* inputSources(ScriptState*) const;
 
diff --git a/third_party/blink/renderer/modules/xr/xr_session.idl b/third_party/blink/renderer/modules/xr/xr_session.idl
index 97b1caa..c465648f 100644
--- a/third_party/blink/renderer/modules/xr/xr_session.idl
+++ b/third_party/blink/renderer/modules/xr/xr_session.idl
@@ -72,8 +72,8 @@
   [RuntimeEnabled=WebXRFrameRate, RaisesException] Promise<undefined> updateTargetFrameRate(float rate);
   [CallWith=ScriptState, RaisesException] Promise<XRReferenceSpace> requestReferenceSpace(XRReferenceSpaceType type);
 
-  long requestAnimationFrame(XRFrameRequestCallback callback);
-  void cancelAnimationFrame(long handle);
+  unsigned long requestAnimationFrame(XRFrameRequestCallback callback);
+  void cancelAnimationFrame(unsigned long handle);
 
   [CallWith=ScriptState, Measure, RaisesException] Promise<undefined> end();
 
Loading diff…

Original Bug Report

reported by [email protected]

Potential UAF in XRFrameRequestCallbackCollection via Integer Overflow

Flapjack, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: An integer overflow in XRFrameRequestCallbackCollection can cause a callback ID to reach -1, a reserved sentinel value in Blink’s hash maps. This collision causes the garbage collector to skip tracing the callback object, prematurely freeing it. When the WebXR frame executes, it successfully retrieves and uses the dangling pointer, resulting in a potential Use-After-Free.

Affected files:

  • third_party/blink/renderer/modules/xr/xr_frame_request_callback_collection.cc
  • third_party/blink/renderer/modules/xr/xr_frame_request_callback_collection.h

Estimated timestamp from git blame: Unknown (Google3 checkout)

Summary

A potential Use-After-Free (UAF) vulnerability exists in XRFrameRequestCallbackCollection due to an unchecked integer overflow of the next_callback_id_ counter. When this 32-bit signed integer overflows and eventually reaches the value -1, it collides with the DeletedValue sentinel used by Blink’s HeapHashMap. This causes the garbage collector to skip tracing the associated callback object, leaving a dangling pointer in the map which is subsequently accessed during frame execution.

Technical Details

In third_party/blink/renderer/modules/xr/xr_frame_request_callback_collection.cc, the RegisterCallback method generates a new ID for each requestAnimationFrame callback using a 32-bit signed integer:

XRFrameRequestCallbackCollection::CallbackId
XRFrameRequestCallbackCollection::RegisterCallback(
    V8XRFrameRequestCallback* callback) {
  CallbackId id = ++next_callback_id_;
  // ...
  auto add_result_frame_request = callback_frame_requests_.Set(id, callback);
  pending_callbacks_.push_back(id);
  return id;
}

An attacker can overflow next_callback_id_ by repeatedly registering and canceling WebXR callbacks. By spreading these requests across multiple frames, the attacker avoids Out-Of-Memory (OOM) crashes, as pending_callbacks_ is cleared at the start of every frame and canceled callbacks are garbage collected.

When the counter wraps around and becomes -1, the ID is inserted into callback_frame_requests_. Because CallbackId is an int, Blink uses WTF::IntHashTraits<int>, which reserves 0 as the EmptyValue and -1 as the DeletedValue. Inserting -1 corrupts the internal state of the HeapHashMap, as the bucket is implicitly marked as deleted.

During Garbage Collection, TraceHashTableBackingInCollectionTrait::Trace (third_party/blink/renderer/platform/heap/collection_support/heap_hash_table_backing.h) iterates the map’s backing store to trace live objects. It explicitly skips buckets whose keys match the empty or deleted sentinels:

if (!IsHashTraitsEmptyOrDeletedValue<typename Table::KeyTraitsType>(
        Extractor::ExtractKey(UNSAFE_TODO(array[i])))) {
  blink::TraceCollectionIfEnabled<weak_handling, Value, Traits>::Trace(
      visitor, &UNSAFE_TODO(array[i]));
}

Because the attacker’s bucket has the key -1, IsHashTraitsEmptyOrDeletedValue(-1) evaluates to true. Oilpan skips tracing the Member<V8XRFrameRequestCallback> pointer, assumes the object is unreachable, and sweeps it. The bucket now contains a dangling pointer.

When XRFrameRequestCallbackCollection::ExecuteCallbacks runs for the frame, it iterates over pending_callbacks_ (which contains -1) and calls current_callback_frame_requests_.find(-1). For integer keys, kSafeToCompareToEmptyOrDeleted is true, causing HashTable::Lookup to bypass standard sentinel checks and return the bucket if the key exactly matches. The code then executes it_frame_request->value->InvokeAndReportException(session, timestamp, frame);, resulting in a virtual method call on a freed object.

Potential Attack Steps

(Note: These are suggested steps; our tooling agent does not yet have the ability to run code.)

  1. Initialize Session: An attacker initiates a WebXR session via navigator.xr.requestSession('inline').
  2. Flood and Cancel: The attacker runs a loop in Javascript that repeatedly calls session.requestAnimationFrame(callback) and session.cancelAnimationFrame(id).
  3. Span Frames: The loop is spread across multiple animation frames (e.g., 100,000 iterations per frame) to clear pending_callbacks_ and allow GC of canceled wrappers, avoiding OOM.
  4. Overflow: After $2^{32} - 1$ calls, the internal counter overflows to -1. The -1 callback is registered but not canceled.
  5. Trigger GC: The attacker triggers garbage collection (via memory pressure or natural GC cycles), causing the -1 callback object to be freed.
  6. Trigger UAF: The browser renders the next WebXR frame, triggering ExecuteCallbacks, retrieving the dangling pointer, and executing the UAF.

Suggested Fix

Ensure that next_callback_id_ never takes on reserved sentinel values (such as 0 and -1). This can be achieved by skipping these values during increment:

CallbackId id;
do {
  id = ++next_callback_id_;
} while (id <= 0); // or !IsValidCallbackId(id)

Alternatively, change CallbackId to an unsigned 64-bit integer, which realistically cannot overflow.

Evaluated with Chrome root at commit: 65b3256311f3ab6fb9870eaa522de7e6dd2663bb


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker