Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in WebGL
DescriptionInappropriate implementation in WebGL
ComponentWebGL
Bug ClassLogic Error
Tracker523752265
Fix commit54c05aecf056 (chromium/src) +68/-21
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-08

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc
modified
for
third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc
modified

Files Changed

  • third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc
  • third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.h
  • third_party/blink/web_tests/wpt_internal/webxr/webGLCanvasContext_makecompatible_isolated_world.https.html
From 54c05aecf0562998a91d183518c48250816d43a2 Mon Sep 17 00:00:00 2001
From: Brandon Jones <[email protected]>
Date: Wed, 01 Jul 2026 09:37:05 -0700
Subject: [PATCH] WebGL: use per-caller resolvers for makeXRCompatible()

makeXRCompatible() cached a single ScriptPromiseResolver while the async
XR-compatible request was in flight and returned that resolver's promise
to every subsequent caller. Because a single WebGLRenderingContextBase
is shared by every world that wraps a given canvas, a caller from a
different world could be handed a Promise that didn't belong to its
world.

Track all pending callers in a HeapVector instead, creating a fresh
resolver bound to the caller's ScriptState for each call and
resolving/rejecting them together when the in-flight request completes
(the same pattern as HTMLMediaElement's play() resolvers). Add a
wpt_internal test that calls makeXRCompatible() from an isolated world
while a main-world request is pending and checks that the returned
Promise belongs to the calling world.

(Patch and description provided by Project Fortify)

Fixed: 523752265
Change-Id: I9f5bf2d79cc4ee1ae8564f13175bbba2af0f58b2
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8025784
Reviewed-by: Alexander Cooper <[email protected]>
Commit-Queue: Brandon Jones <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1655552}
---

diff --git a/third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc b/third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc
index 71c1cded..c17bb34 100644
--- a/third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc
+++ b/third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc
@@ -1008,16 +1008,17 @@
   if (xr_compatible_)
     return ToResolvedUndefinedPromise(script_state);
 
-  // If there's a request currently in progress, return the same promise.
-  if (make_xr_compatible_resolver_)
-    return make_xr_compatible_resolver_->Promise();
+  auto* resolver = MakeGarbageCollected<ScriptPromiseResolver<IDLUndefined>>(
+      script_state, exception_state.GetContext());
+  auto promise = resolver->Promise();
 
-  make_xr_compatible_resolver_ =
-      MakeGarbageCollected<ScriptPromiseResolver<IDLUndefined>>(
-          script_state, exception_state.GetContext());
-  auto promise = make_xr_compatible_resolver_->Promise();
-
-  MakeXrCompatibleAsync();
+  // If there's a request currently in progress, share its result; otherwise
+  // start a new one.
+  bool request_in_progress = !make_xr_compatible_resolvers_.empty();
+  make_xr_compatible_resolvers_.push_back(resolver);
+  if (!request_in_progress) {
+    MakeXrCompatibleAsync();
+  }
 
   return promise;
 }
@@ -1117,17 +1118,16 @@
 
 void WebGLRenderingContextBase::CompleteXrCompatiblePromiseIfPending(
     DOMExceptionCode exception_code) {
-  if (make_xr_compatible_resolver_) {
+  HeapVector<Member<ScriptPromiseResolver<IDLUndefined>>> resolvers;
+  resolvers.swap(make_xr_compatible_resolvers_);
+  for (auto& resolver : resolvers) {
     if (xr_compatible_) {
       DCHECK(exception_code == DOMExceptionCode::kNoError);
-      make_xr_compatible_resolver_->Resolve();
+      resolver->Resolve();
     } else {
       DCHECK(exception_code != DOMExceptionCode::kNoError);
-      make_xr_compatible_resolver_->Reject(
-          MakeGarbageCollected<DOMException>(exception_code));
+      resolver->Reject(MakeGarbageCollected<DOMException>(exception_code));
     }
-
-    make_xr_compatible_resolver_ = nullptr;
   }
 }
 
@@ -7610,10 +7610,9 @@
     tracker->LoseExtension(false);
   }
 
-  // This resolver is non-null during a makeXRCompatible call, while waiting
-  // for a response from the browser and XR process. If the WebGL context is
-  // lost before we get a response, the resolver has to be rejected to be
-  // be properly disposed of.
+  // makeXRCompatible() resolvers are pending while waiting for a response from
+  // the browser and XR process. If the WebGL context is lost before we get a
+  // response, the resolvers have to be rejected to be properly disposed of.
   xr_compatible_ = false;
   CompleteXrCompatiblePromiseIfPending(DOMExceptionCode::kInvalidStateError);
 
@@ -9312,7 +9311,7 @@
   visitor->Trace(texture_units_);
   visitor->Trace(extensions_);
   visitor->Trace(buffers_);
-  visitor->Trace(make_xr_compatible_resolver_);
+  visitor->Trace(make_xr_compatible_resolvers_);
   visitor->Trace(program_completion_query_list_);
   visitor->Trace(program_completion_query_map_);
   WebGLContextObjectSupport::Trace(visitor);
diff --git a/third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.h b/third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.h
index fdecf214..2aed30c 100644
--- a/third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.h
+++ b/third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.h
@@ -885,7 +885,8 @@
       device::mojom::blink::XrCompatibleResult xr_compatible_result);
   void CompleteXrCompatiblePromiseIfPending(DOMExceptionCode exception_code);
   bool xr_compatible_;
-  Member<ScriptPromiseResolver<IDLUndefined>> make_xr_compatible_resolver_;
+  HeapVector<Member<ScriptPromiseResolver<IDLUndefined>>>
+      make_xr_compatible_resolvers_;
 
   HeapVector<TextureUnitState> texture_units_;
   wtf_size_t active_texture_unit_;
diff --git a/third_party/blink/web_tests/wpt_internal/webxr/webGLCanvasContext_makecompatible_isolated_world.https.html b/third_party/blink/web_tests/wpt_internal/webxr/webGLCanvasContext_makecompatible_isolated_world.https.html
new file mode 100644
index 0000000..2b2c675d
--- /dev/null
+++ b/third_party/blink/web_tests/wpt_internal/webxr/webGLCanvasContext_makecompatible_isolated_world.https.html
@@ -0,0 +1,47 @@
+<!DOCTYPE html>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/webxr/resources/webxr_util.js"></script>
+<script src="/webxr/resources/webxr_test_constants.js"></script>
+<script>
+
+// Calling makeXRCompatible() while a request is already pending should still
+// return a Promise from the calling world. Uses testRunner to evaluate script
+// in an isolated world, so this is an internal-only test.
+function testIsolatedWorld(t, gl, glContextType) {
+  assert_true(!!window.testRunner, 'testRunner is required');
+
+  return navigator.xr.test.simulateDeviceConnection(TRACKED_IMMERSIVE_DEVICE)
+      .then((controller) => {
+        gl.canvas.id = glContextType + '-canvas';
+
+        const mainPromise = gl.makeXRCompatible();
+        assert_equals(Object.getPrototypeOf(mainPromise), Promise.prototype,
+            'main world promise has main world Promise.prototype');
+
+        // Synchronously call makeXRCompatible() on the same canvas from an
+        // isolated world while the request started above is still pending.
+        const result = testRunner.evaluateScriptInIsolatedWorldAndReturnValue(1,
+            "var gl = document.getElementById('" + glContextType + "-canvas')" +
+                ".getContext('" + glContextType + "');" +
+            "var p = gl.makeXRCompatible();" +
+            "p.catch(() => {});" +
+            "Object.getPrototypeOf(p) === Promise.prototype;");
+        assert_true(result,
+            'isolated world promise has isolated world Promise.prototype');
+
+        return mainPromise;
+      }).then(() => {
+        assert_true(gl.getContextAttributes().xrCompatible);
+      });
+}
+
+xr_promise_test(
+  "makeXRCompatible() returns a Promise from the calling world for webgl",
+  (t, gl) => testIsolatedWorld(t, gl, 'webgl'), null, 'webgl');
+
+xr_promise_test(
+  "makeXRCompatible() returns a Promise from the calling world for webgl2",
+  (t, gl) => testIsolatedWorld(t, gl, 'webgl2'), null, 'webgl2');
+
+</script>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/wpt_internal/webxr/webGLCanvasContext_makecompatible_isolated_world.https.html b/third_party/blink/web_tests/wpt_internal/webxr/webGLCanvasContext_makecompatible_isolated_world.https.html
new file mode 100644
index 0000000..2b2c675d
--- /dev/null
+++ b/third_party/blink/web_tests/wpt_internal/webxr/webGLCanvasContext_makecompatible_isolated_world.https.html
@@ -0,0 +1,47 @@
+<!DOCTYPE html>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/webxr/resources/webxr_util.js"></script>
+<script src="/webxr/resources/webxr_test_constants.js"></script>
+<script>
+
+// Calling makeXRCompatible() while a request is already pending should still
+// return a Promise from the calling world. Uses testRunner to evaluate script
+// in an isolated world, so this is an internal-only test.
+function testIsolatedWorld(t, gl, glContextType) {
+  assert_true(!!window.testRunner, 'testRunner is required');
+
+  return navigator.xr.test.simulateDeviceConnection(TRACKED_IMMERSIVE_DEVICE)
+      .then((controller) => {
+        gl.canvas.id = glContextType + '-canvas';
+
+        const mainPromise = gl.makeXRCompatible();
+        assert_equals(Object.getPrototypeOf(mainPromise), Promise.prototype,
+            'main world promise has main world Promise.prototype');
+
+        // Synchronously call makeXRCompatible() on the same canvas from an
+        // isolated world while the request started above is still pending.
+        const result = testRunner.evaluateScriptInIsolatedWorldAndReturnValue(1,
+            "var gl = document.getElementById('" + glContextType + "-canvas')" +
+                ".getContext('" + glContextType + "');" +
+            "var p = gl.makeXRCompatible();" +
+            "p.catch(() => {});" +
+            "Object.getPrototypeOf(p) === Promise.prototype;");
+        assert_true(result,
+            'isolated world promise has isolated world Promise.prototype');
+
+        return mainPromise;
+      }).then(() => {
+        assert_true(gl.getContextAttributes().xrCompatible);
+      });
+}
+
+xr_promise_test(
+  "makeXRCompatible() returns a Promise from the calling world for webgl",
+  (t, gl) => testIsolatedWorld(t, gl, 'webgl'), null, 'webgl');
+
+xr_promise_test(
+  "makeXRCompatible() returns a Promise from the calling world for webgl2",
+  (t, gl) => testIsolatedWorld(t, gl, 'webgl2'), null, 'webgl2');
+
+</script>
Loading diff…

Original Bug Report

reported by [email protected]

Cross-world V8 Promise leak in WebGL makeXRCompatible

Project Fortify, 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: A potential cross-world V8 Promise leak exists in WebGLRenderingContextBase::makeXRCompatible(). The method improperly caches a single world-bound ScriptPromiseResolver on a shared C++ context object, allowing a malicious webpage to obtain a raw v8::Promise belonging to a privileged isolated world (like an extension). This leak can be exploited to achieve Universal XSS via prototype pollution.

Affected files:

  • third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc
  • third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.h

Estimated timestamp from git blame: Unknown (Google3 checkout)

Summary

A cross-world V8 Promise leak has been identified in WebGLRenderingContextBase::makeXRCompatible(). In Blink, a single WebGLRenderingContextBase C++ object is shared between the main world and isolated worlds (like those of extensions) when they access the same <canvas> element via getContext(). However, makeXRCompatible() caches a ScriptPromiseResolver in a member variable that is not world-aware. If an isolated world initiates the async request and the main world subsequently calls the method while the request is pending, the main world receives the isolated world’s promise. This allows the main world to pollute the isolated world’s Object.prototype.

Vulnerability Details

  1. Shared Context: HTMLCanvasElement::GetCanvasRenderingContextInternal caches and returns the same underlying WebGLRenderingContextBase C++ object for a <canvas>, regardless of which V8 world requested it.
  2. World-Bound Resolver: When makeXRCompatible() is called, it creates a ScriptPromiseResolver bound to the caller’s ScriptState (V8 context) and caches it in make_xr_compatible_resolver_ (third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc:994-996).
  3. The Leak: If the method is called again while the asynchronous request is pending, it returns make_xr_compatible_resolver_->Promise() (line 992). If the second caller is from a different V8 world, it receives the v8::Promise created in the first caller’s world.
  4. No Binding Checks: The auto-generated V8 bindings for this method do not perform cross-context security checks on the returned promise (it lacks the [CheckSecurity=ReturnValue] IDL attribute), allowing the raw cross-world promise to be returned directly to JavaScript.

Potential Exploitation Steps

Note: These are potential steps based on code analysis; our tooling agent does not run code to verify a working proof-of-concept.

  1. An attacker’s webpage injects a <canvas> element into the DOM.
  2. A privileged browser extension (running in an isolated world) interacts with the DOM, accesses the canvas, and calls makeXRCompatible() on its WebGL context.
  3. Before the asynchronous GPU check completes, the attacker’s webpage intercepts execution (e.g., via a synchronous getter trap) and calls makeXRCompatible() on the same canvas’s context.
  4. Blink returns the pending ScriptPromise, giving the attacker’s webpage the raw v8::Promise instantiated in the extension’s isolated world.
  5. The attacker’s JavaScript traverses the prototype chain (Object.getPrototypeOf(Object.getPrototypeOf(leaked_promise))) to obtain the isolated world’s Object.prototype.
  6. The attacker pollutes Object.prototype (e.g., overriding .then or poisoning configuration objects).
  7. When the isolated world continues execution and handles the promise resolution, the attacker’s malicious prototype methods execute within the extension’s privileged context, achieving Universal XSS (UXSS) and an isolated world escape.

Suggested Fix

Replace the single make_xr_compatible_resolver_ member with a mechanism that safely tracks promises on a per-world basis. Using ScriptPromiseProperty<IDLUndefined, IDLAny> is the standard Blink pattern for this, as it inherently manages promises for multiple worlds accessing the same underlying state. Alternatively, if multiple concurrent requests from different worlds are not expected or supported, the method could detect the world mismatch and reject the new promise or return a newly created chained promise.

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