CVE-2026-79098
Overview
Background
- PEPC / capability element
- the page-embedded permission control (e.g.
<usermedia type='camera'>), implemented onHTMLCapabilityElementBase, 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 itsIntersectionVisibilityshows it is fully visible. If it is covered or distorted, it is put in thekOccludedOrDistortedstate 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.
Attack Path
- 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. - 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. - 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. - 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
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
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/html/html_capability_element_base.cc |
modified | |
TEST_Fthird_party/blink/renderer/core/html/html_capability_element_base_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/html/html_capability_element_base.hthird_party/blink/renderer/core/html/html_capability_element_base.ccthird_party/blink/renderer/core/html/html_capability_element_base_test.cc
Audit Directions
- Per-document observers on movable elementsAudit elements that own an
IntersectionObserver,ResizeObserveror 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 changesLook 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 casesTest anti-clickjacking gates under adopt-then-detach-frame,
popover/top-layer moves anddisplay:nonetoggles, where the observer’s root or frame may be gone while the element is still interactive.
Patch
From 3a9b444e1f27bbf25c28289f93de51e443678f5a Mon Sep 17 00:00:00 2001 From: Stefan Zager <[email protected]> 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 <[email protected]> Commit-Queue: Stefan Zager <[email protected]> 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");
Regression Test / PoC
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");