CVE-2025-0439
Overview
Background
- `IntersectionObserver` v2
- a Web API that, when
trackVisibilityis enabled, reports not only whether a target intersects the viewport but whether it is actually visible (unoccluded), so pages can gate actions like clicks on genuine visibility. - `FrameOcclusionState`
- a
mojom::blinkenum whose values (kGuaranteedNotOccluded,kPossiblyOccluded,kUnknown) describe whether a frame is known to be unobscured by other content. - Occlusion propagation
- the process in
frame_view.ccby which a child frame derives its occlusion state from its parent’s state and, when needed, an actual geometry test of the child within the parent. - Lifecycle throttling
- Blink may skip document lifecycle updates (
kPrePaintCleanand beyond) for out-of-view or cross-origin frames, leaving their occlusion data stale or unavailable.
Root Cause Analysis
The vulnerable path lived in LocalFrameView’s occlusion computation in frame_view.cc, which propagated the parent frame’s kGuaranteedNotOccluded state to a child without ever performing an occlusion test on that child when tracking was not requested or the parent lifecycle was throttled. The invariant that “visibility must never be asserted without a positive occlusion test on the frame itself” was violated, because a child could inherit kGuaranteedNotOccluded purely from its parent. Compounding this, IntersectionObservation::ShouldCompute in intersection_observation.cc only deferred computation when the state was kUnknown, so once a target had been reported visible it could remain latched as visible even as the frame occlusion state degraded.
The fix forces occlusion_state to kUnknown whenever the parent is kGuaranteedNotOccluded unless an actual test on this frame is scheduled, computes a real kGuaranteedNotOccluded/kPossiblyOccluded result from geometry.IsVisible(), and makes deferral also trigger when a currently-visible target sees kUnknown — closing the race where stale or inherited state let occluded content be reported as visible.
kUnknown occlusion never demoted an already-“visible” target. The fix propagates kUnknown unless the child is actually occlusion-tested, and demotes any currently-visible target when its frame’s state becomes kUnknown.Attack Path
- Establish a tracked target in a nested frame
An attacker page embeds a cross-origin iframe that runs an
IntersectionObserverwithtrackVisibility: trueover a clickable target. - Get the target reported visible
At a moment when the parent frame’s
FrameOcclusionStateiskGuaranteedNotOccluded, the child inherits that state and the target is reported asisVisible: true, latchinglast_is_visible_. - Race the occlusion state to "unknown"
The attacker throttles or de-syncs the parent’s lifecycle (e.g. scrolling, cross-origin throttling) so the frame’s occlusion state becomes
kUnknownwhile the target stays latched as visible. - Overlay or reposition to occlude the target Because the stale visible report is not demoted, the attacker places overlapping content over the target while the API still reports it visible.
- Trigger a gated action A user gesture or script-observed “visible” state drives a clickjacking-style action against a target the user cannot actually see unobstructed.
Impact Assessment
IntersectionObserver v2, defeating the visibility guarantees that pages rely on to prevent clickjacking, in the renderer process across a cross-origin frame boundary. Exploitation requires the attacker to control a page embedding a tracked cross-origin frame and to win a timing race that drives the frame’s FrameOcclusionState to kUnknown while a target is latched visible. The result is a visibility-integrity bypass enabling UI redress attacks rather than direct memory corruption.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/frame/frame_view.cc |
modified | |
async_testthird_party/blink/web_tests/external/wpt/intersection-observer/v2/nested-cross-origin.sub.html |
modified |
Files Changed
third_party/blink/renderer/core/frame/frame_view.ccthird_party/blink/renderer/core/intersection_observer/intersection_observation.ccthird_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-midframe.sub.htmlthird_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-subframe.htmlthird_party/blink/web_tests/external/wpt/intersection-observer/v2/nested-cross-origin.sub.html
Audit Directions
- Inherited-without-verification stateLook for places where a child frame or object copies a parent’s positive/safe security state (visibility, occlusion, trust) without an independent test of the child itself.
- Latched positive results across state degradationAudit code that caches a “true/visible/allowed” result and only re-evaluates on specific transitions, missing degradations to
kUnknown/unavailable states during lifecycle throttling. - Throttling and lifecycle race windowsExamine cross-origin or throttled frame paths where stale lifecycle data (
kPrePaintCleangating) can be raced to make security-relevant computations report optimistic answers.
Patch
From 7b2e3f7ff30d5dcf17cd5c00f0554a44eec2c2a1 Mon Sep 17 00:00:00 2001 From: Stefan Zager <[email protected]> Date: Thu, 24 Oct 2024 02:35:39 +0000 Subject: [PATCH] IntersectionObserver -- properly handle "unknown" occlusion state If we most recently reported a target as "guaranteed visible", then in the interest of avoiding false positives we must transition to "not guaranteed visible" if the frame occlusion state becomes "unknown". This CL also makes a child frame inherit its parent's "not visible" occlusion state rather than calling it "unknown", which is technically more correct. Bug: chromium:371247941 Change-Id: I4d721dd252d013deac14a12f1f2922830ef2a8a4 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/5950965 Reviewed-by: Xianzhu Wang <[email protected]> Commit-Queue: Stefan Zager <[email protected]> Cr-Commit-Position: refs/heads/main@{#1373093} --- diff --git a/third_party/blink/renderer/core/frame/frame_view.cc b/third_party/blink/renderer/core/frame/frame_view.cc index 3d6d9a2..29981ce52 100644 --- a/third_party/blink/renderer/core/frame/frame_view.cc +++ b/third_party/blink/renderer/core/frame/frame_view.cc @@ -77,15 +77,20 @@ gfx::Transform main_frame_transform_matrix; DocumentLifecycle::LifecycleState parent_lifecycle_state = owner_document.Lifecycle().GetState(); + + bool should_compute_occlusion = false; mojom::blink::FrameOcclusionState occlusion_state = owner_document.GetFrame()->GetOcclusionState(); - bool should_compute_occlusion = - needs_occlusion_tracking && - occlusion_state == - mojom::blink::FrameOcclusionState::kGuaranteedNotOccluded && - parent_lifecycle_state >= DocumentLifecycle::kPrePaintClean; - if (!should_compute_occlusion) { + if (occlusion_state == + mojom::blink::FrameOcclusionState::kGuaranteedNotOccluded) { + // We can't propagate kGuaranteedNotOccluded from the parent without testing + // occlusion of this frame. If we don't ultimately do an occlusion test on + // this frame, then we should propagate "unknown". occlusion_state = mojom::blink::FrameOcclusionState::kUnknown; + if (needs_occlusion_tracking && + parent_lifecycle_state >= DocumentLifecycle::kPrePaintClean) { + should_compute_occlusion = true; + } } LayoutEmbeddedContent* owner_layout_object = @@ -159,8 +164,12 @@ rect_in_parent_stable_since_for_iov2_ = base::TimeTicks::Now(); } } - if (should_compute_occlusion && !geometry.IsVisible()) - occlusion_state = mojom::blink::FrameOcclusionState::kPossiblyOccluded; + if (should_compute_occlusion) { + occlusion_state = + geometry.IsVisible() + ? mojom::blink::FrameOcclusionState::kGuaranteedNotOccluded + : mojom::blink::FrameOcclusionState::kPossiblyOccluded; + } // Generate matrix to transform from the space of the containing document // to the space of the iframe's contents. @@ -248,11 +257,6 @@ } main_frame_transform_matrix = child_frame_to_root_frame.AccumulatedTransform(); - } else if (occlusion_state == - mojom::blink::FrameOcclusionState::kGuaranteedNotOccluded) { - // If the parent LocalFrameView is throttled and out-of-date, then we can't - // get any useful information. - occlusion_state = mojom::blink::FrameOcclusionState::kUnknown; } // An iframe's content is always pixel-snapped, even if the iframe element has diff --git a/third_party/blink/renderer/core/intersection_observer/intersection_observation.cc b/third_party/blink/renderer/core/intersection_observer/intersection_observation.cc index a05620d..160254b6 100644 --- a/third_party/blink/renderer/core/intersection_observer/intersection_observation.cc +++ b/third_party/blink/renderer/core/intersection_observer/intersection_observation.cc @@ -179,11 +179,14 @@ Observer()->trackVisibility()) { mojom::blink::FrameOcclusionState occlusion_state = target_->GetDocument().GetFrame()->GetOcclusionState(); - // If we're tracking visibility, and we don't have occlusion information - // from our parent frame, then postpone computing intersections until a - // later lifecycle when the occlusion information is known. - if (occlusion_state == mojom::blink::FrameOcclusionState::kUnknown) + // If we're tracking visibility, and we aren't currently reporting the + // target visible, and we don't have occlusion information from our parent + // frame, then postpone computing intersections until a later lifecycle when + // the occlusion information is known. + if (!last_is_visible_ && + occlusion_state == mojom::blink::FrameOcclusionState::kUnknown) { return false; + } } return true; } diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-midframe.sub.html b/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-midframe.sub.html new file mode 100644 index 0000000..b9b055b6 --- /dev/null +++ b/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-midframe.sub.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<style> +iframe { + width: 300px; + height: 150px; + border: none; +} +</style> + +<iframe src="http://{{domains[www2]}}:{{ports[http][0]}}/intersection-observer/resources/v2-subframe.html"></iframe> + +<script> +window.addEventListener("message", event => { + requestAnimationFrame(() => setTimeout(() => { + document.querySelector('iframe').contentWindow.postMessage(event.data, "*"); + })); +}); +</script> diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-subframe.html b/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-subframe.html index 295bbf04..cfc82646 100644 --- a/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-subframe.html +++ b/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-subframe.html @@ -16,7 +16,7 @@ window.addEventListener("message", event => { waitForNotification(() => { - window.parent.postMessage(results.map(e => e.isVisible), "*"); + window.top.postMessage(results.map(e => e.isVisible), "*"); results = []; }); }); @@ -27,6 +27,6 @@ results = entries; }, {trackVisibility: true, delay: delay}); observer.observe(document.getElementById("target")); - window.parent.postMessage("", "*"); + window.top.postMessage("", "*"); }; </script> diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/v2/nested-cross-origin.sub.html b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/nested-cross-origin.sub.html new file mode 100644 index 0000000..42a69bf --- /dev/null +++ b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/nested-cross-origin.sub.html @@ -0,0 +1,61 @@ +<!DOCTYPE html> +<meta name="viewport" content="width=device-width,initial-scale=1"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../resources/intersection-observer-test-utils.js"></script> + +<style> +pre, #log { + position: absolute; + top: 0; + left: 200px; +} +iframe { + width: 300px; + height: 150px; + border: none; +} +</style> + +<iframe src="http://{{domains[www1]}}:{{ports[http][0]}}/intersection-observer/resources/v2-midframe.sub.html"></iframe> + +<script> +async_test(function(t) { + let iframe = document.querySelector("iframe"); + + function step0(event) { + assert_equals(event.data,""); + } + + function step1(event) { + assert_equals(JSON.stringify(event.data), + JSON.stringify([true])); + iframe.style.opacity = "0.9"; + } + + function step2(event) { + assert_equals(JSON.stringify(event.data), + JSON.stringify([false])); + iframe.style.opacity = ""; + } + + function step3(event) { + assert_equals(JSON.stringify(event.data), + JSON.stringify([true])); + } + + let steps = [step0, step1, step2, step3];
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-midframe.sub.html b/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-midframe.sub.html
new file mode 100644
index 0000000..b9b055b6
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-midframe.sub.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<style>
+iframe {
+ width: 300px;
+ height: 150px;
+ border: none;
+}
+</style>
+
+<iframe src="http://{{domains[www2]}}:{{ports[http][0]}}/intersection-observer/resources/v2-subframe.html"></iframe>
+
+<script>
+window.addEventListener("message", event => {
+ requestAnimationFrame(() => setTimeout(() => {
+ document.querySelector('iframe').contentWindow.postMessage(event.data, "*");
+ }));
+});
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-subframe.html b/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-subframe.html
index 295bbf04..cfc82646 100644
--- a/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-subframe.html
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-subframe.html
@@ -16,7 +16,7 @@
window.addEventListener("message", event => {
waitForNotification(() => {
- window.parent.postMessage(results.map(e => e.isVisible), "*");
+ window.top.postMessage(results.map(e => e.isVisible), "*");
results = [];
});
});
@@ -27,6 +27,6 @@
results = entries;
}, {trackVisibility: true, delay: delay});
observer.observe(document.getElementById("target"));
- window.parent.postMessage("", "*");
+ window.top.postMessage("", "*");
};
</script>
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/v2/nested-cross-origin.sub.html b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/nested-cross-origin.sub.html
new file mode 100644
index 0000000..42a69bf
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/nested-cross-origin.sub.html
@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="../resources/intersection-observer-test-utils.js"></script>
+
+<style>
+pre, #log {
+ position: absolute;
+ top: 0;
+ left: 200px;
+}
+iframe {
+ width: 300px;
+ height: 150px;
+ border: none;
+}
+</style>
+
+<iframe src="http://{{domains[www1]}}:{{ports[http][0]}}/intersection-observer/resources/v2-midframe.sub.html"></iframe>
+
+<script>
+async_test(function(t) {
+ let iframe = document.querySelector("iframe");
+
+ function step0(event) {
+ assert_equals(event.data,"");
+ }
+
+ function step1(event) {
+ assert_equals(JSON.stringify(event.data),
+ JSON.stringify([true]));
+ iframe.style.opacity = "0.9";
+ }
+
+ function step2(event) {
+ assert_equals(JSON.stringify(event.data),
+ JSON.stringify([false]));
+ iframe.style.opacity = "";
+ }
+
+ function step3(event) {
+ assert_equals(JSON.stringify(event.data),
+ JSON.stringify([true]));
+ }
+
+ let steps = [step0, step1, step2, step3];
+
+ window.addEventListener("message", event => {
+ if (steps.length) {
+ t.step(steps.shift(), t, event);
+ waitForFrame(t, () => {
+ iframe.contentWindow.postMessage("", "*");
+ });
+ } else {
+ t.done();
+ }
+ });
+
+}, "Intersection observer V2 test with nested cross-origin iframes.");
+</script>
Original Bug Report
Bypass of https://issues.chromium.org/issues/333708039
VULNERABILITY DETAILS
This vulnerability is similar to https://issues.chromium.org/issues/333708039, in this bug when the cursor focus on google one tap button after that the opacity the frame set 0 (obscured by “click me see funny cats” button) the focus still to google tap button lead to click jacking
VERSION Chrome Version 131.0.6755.0 (Official Build) canary (64-bit) Operating System: Windows 10
REPRODUCTION CASE
- open https://thundering-unruly-windflower.glitch.me/spoofh.html
- click on “click me see funny cats” button
CREDIT INFORMATION Externally reported security bugs may appear in Chrome release notes. If this bug is included, how would you like to be credited? Reporter credit: Hafiizh (https://www.linkedin.com/in/hafiizh-7aa6bb31/)