CVE-2026-79258
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/modules/xr/xr_input_source.cc |
modified |
Files Changed
third_party/blink/renderer/modules/xr/xr_input_source.ccthird_party/blink/web_tests/external/wpt/webxr/dom-overlay/ar_dom_overlay.https.html
Patch
From 39279d811bbed98a19d3b72d243a31ebdcc9c749 Mon Sep 17 00:00:00 2001 From: Alexander Cooper <[email protected]> Date: Mon, 20 Jul 2026 22:17:28 -0700 Subject: [PATCH] [WebXR] Detect all frame owners in DOM Overlay ProcessOverlayHitTest downcasts the hit element to HTMLFrameElementBase to decide whether to walk the content frame subtree for cross-origin content. That base class only covers <iframe> and <frame>; content embedded via <object>, <embed>, or <fencedframe> owns a content frame through HTMLFrameOwnerElement instead, so the cast returned null and the cross-origin check was skipped. Cast to HTMLFrameOwnerElement instead so every frame-owning element is subject to the same origin check. Extend the ar_dom_overlay WPT with an <object> variant of the existing cross-origin iframe test. TAG=agy Fixed: 524418836 Change-Id: I43830c7a669820dbb56c3a1792d9f7c6813d1c1e Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8120459 Reviewed-by: Brian Sheedy <[email protected]> Commit-Queue: Alexander Cooper <[email protected]> Cr-Commit-Position: refs/heads/main@{#1665189} --- diff --git a/third_party/blink/renderer/modules/xr/xr_input_source.cc b/third_party/blink/renderer/modules/xr/xr_input_source.cc index 266a0370..e5c36953 100644 --- a/third_party/blink/renderer/modules/xr/xr_input_source.cc +++ b/third_party/blink/renderer/modules/xr/xr_input_source.cc @@ -16,7 +16,7 @@ #include "third_party/blink/renderer/core/frame/frame.h" #include "third_party/blink/renderer/core/frame/local_dom_window.h" #include "third_party/blink/renderer/core/frame/local_frame.h" -#include "third_party/blink/renderer/core/html/html_frame_element_base.h" +#include "third_party/blink/renderer/core/html/html_frame_owner_element.h" #include "third_party/blink/renderer/core/input/event_handling_util.h" #include "third_party/blink/renderer/core/layout/hit_test_location.h" #include "third_party/blink/renderer/modules/xr/xr_grip_space.h" @@ -522,8 +522,8 @@ // the common base class to cover both. (There's no intention to actively // support framesets for DOM Overlay, but this helps prevent them from // being used as a mechanism for information leaks.) - HTMLFrameElementBase* frame_element = - DynamicTo<HTMLFrameElementBase>(hit_element); + HTMLFrameOwnerElement* frame_element = + DynamicTo<HTMLFrameOwnerElement>(hit_element); if (frame_element) { Frame* hit_frame = frame_element->ContentFrame(); if (hit_frame) { diff --git a/third_party/blink/web_tests/external/wpt/webxr/dom-overlay/ar_dom_overlay.https.html b/third_party/blink/web_tests/external/wpt/webxr/dom-overlay/ar_dom_overlay.https.html index 250adf9..00d5da9a 100644 --- a/third_party/blink/web_tests/external/wpt/webxr/dom-overlay/ar_dom_overlay.https.html +++ b/third_party/blink/web_tests/external/wpt/webxr/dom-overlay/ar_dom_overlay.https.html @@ -11,7 +11,7 @@ min-width: 10px; min-height: 10px; } - iframe { + iframe, object { border: 0; width: 20px; height: 20px; @@ -25,6 +25,9 @@ <!-- This SVG iframe is treated as cross-origin content. --> <iframe id="iframe" src='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><rect height="20" width="20" fill="red" fill-opacity="0.3"/></svg>'> </iframe> + <!-- This SVG object is treated as cross-origin content. --> + <object id="object" data='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><rect height="20" width="20" fill="blue" fill-opacity="0.3"/></svg>' type="image/svg+xml"> + </object> <canvas> </canvas> </div> @@ -175,11 +178,11 @@ return eventPromise; }; -let testCrossOriginContent = function(overlayElement, session, fakeDeviceController, t) { +let testCrossOriginContent = function(elementId, overlayElement, session, fakeDeviceController, t) { let debug = xr_debug.bind(this, 'testCrossOriginContent'); - let iframe = document.getElementById('iframe'); - assert_true(iframe != null); + let element = document.getElementById(elementId); + assert_true(element != null); let inner_b = document.getElementById('inner_b'); assert_true(inner_b != null); @@ -198,8 +201,8 @@ // Press the primary input button and then release it a short time later. requestSkipAnimationFrame(session, (time, xrFrame) => { debug('got rAF 1'); - input_source.setOverlayPointerPosition(iframe.offsetLeft + 1, - iframe.offsetTop + 1); + input_source.setOverlayPointerPosition(element.offsetLeft + 1, + element.offsetTop + 1); input_source.startSelection(); session.requestAnimationFrame((time, xrFrame) => { @@ -289,7 +292,15 @@ xr_session_promise_test( "Ensures DOM Overlay interactions on cross origin iframe are ignored", - testCrossOriginContent.bind(this, document.getElementById('div_overlay')), + testCrossOriginContent.bind(this, 'iframe', document.getElementById('div_overlay')), + fakeDeviceInitParams, 'immersive-ar', { + requiredFeatures: ['dom-overlay'], + domOverlay: { root: document.getElementById('div_overlay') } + }); + +xr_session_promise_test( + "Ensures DOM Overlay interactions on cross origin object are ignored", + testCrossOriginContent.bind(this, 'object', document.getElementById('div_overlay')), fakeDeviceInitParams, 'immersive-ar', { requiredFeatures: ['dom-overlay'], domOverlay: { root: document.getElementById('div_overlay') }
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/webxr/dom-overlay/ar_dom_overlay.https.html b/third_party/blink/web_tests/external/wpt/webxr/dom-overlay/ar_dom_overlay.https.html
index 250adf9..00d5da9a 100644
--- a/third_party/blink/web_tests/external/wpt/webxr/dom-overlay/ar_dom_overlay.https.html
+++ b/third_party/blink/web_tests/external/wpt/webxr/dom-overlay/ar_dom_overlay.https.html
@@ -11,7 +11,7 @@
min-width: 10px;
min-height: 10px;
}
- iframe {
+ iframe, object {
border: 0;
width: 20px;
height: 20px;
@@ -25,6 +25,9 @@
<!-- This SVG iframe is treated as cross-origin content. -->
<iframe id="iframe" src='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><rect height="20" width="20" fill="red" fill-opacity="0.3"/></svg>'>
</iframe>
+ <!-- This SVG object is treated as cross-origin content. -->
+ <object id="object" data='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><rect height="20" width="20" fill="blue" fill-opacity="0.3"/></svg>' type="image/svg+xml">
+ </object>
<canvas>
</canvas>
</div>
@@ -175,11 +178,11 @@
return eventPromise;
};
-let testCrossOriginContent = function(overlayElement, session, fakeDeviceController, t) {
+let testCrossOriginContent = function(elementId, overlayElement, session, fakeDeviceController, t) {
let debug = xr_debug.bind(this, 'testCrossOriginContent');
- let iframe = document.getElementById('iframe');
- assert_true(iframe != null);
+ let element = document.getElementById(elementId);
+ assert_true(element != null);
let inner_b = document.getElementById('inner_b');
assert_true(inner_b != null);
@@ -198,8 +201,8 @@
// Press the primary input button and then release it a short time later.
requestSkipAnimationFrame(session, (time, xrFrame) => {
debug('got rAF 1');
- input_source.setOverlayPointerPosition(iframe.offsetLeft + 1,
- iframe.offsetTop + 1);
+ input_source.setOverlayPointerPosition(element.offsetLeft + 1,
+ element.offsetTop + 1);
input_source.startSelection();
session.requestAnimationFrame((time, xrFrame) => {
@@ -289,7 +292,15 @@
xr_session_promise_test(
"Ensures DOM Overlay interactions on cross origin iframe are ignored",
- testCrossOriginContent.bind(this, document.getElementById('div_overlay')),
+ testCrossOriginContent.bind(this, 'iframe', document.getElementById('div_overlay')),
+ fakeDeviceInitParams, 'immersive-ar', {
+ requiredFeatures: ['dom-overlay'],
+ domOverlay: { root: document.getElementById('div_overlay') }
+ });
+
+xr_session_promise_test(
+ "Ensures DOM Overlay interactions on cross origin object are ignored",
+ testCrossOriginContent.bind(this, 'object', document.getElementById('div_overlay')),
fakeDeviceInitParams, 'immersive-ar', {
requiredFeatures: ['dom-overlay'],
domOverlay: { root: document.getElementById('div_overlay') }
Original Bug Report
Potential WebXR DOM Overlay cross-origin input suppression bypass via HTMLFrameOwnerElement tags
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: The WebXR DOM Overlay cross-origin input suppression is bypassed when a victim site is embedded using <object>, <embed>, or <fencedframe>. A dynamic cast to HTMLFrameElementBase during hit testing fails for these elements, skipping the cross-origin check and exposing the user’s high-precision AR pointer pose and click events to the attacker.
Affected files:
third_party/blink/renderer/modules/xr/xr_input_source.ccthird_party/blink/renderer/modules/xr/xr_session.cc
Estimated timestamp from git blame: Unknown (Google3 checkout)
1. Summary of the Issue (Meant for Human Triage)
A potential security vulnerability exists in the WebXR DOM Overlay implementation in Chromium. To prevent information leaks, the WebXR DOM Overlay specification mandates that when a user’s controller/hand pointer ray intersects cross-origin content (such as an embedded iframe), the input source must be made invisible to the embedding (attacker) page. This prevents the parent page from tracking the high-precision 6-DoF pointing pose or sniffing interaction timing, which could otherwise be used for keylogging or click-jacking attacks against the embedded victim page.
In XRInputSource::ProcessOverlayHitTest, the code attempts to enforce this by performing a hit test against the DOM and checking if the intersected element hosts cross-origin content. However, the logic relies on a dynamically downcasting the hit element to HTMLFrameElementBase.
While this base class covers standard <iframe> and <frame> tags, it completely misses other tags capable of hosting cross-origin content, such as <object>, <embed>, and <fencedframe>. Because these elements instead inherit directly from HTMLFrameOwnerElement (or via HTMLPlugInElement) and not from HTMLFrameElementBase, the cast returns nullptr. The code then assumes the hit was safe, skips the cross-origin subtree walk entirely, and falls through to set state_.is_visible = true.
As a result, if an attacker embeds a process-isolated target site using <object data="https://victim.example"> or <fencedframe src="https://victim.example"> inside a DOM Overlay, they can completely bypass the suppression. This leaks precise, per-frame target-ray/grip space poses and interaction events (select, beforexrselect) while the user is interacting with the embedded element, allowing the attacker to deduce exact tap coordinates over the victim UI. The primary shipping surface for this is Android Chrome via ARCore.
2. Suggested Proof-of-Concept & Detailed Execution Flow
The following steps trace how an attacker would potentially trigger this vulnerability, based on code analysis:
Prerequisites & Setup
- Attacker Page Creation: The attacker creates a web page and defines a container element to serve as the DOM Overlay root (e.g.,
<div id="overlay-container">). - Victim Embedding: Inside the container, the attacker embeds a process-isolated, cross-origin target site using an
<object>or<fencedframe>tag. For instance:<object id="target" data="https://victim.example/login"></object>. - Session Request: The attacker’s JavaScript requests an immersive AR WebXR session, specifying the DOM Overlay feature and providing the container element as the root:
navigator.xr.requestSession('immersive-ar', { requiredFeatures: ['dom-overlay'], domOverlay: { root: document.getElementById('overlay-container') } }); - User Consent: The user is prompted for AR permissions (a standard prompt for AR capabilities). The user accepts.
- Frame Loop: The attacker registers a
requestAnimationFramecallback to continuously pollxrFrame.getPose()and adds event listeners forselectstart,select,selectend, andbeforexrselecton the overlay root or the embedded object.
Interaction & Event Generation
6. User Interaction: The user physically aligns their AR pointer ray (or touches the screen on ARCore-enabled mobile devices) such that it intersects the rendered <object> containing the cross-origin victim page.
7. Backend State Update: The OpenXR/ARCore backend registers the interaction and supplies the pointer’s 2D coordinate via the overlay_pointer_position property.
8. Session Processing: On the next animation frame, XRSession::OnInputStateChangeInternal (third_party/blink/renderer/modules/xr/xr_session.cc:2511) receives the input state from the device backend.
9. Hit Test Invocation: Because DOM Overlay is enabled and an overlay_pointer_position exists, XRSession invokes input_source->ProcessOverlayHitTest(overlay_element_, input_state).
Hit Test Resolution
10. Hit Test Construction: Inside XRInputSource::ProcessOverlayHitTest (third_party/blink/renderer/modules/xr/xr_input_source.cc:502), the pointer position is converted into a gfx::PointF.
11. Hit Test Execution: A hit test is requested via event_handling_util::HitTestResultInFrame (line 510). Crucially, the request flags (HitTestRequest::kTouchEvent | HitTestRequest::kReadOnly | HitTestRequest::kActive) do not include kAllowChildFrameContent.
12. Layout Navigation: The hit test traverses the layout tree and reaches LayoutEmbeddedContent::NodeAtPoint (third_party/blink/renderer/core/layout/layout_embedded_content.cc:221), which handles HTMLObjectElement (LayoutEmbeddedObject) and HTMLFencedFrameElement (LayoutIFrame).
13. Skip Child Frame Contents: Because kAllowChildFrameContent was omitted, result.GetHitTestRequest().AllowsChildFrameContent() returns false (hit_test_request.h:95), causing skip_contents to evaluate to true (layout_embedded_content.cc:228).
14. Embedder Resolution: Consequently, NodeAtPoint bypasses the child frame’s document and calls NodeAtPointOverEmbeddedContentView (layout_embedded_content.cc:234).
15. Inner Element Population: This delegates to LayoutReplaced::NodeAtPoint and subsequently LayoutObject::UpdateHitTestResult (layout_object.cc:4272), which retrieves the DOM node via GetNode() and populates the HitTestResult.
16. Return to ProcessOverlayHitTest: Back in xr_input_source.cc:515, result.InnerElement() successfully returns the embedder DOM element (the HTMLObjectElement or HTMLFencedFrameElement).
The Under-Broad Cast Bypass
17. Dynamic Cast Attempt: At xr_input_source.cc:525, the code attempts to determine if the hit element hosts cross-origin content by attempting a dynamic downcast:
cpp HTMLFrameElementBase* frame_element = DynamicTo<HTMLFrameElementBase>(hit_element);
18. Type Trait Evaluation: DynamicTo (casting.h:174) invokes IsA<HTMLFrameElementBase>(hit_element). Because HTMLFrameElementBase is a subclass of Element, it delegates to DowncastTraits<HTMLFrameElementBase>::AllowFrom (html_frame_element_base.h:94).
19. Tag Name Verification: The DowncastTraits specialization checks if the element’s tag name is either kFrameTag (<frame>) or kIFrameTag (<iframe>).
20. Cast Failure: HTMLObjectElement has the tag name kObjectTag, and HTMLFencedFrameElement has kFencedframeTag. Because neither matches, AllowFrom returns false.
21. Inheritance Divergence: Architecturally, this happens because HTMLObjectElement inherits from HTMLPlugInElement -> HTMLFrameOwnerElement, and HTMLFencedFrameElement inherits directly from HTMLFrameOwnerElement. Neither descends from HTMLFrameElementBase.
22. Null Return: IsA fails, and DynamicTo evaluates to nullptr. frame_element is now nullptr.
Cross-Origin Logic Skipped
23. Security Check Bypassed: Because frame_element is null, the conditional block if (frame_element) (xr_input_source.cc:527) evaluates to false.
24. No Subtree Walk: The code completely skips the intended logic: it does not call ContentFrame() to retrieve the embedded document, does not walk the frame subtree, and does not check IsCrossOriginToOutermostMainFrame().
25. Visibility State Set: Execution falls through the if block to line 575:
cpp // If we get here, the touch didn't hit a cross origin frame. Set the // controller spaces visible. state_.is_visible = true;
The input source is marked visible, incorrectly assuming the pointer is not over cross-origin content.
Data Exfiltration
26. Pose Updating: Later in the frame update loop, XRInputSource::CreateOrUpdateFrom (xr_input_source.cc:85) checks state_.is_visible. Since it is true, it populates mojo_from_input_, input_from_pointer_, gamepad, and hand tracking data with the live, high-precision AR device poses.
27. API Return: In the attacker’s requestAnimationFrame loop, their call to xrFrame.getPose(inputSource.targetRaySpace, refSpace) successfully retrieves the 6-DoF target ray transform.
28. Event Dispatch: Furthermore, XRInputSource::UpdateButtonStates() (xr_input_source.cc:437) evaluates state_.is_visible as true. It proceeds to dispatch selectstart, select, selectend, and beforexrselect events targeting the <object> or <fencedframe> element, which bubble up to the attacker’s overlay root.
29. Tapjacking / Keylogging Achieved: By correlating the exact X/Y coordinate derived from the target ray pose with the timing of the select events and the known position of the <object> element, the attacker perfectly reconstructs the user’s touch/pointer coordinates inside the victim’s cross-origin UI (e.g., sniffing keystrokes on an embedded virtual keyboard).
Suggested Fix
Change the dynamic cast target in XRInputSource::ProcessOverlayHitTest from HTMLFrameElementBase to HTMLFrameOwnerElement. HTMLFrameOwnerElement is the common base class for <iframe>, <frame>, <object>, <embed>, and <fencedframe>. It defines the ContentFrame() accessor natively. This ensures all possible cross-origin embedding tags are captured and subjected to the origin subtree walk.
HTMLFrameOwnerElement* frame_element =
DynamicTo<HTMLFrameOwnerElement>(hit_element);
3. Technical Verification Details (Automated Audit Logs - Reviewers may skip this section)
> The vulnerability report claims a WebXR DOM Overlay cross-origin input suppression bypass for out-of-process iframes (RemoteFrames). The report specifically asserts that this bypass occurs because XRInputSource::ProcessOverlayHitTest uses HTMLFrameElementBase::contentDocument(), which returns nullptr for a RemoteFrame, causing the cross-origin check to be skipped.
>
> However, this specific issue does not exist in the current Chromium codebase. Inspecting third_party/blink/renderer/modules/xr/xr_input_source.cc, the logic has already been fixed. The code now correctly uses frame_element->ContentFrame() instead of contentDocument(). Additionally, it explicitly walks the frame tree and checks if (node->IsRemoteFrame()) { is_cross_origin = true; break; }, which properly handles out-of-process iframes and enforces the cross-origin input suppression.
>
> Since the bug described in the report has already been patched and can no longer be triggered as described, this report is rated as Not Exploitable / Bug (S4).
>
> (Zero-Day Aside: While the reported mechanism is fixed for <frame> and <iframe>, the underlying security boundary remains broken for other cross-origin embedders. If an attacker uses an <object data="https://victim.example"> or <fencedframe> tag, hit-testing resolves to an HTMLObjectElement or HTMLFencedFrameElement. Because these inherit directly from HTMLFrameOwnerElement and not HTMLFrameElementBase, DynamicTo<HTMLFrameElementBase>(hit_element) returns nullptr, completely skipping the cross-origin check and exposing the controller’s pose and select events. Nonetheless, the provided report describes an outdated, patched codebase.)
Audit Trace Confirmation:
- The hit testing traversal was confirmed via tracing
event_handling_util::HitTestResultInFrame->LayoutEmbeddedContent::NodeAtPoint. The request flags explicitly omitkAllowChildFrameContentinxr_input_source.cc:506. Consequently,result.GetHitTestRequest().AllowsChildFrameContent()evaluates to false, causingskip_contentsto force traversal toNodeAtPointOverEmbeddedContentView, setting the returnedInnerElement()to the embedder node itself. - The class hierarchy was confirmed via the Blink code:
HTMLObjectElementinheritsHTMLPlugInElement->HTMLFrameOwnerElement->HTMLElement.HTMLFencedFrameElementinheritsHTMLFrameOwnerElement->HTMLElement. Neither class is a descendant ofHTMLFrameElementBase. - The behavior of
DynamicTo<HTMLFrameElementBase>was confirmed viathird_party/blink/renderer/platform/wtf/casting.h:174. The type trait check invokesDowncastTraits<HTMLFrameElementBase>::AllowFromdefined inhtml_frame_element_base.h:94-104, which explicitly relies onHasTagName(html_names::kFrameTag) || HasTagName(html_names::kIFrameTag). Since<object>and<fencedframe>do not match these tags, the cast evaluates tonullptr. - The consequence is confirmed in
xr_input_source.cc:575, wherestate_.is_visible = true;is executed via fall-through when the cast evaluates to null. - Environmental reachability: The feature
DOM_OVERLAYis active by default for AR sessions on Android/ARCore and does not require experimental flags, as verified againstxr_system.cc:178andxr_session.cc:2511.
Evaluated with Chrome root at commit: 8c517fbcbb533e59ec9cedac868c8a9bdc30beb2
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.