CVE-2026-15127
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc |
modified | |
forthird_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc |
modified |
Files Changed
third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.ccthird_party/blink/renderer/modules/webgl/webgl_rendering_context_base.hthird_party/blink/web_tests/wpt_internal/webxr/webGLCanvasContext_makecompatible_isolated_world.https.html
Patch
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>
Regression Test / PoC
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>
Original Bug Report
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.ccthird_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
- Shared Context:
HTMLCanvasElement::GetCanvasRenderingContextInternalcaches and returns the same underlyingWebGLRenderingContextBaseC++ object for a<canvas>, regardless of which V8 world requested it. - World-Bound Resolver: When
makeXRCompatible()is called, it creates aScriptPromiseResolverbound to the caller’sScriptState(V8 context) and caches it inmake_xr_compatible_resolver_(third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc:994-996). - 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 thev8::Promisecreated in the first caller’s world. - 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.
- An attacker’s webpage injects a
<canvas>element into the DOM. - A privileged browser extension (running in an isolated world) interacts with the DOM, accesses the canvas, and calls
makeXRCompatible()on its WebGL context. - 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. - Blink returns the pending
ScriptPromise, giving the attacker’s webpage the rawv8::Promiseinstantiated in the extension’s isolated world. - The attacker’s JavaScript traverses the prototype chain (
Object.getPrototypeOf(Object.getPrototypeOf(leaked_promise))) to obtain the isolated world’sObject.prototype. - The attacker pollutes
Object.prototype(e.g., overriding.thenor poisoning configuration objects). - 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.