Low watch CVSS 4.3 chrome Spoofing 🔧 Commit mapped

Overview

Low
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUI misrepresentation in PermissionElement
DescriptionUI misrepresentation in PermissionElement
ComponentPermissionElement
Sub-componentIOv2
Bug ClassSpoofing
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker514010111
Fix commit3a9b444e1f27 (chromium/src) +61/-1
CWECWE-451
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-17 (per vendor advisory)
Fix landed2026-07-10 (public git)
Disclosed2026-08-25
Patch gap46 days — fix public in source before the advisory

Background

PEPC / capability element
the page-embedded permission control (e.g. <usermedia type='camera'>), implemented on HTMLCapabilityElementBase, which lets a user click an in-page browser-styled element to grant a permission such as camera access.
Visibility gating
to prevent clickjacking, the element only enables clicking (IsClickingEnabled()) when its IntersectionVisibility shows it is fully visible. If it is covered or distorted, it is put in the kOccludedOrDistorted state and clicks are disabled.
`intersection_observer_`
the element’s IntersectionObserver v2 (IOv2) visibility observer, which reports occlusion and visibility changes that drive the gating above.
`performing_reattach`
a flag passed during layout-tree detachment that tells a plain re-layout reattach (element stays in the DOM) apart from a real removal or move.

Root Cause Analysis

When a capability element was detached for real (not a reattach), HTMLCapabilityElementBase unregistered from lifecycle notifications but left its intersection_observer_ alive. That observer was bound to the element’s original document and frame. The regression test moves a <usermedia> element out of an iframe into the main document and then removes the iframe.

Before the fix, the element went on relying on the stale observer from the destroyed iframe context, so occlusion in its new location was never reported. It kept the “clicking enabled” state it had earned while visible in the iframe, even though a #occluder div now covers it. This breaks the anti-clickjacking rule that clicking is enabled only while the element is verifiably unoccluded in its current context.

The fix disconnects and nulls intersection_observer_ whenever detachment is not a reattach. Visibility is then re-established from scratch (the test confirms the state becomes kOccludedOrDistorted and IsClickingEnabled() is false). That a fresh observer is created on re-insertion is inferred from the test result; the creation code is not in the diff.

Key insight
The visibility observer behind the permission element’s anti-clickjacking gate outlived the document context it was created for, so a moved element kept stale “visible” state. The fix tears down the observer on any non-reattach detachment.

Attack Path

  1. Element shown visibly in an iframe A page embeds a same-origin iframe (with allow="camera *") containing a <usermedia type='camera'> element that is fully visible, so its visibility check passes and clicking becomes enabled.
  2. Move element and destroy iframe Script moves the element into the main document at a position covered by another element (the test’s #occluder) and removes the iframe.
  3. Stale visibility state persists Before the fix, the old intersection_observer_ was never disconnected, so the element did not re-evaluate occlusion in its new context and stayed click-enabled while hidden.
  4. Clickjacked permission grant The user clicks on what appears to be ordinary page content, the click reaches the obscured permission element, and a camera permission flow is triggered (inferred: the patch shows only the gating state, not the downstream prompt or grant).

Impact Assessment

A malicious page can make the permission element accept clicks while it is covered or disguised. This defeats the element’s clickjacking protection and can trick the user into starting a permission grant such as camera access, which is UI misrepresentation in the renderer’s permission-element logic. It needs only web content that can use the permission element (which may depend on the PermissionElement feature or origin-trial status) plus a user click. No compromised renderer is needed. The advisory states no in-the-wild status, and any permission actually granted without a confirming prompt is (inferred).

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/html/html_capability_element_base.cc
modified
TEST_F
third_party/blink/renderer/core/html/html_capability_element_base_test.cc
modified

Files Changed

  • third_party/blink/renderer/core/html/html_capability_element_base.h
  • third_party/blink/renderer/core/html/html_capability_element_base.cc
  • third_party/blink/renderer/core/html/html_capability_element_base_test.cc

Audit Directions

  • Per-document observers on movable elements
    Audit elements that own an IntersectionObserver, ResizeObserver or lifecycle registration (permission elements, <fencedframe>, media controls, autofill-related overlays) to confirm they are torn down and recreated when the node moves between documents (DidMoveToNewDocument, RemovedFrom, DetachLayoutTree).
  • Security state cached across context changes
    Look for “clicking enabled”, “visible” or “trusted” booleans that are set from asynchronous observations and not reset when the element is removed, adopted into another document, or its frame is destroyed.
  • Frame-destruction edge cases
    Test anti-clickjacking gates under adopt-then-detach-frame, popover/top-layer moves and display:none toggles, where the observer’s root or frame may be gone while the element is still interactive.
From 3a9b444e1f27bbf25c28289f93de51e443678f5a Mon Sep 17 00:00:00 2001
From: Stefan Zager <szager@chromium.org>
Date: Fri, 10 Jul 2026 12:49:50 -0700
Subject: [PATCH] [IOv2] Delete visibility observer when pepc element removed from DOM

Bug: 514010111
Change-Id: I6b5f58b149505ed315bb91bbd5c4de9110967971
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8067916
Reviewed-by: Philip Rogers <pdr@chromium.org>
Commit-Queue: Stefan Zager <szager@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1660470}
---
diff --git a/third_party/blink/renderer/core/html/html_capability_element_base.cc b/third_party/blink/renderer/core/html/html_capability_element_base.cc
index ba61a89..69460a67c 100644
--- a/third_party/blink/renderer/core/html/html_capability_element_base.cc
+++ b/third_party/blink/renderer/core/html/html_capability_element_base.cc
@@ -360,6 +360,10 @@
   if (auto* view = GetDocument().View()) {
     view->UnregisterFromLifecycleNotifications(this);
   }
+  if (!performing_reattach && intersection_observer_) {
+    intersection_observer_->disconnect();
+    intersection_observer_ = nullptr;
+  }
 }
 
 void HTMLCapabilityElementBase::RemovedFrom(ContainerNode& insertion_point) {
diff --git a/third_party/blink/renderer/core/html/html_capability_element_base.h b/third_party/blink/renderer/core/html/html_capability_element_base.h
index 5e01e30..0c71c7f 100644
--- a/third_party/blink/renderer/core/html/html_capability_element_base.h
+++ b/third_party/blink/renderer/core/html/html_capability_element_base.h
@@ -211,6 +211,8 @@
   FRIEND_TEST_ALL_PREFIXES(HTMLCapabilityElementBaseIntersectionTest,
                            IntersectionChanged);
   FRIEND_TEST_ALL_PREFIXES(HTMLCapabilityElementBaseIntersectionTest,
+                           MovePEPCFromIframeAndDestroyIframe);
+  FRIEND_TEST_ALL_PREFIXES(HTMLCapabilityElementBaseIntersectionTest,
                            IntersectionChangedDisableEnableDisable);
   FRIEND_TEST_ALL_PREFIXES(HTMLCapabilityElementBaseIntersectionTest,
                            ContainerDivRotates);
diff --git a/third_party/blink/renderer/core/html/html_capability_element_base_test.cc b/third_party/blink/renderer/core/html/html_capability_element_base_test.cc
index 2886eaf8..bcaca0a6 100644
--- a/third_party/blink/renderer/core/html/html_capability_element_base_test.cc
+++ b/third_party/blink/renderer/core/html/html_capability_element_base_test.cc
@@ -296,7 +296,6 @@
     }
 
     GetDocument().body()->AppendChild(permission_element);
-    GetDocument().UpdateStyleAndLayout(DocumentUpdateReason::kTest);
     GetDocument().View()->UpdateAllLifecyclePhasesForTest();
     return permission_element;
   }
@@ -1637,6 +1636,61 @@
   }
 };
 
+TEST_F(HTMLCapabilityElementBaseIntersectionTest,
+       MovePEPCFromIframeAndDestroyIframe) {
+  GetDocument().GetSettings()->SetDefaultFontSize(12);
+  SimRequest main_resource("https://example.test/", "text/html");
+  SimRequest iframe_resource("https://example.test/foo.html", "text/html");
+  LoadURL("https://example.test/");
+  main_resource.Complete(R"HTML(
+    <body>
+      <style>
+        #occluder {
+          position:absolute;
+          left:0;
+          top:0;
+          width:100px;
+          height:100px;
+          background:black;
+        }
+        #iframe {
+          position:absolute;
+          left:0;
+          top:150px;
+        }
+      </style>
+      <div id="occluder"></div>
+      <iframe id="iframe" src='https://example.test/foo.html' allow="camera *">
+      </iframe>
+    </body>
+  )HTML");
+  iframe_resource.Complete(R"HTML(
+    <!DOCTYPE html><usermedia id='camera' type='camera'></usermedia>
+  )HTML");
+
+  Compositor().BeginFrame();
+
+  auto* subframe = To<WebLocalFrameImpl>(MainFrame().FirstChild())->GetFrame();
+  auto* permission_element = static_cast<HTMLCapabilityElementBase*>(
+      subframe->GetDocument()->getElementById(AtomicString("camera")));
+  DeferredChecker checker1(permission_element);
+  checker1.CheckClickingEnabledAfterDelay(kDefaultTimeout,
+                                          /*expected_enabled*/ true);
+
+  // Move PEPC to main document and remove iframe
+  auto& main_document = *MainFrame().GetFrame()->GetDocument();
+  main_document.body()->AppendChild(permission_element);
+  main_document.getElementById(AtomicString("iframe"))->remove();
+
+  Compositor().BeginFrame();
+
+  // Verify it is occluded and clicking is disabled.
+  WaitForIntersectionVisibilityChanged(
+      permission_element,
+      HTMLCapabilityElementBase::IntersectionVisibility::kOccludedOrDistorted);
+  EXPECT_FALSE(permission_element->IsClickingEnabled());
+}
+
 TEST_F(HTMLCapabilityElementBaseIntersectionTest, IntersectionChanged) {
   GetDocument().GetSettings()->SetDefaultFontSize(12);
   SimRequest main_resource("https://example.test/", "text/html");
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/html/html_capability_element_base_test.cc b/third_party/blink/renderer/core/html/html_capability_element_base_test.cc
index 2886eaf8..bcaca0a6 100644
--- a/third_party/blink/renderer/core/html/html_capability_element_base_test.cc
+++ b/third_party/blink/renderer/core/html/html_capability_element_base_test.cc
@@ -296,7 +296,6 @@
     }
 
     GetDocument().body()->AppendChild(permission_element);
-    GetDocument().UpdateStyleAndLayout(DocumentUpdateReason::kTest);
     GetDocument().View()->UpdateAllLifecyclePhasesForTest();
     return permission_element;
   }
@@ -1637,6 +1636,61 @@
   }
 };
 
+TEST_F(HTMLCapabilityElementBaseIntersectionTest,
+       MovePEPCFromIframeAndDestroyIframe) {
+  GetDocument().GetSettings()->SetDefaultFontSize(12);
+  SimRequest main_resource("https://example.test/", "text/html");
+  SimRequest iframe_resource("https://example.test/foo.html", "text/html");
+  LoadURL("https://example.test/");
+  main_resource.Complete(R"HTML(
+    <body>
+      <style>
+        #occluder {
+          position:absolute;
+          left:0;
+          top:0;
+          width:100px;
+          height:100px;
+          background:black;
+        }
+        #iframe {
+          position:absolute;
+          left:0;
+          top:150px;
+        }
+      </style>
+      <div id="occluder"></div>
+      <iframe id="iframe" src='https://example.test/foo.html' allow="camera *">
+      </iframe>
+    </body>
+  )HTML");
+  iframe_resource.Complete(R"HTML(
+    <!DOCTYPE html><usermedia id='camera' type='camera'></usermedia>
+  )HTML");
+
+  Compositor().BeginFrame();
+
+  auto* subframe = To<WebLocalFrameImpl>(MainFrame().FirstChild())->GetFrame();
+  auto* permission_element = static_cast<HTMLCapabilityElementBase*>(
+      subframe->GetDocument()->getElementById(AtomicString("camera")));
+  DeferredChecker checker1(permission_element);
+  checker1.CheckClickingEnabledAfterDelay(kDefaultTimeout,
+                                          /*expected_enabled*/ true);
+
+  // Move PEPC to main document and remove iframe
+  auto& main_document = *MainFrame().GetFrame()->GetDocument();
+  main_document.body()->AppendChild(permission_element);
+  main_document.getElementById(AtomicString("iframe"))->remove();
+
+  Compositor().BeginFrame();
+
+  // Verify it is occluded and clicking is disabled.
+  WaitForIntersectionVisibilityChanged(
+      permission_element,
+      HTMLCapabilityElementBase::IntersectionVisibility::kOccludedOrDistorted);
+  EXPECT_FALSE(permission_element->IsClickingEnabled());
+}
+
 TEST_F(HTMLCapabilityElementBaseIntersectionTest, IntersectionChanged) {
   GetDocument().GetSettings()->SetDefaultFontSize(12);
   SimRequest main_resource("https://example.test/", "text/html");
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.