CVE-2026-15127
Overview
Background
- `makeXRCompatible()`
- A WebGL API that asynchronously reconfigures a rendering context so its resources can be shared with a WebXR session, returning a
Promisethat resolves when the context is XR-compatible. - `ScriptPromiseResolver`
- A Blink object bound to a specific
ScriptStatethat owns a JavaScriptPromiseand controls its resolution/rejection within that state’s world and context. - Isolated world
- A separate JavaScript execution environment (used by extension content scripts) that shares the DOM with the main world but must keep its objects, prototypes, and exceptions from crossing into other worlds.
- `WebGLRenderingContextBase`
- The shared C++ backing object for a canvas’s WebGL context; a single instance services every world that wraps that canvas.
Root Cause Analysis
makeXRCompatible() cached a single ScriptPromiseResolver in the member make_xr_compatible_resolver_ while an XR-compatibility request was in flight. On any subsequent call before that request completed, the code returned the cached resolver’s existing Promise (if (make_xr_compatible_resolver_) return make_xr_compatible_resolver_->Promise();) instead of minting a fresh one for the caller. Because one WebGLRenderingContextBase is shared across every world that wraps the canvas, the cached resolver — and thus its Promise — was created in and bound to whichever world called first, typically the main world. A second caller from an isolated world would then receive a Promise object that belongs to the main world’s ScriptState rather than its own. This handed a foreign-world JavaScript object across the world boundary, violating the isolation that extension content scripts rely on. The defect is a logic error in resolver lifecycle management, not a memory-safety bug.Promise from a per-canvas shared C++ object ignores that concurrent callers may live in different JavaScript worlds; promise identity must be per-caller, bound to each caller’s own ScriptState, even when they share one underlying async operation.Attack Path
- Set up a shared context An extension’s isolated-world content script and the page’s main world both hold references to the same canvas’s WebGL context.
- Main world starts the request
The main world calls
makeXRCompatible()first, creating and caching a resolver whosePromiseis bound to the main world’sScriptState, with the async request now in flight. - Isolated world calls in flight
Before the request completes, the isolated-world script calls
makeXRCompatible()and is handed back the main world’s cachedPromise. - Cross-world leak The isolated world now holds a main-world object; its resolution value or thrown exception carries objects and prototypes across the world boundary, breaking isolated-world separation.
Impact Assessment
Promise (and any object or exception it conveys) cross the JavaScript world boundary between an isolated world and the main world, undermining the separation extensions depend on for safety. This is an inappropriate-implementation logic error rather than direct memory corruption, but it can leak objects across a trust boundary and enable prototype/exception confusion between mutually distrusting worlds. Exploitation requires concurrent callers from different worlds sharing one canvas, which is realistic for extension content scripts running on attacker-controlled pages.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
Audit Directions
- Cached single resolver on a shared objectFlag any
ScriptPromiseResolver*or singlePromisemember cached on an object shared across worlds (per-canvas, per-frame, per-document) and returned to later callers; require per-caller resolvers bound to each caller’sScriptState. - World-agnostic Promise return pathsAudit async Web APIs that coalesce concurrent callers onto one in-flight operation to confirm each caller receives a
Promiseminted in its own world, following theHTMLMediaElement::play()HeapVectorpattern. - `ScriptState` provenanceTrace where a returned
Promise’sScriptStateoriginates versus the calling context; a mismatch across isolated/main worlds indicates a cross-world identity leak.
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.