CVE-2026-2316
Overview
Background
- IntersectionObserver v2 (`trackVisibility`)
- an extension of
IntersectionObserverthat reportsisVisible, letting a frame check whether it is actually unoccluded, a signal used as an anti-clickjacking defense. - Intersection observation state
LocalFrameView::SetIntersectionObservationState(LocalFrameView::kDesired)marks a frame view as needing its intersection observations recomputed on the next lifecycle update.- Paint invalidation trigger
- per the commit message, observations are normally recomputed when paint is invalidated (
PaintInvalidator::InvalidatePaint), but az-indexchange does not always cause a paint invalidation.
Root Cause Analysis
In third_party/blink/renderer/core/paint/paint_layer.cc, the branch that handles a stacking-order change (the enclosing function is not named in the hunk) only called stacking_context->SetNeedsRepaint() on the AncestorStackingContext(), because a z-index change alone does not need the layer itself repainted. Since IntersectionObserver recomputation was tied to paint invalidation, a pure z-index change that moved an element over or out from in front of an observed target did not schedule a new observation.
As a result, isVisible reported by an IntersectionObserver v2 observer could stay stale: a frame would keep believing it was visible after an overlay was raised above it.
The fix adds a call to GetLayoutObject().GetFrameView()->SetIntersectionObservationState(LocalFrameView::kDesired) in the same branch, so z-index changes force recomputation of intersection observations; the new WPT z-index-changes.html asserts that isVisible flips to false and back as the overlay’s z-index changes.
z-index changes could alter occlusion without invalidating paint; the fix explicitly requests intersection recomputation whenever stacking order changes.Attack Path
- Embed a protected frame
An attacker page embeds an iframe whose content uses IntersectionObserver v2 (
trackVisibility: true) to verify it is visible before honoring clicks. - Obtain an initial visible observation
The overlay starts with a low
z-index(as in the regression test), so the frame receivesisVisible == true. - Raise an overlay via `z-index` only
The attacker changes only the overlay’s
z-indexso it now covers the frame; pre-patch this did not trigger an intersection recomputation, so the frame keeps its stale visible state. - Clickjack the occluded frame The user clicks on the decoy overlay, and input reaching the frame is accepted as if it were visible (inferred — the patch shows the stale observation, not a specific consuming widget).
Impact Assessment
z-index and can reach the stale-visibility condition; the practical consequence is defeating IntersectionObserver v2–based clickjacking protections of a cross-origin embedded frame, which additionally requires user interaction (a click) and a target frame that relies on isVisible (inferred). No memory-safety impact is involved.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/paint/paint_layer.cc |
modified | |
async_testthird_party/blink/web_tests/external/wpt/intersection-observer/v2/z-index-changes.html |
modified | |
ifthird_party/blink/web_tests/external/wpt/intersection-observer/v2/z-index-changes.html |
modified |
Files Changed
third_party/blink/renderer/core/paint/paint_layer.ccthird_party/blink/web_tests/external/wpt/intersection-observer/v2/z-index-changes.html
Audit Directions
- Occlusion-affecting changes that skip paint invalidationLook for other style changes that alter what covers a frame without invalidating paint (e.g.
opacity/filteron compositor-only paths,transformanimations,pointer-events, top-layer andpopoverchanges) and check they also schedule intersection recomputation. - Throttled/delayed observation updatesReview
LocalFrameViewintersection-observation scheduling and thedelaythrottle for windows whereisVisiblecan be stale while input is still delivered. - Cross-frame visibility signalsAudit other security decisions based on visibility (e.g. permission prompts, payment/WebAuthn frames, input protection heuristics) for reliance on cached occlusion state.
Patch
From 50046fdcc3b66587530d7ec09e8f58c8b467a755 Mon Sep 17 00:00:00 2001 From: Philip Rogers <[email protected]> Date: Wed, 07 Jan 2026 16:11:29 -0800 Subject: [PATCH] Recompute intersection observations on z-index changes Intersection observations are recomputed when paint is invalidated (see PaintInvalidator::InvalidatePaint), but z-index changes do not always invalidate paint, so we need to ensure intersection observations are recomputed in those cases. Fixed: 422531206 Change-Id: I6101353f459fa1579136155f278a28ff4ec2e595 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7411729 Commit-Queue: Philip Rogers <[email protected]> Auto-Submit: Philip Rogers <[email protected]> Reviewed-by: Stefan Zager <[email protected]> Cr-Commit-Position: refs/heads/main@{#1565993} --- diff --git a/third_party/blink/renderer/core/paint/paint_layer.cc b/third_party/blink/renderer/core/paint/paint_layer.cc index 25bc28a6..2eab467 100644 --- a/third_party/blink/renderer/core/paint/paint_layer.cc +++ b/third_party/blink/renderer/core/paint/paint_layer.cc @@ -2348,8 +2348,14 @@ // changes. However, we do need to repaint the containing stacking // context, in order to generate new paint chunks in the correct order. // Raster invalidation will be issued if needed during paint. - if (auto* stacking_context = AncestorStackingContext()) + if (auto* stacking_context = AncestorStackingContext()) { stacking_context->SetNeedsRepaint(); + } + // We also need to invalidate intersection observer, which can be affected + // by z-index changes. + if (LocalFrameView* frame_view = GetLayoutObject().GetFrameView()) { + frame_view->SetIntersectionObservationState(LocalFrameView::kDesired); + } } if (old_style) { diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/v2/z-index-changes.html b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/z-index-changes.html new file mode 100644 index 0000000..8271dda --- /dev/null +++ b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/z-index-changes.html @@ -0,0 +1,57 @@ +<!doctype html> +<meta charset="utf-8"> +<title>Intersection observer visibility should be updated after z-index changes</title> +<link rel="help" href="https://crbug.com/422531206"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<style> +#overlay { + position: absolute; + top: 0; + left: 0; + width: 220px; + height: 220px; + background-color: blue; + z-index: -9999; +} +#frame { + position: relative; + width: 200px; + height: 200px; +} +</style> + +<div id="overlay"></div> +<iframe id="frame" srcdoc="<!doctype html>hello"></iframe> + +<script> +async_test(function(t) { + let observation_count = 0; + + const observer = new IntersectionObserver(t.step_func(function(records) { + records.forEach(function(record) { + observation_count++; + if (observation_count === 1) { + assert_equals(record.isVisible, true, 'Initial observation should be visible'); + + // Adjust the overlay z-index so that it occludes the frame. + overlay.style.zIndex = '9999'; + } else if (observation_count === 2) { + assert_equals(record.isVisible, false, 'Second observation should be obscured'); + + // Adjust the overlay z-index so it no longer occludes the frame. + overlay.style.zIndex = '-9999'; + } else if (observation_count === 3) { + assert_equals(record.isVisible, true, 'Third observation should be visible'); + + observer.disconnect(); + t.done(); + } + }); + }), { trackVisibility: true, delay: 100 }); + + frame.onload = t.step_func(() => { + observer.observe(frame.contentDocument.documentElement); + }); +}, 'IntersectionObserver observes visibility changes from z-index'); +</script>
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/v2/z-index-changes.html b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/z-index-changes.html
new file mode 100644
index 0000000..8271dda
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/z-index-changes.html
@@ -0,0 +1,57 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>Intersection observer visibility should be updated after z-index changes</title>
+<link rel="help" href="https://crbug.com/422531206">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<style>
+#overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 220px;
+ height: 220px;
+ background-color: blue;
+ z-index: -9999;
+}
+#frame {
+ position: relative;
+ width: 200px;
+ height: 200px;
+}
+</style>
+
+<div id="overlay"></div>
+<iframe id="frame" srcdoc="<!doctype html>hello"></iframe>
+
+<script>
+async_test(function(t) {
+ let observation_count = 0;
+
+ const observer = new IntersectionObserver(t.step_func(function(records) {
+ records.forEach(function(record) {
+ observation_count++;
+ if (observation_count === 1) {
+ assert_equals(record.isVisible, true, 'Initial observation should be visible');
+
+ // Adjust the overlay z-index so that it occludes the frame.
+ overlay.style.zIndex = '9999';
+ } else if (observation_count === 2) {
+ assert_equals(record.isVisible, false, 'Second observation should be obscured');
+
+ // Adjust the overlay z-index so it no longer occludes the frame.
+ overlay.style.zIndex = '-9999';
+ } else if (observation_count === 3) {
+ assert_equals(record.isVisible, true, 'Third observation should be visible');
+
+ observer.disconnect();
+ t.done();
+ }
+ });
+ }), { trackVisibility: true, delay: 100 });
+
+ frame.onload = t.step_func(() => {
+ observer.observe(frame.contentDocument.documentElement);
+ });
+}, 'IntersectionObserver observes visibility changes from z-index');
+</script>
Original Bug Report
Intersection Observer v2 API fails to correctly determine target's visibility for dynamically changed z-indexes, enabling clickjacking against Google One Tap
VULNERABILITY DETAILS
While researching variations of issue 333708039, it was discovered that the Intersection Observer v2 API fails to accurately determine a target’s visibility. By initially placing an overlay element with a low z-index beneath the target iframe, and then dynamically changing or removing the overlay’s z-index to bring it above the target after the page loads, an attacker can trick the API into reporting the target as visible when it is actually obscured.
Here’s a breakdown of what is currently happening:
- Attacker creates an overlay element with
z-index: "-9999"positioned below the target iframe. - The target iframe uses Intersection Observer V2 for visibility detection.
- The observer correctly reports the iframe as visible (since the overlay is below it).
- Attacker changes the overlay’s z-index via JavaScript using
overlay.style.zIndex = "9999". - The overlay moves above the iframe in the stacking order.
- The observer still reports the iframe as visible, even though it is now covered.
Since the Intersection Observer v2 API does not reliably determine visibility, any applications relying on it to prevent clickjacking attacks are vulnerable. One such example is the Google One Tap SDK, which embeds an iframe that uses this API to check if its login button is visible to the user when it is clicked. If the login button is not visible, it shows a popup asking for the user’s consent to log in to the website. If the login button is visible, it immediately sends the user’s identity to the website, allowing an attacker to leak the user’s identity.
I have also attached a video reproducing the core attack (repro-core.mov) and the Google One Tap SDK attack (repro-tap.mov).
BISECT
By doing an initial bisect, I confirmed that the issue was not working on version 120.0.6099.56 (stable) and that the vulnerability started working on version 121.0.6167.75 (stable).
The commit responsible for that was: https://chromium.googlesource.com/chromium/src/+/f0d3627a2514f7119906da2012b59f6597d8605b
Looking into it, this commit enabled the IntersectionOptimization feature. By running the bisect again with the following command:
python3 bisect-builds.py -a mac -g m120 -b m121 --verify-range -- --enable-features=IntersectionOptimization --no-first-run --user-data-dir=/tmp http://localhost:8080/bypass.html
I was able to narrow it down to these changes: https://chromium.googlesource.com/chromium/src/+log/00ceb304e8ef1a64ee7ccfb25ce6deb39f5135c3..3e2df989cf54598113c005155dc9c75995c8177b
After investigating, it became clear that the commit that introduced the issue is: https://chromium.googlesource.com/chromium/src/+/e90de993ac2b3222e6b1d6d993922a13273fae8d
VERSION
Chrome Version: 137.0.7151.69 (Stable)
Chrome Version: 139.0.7219.0 (Canary)
Operating System: macOS 14.1.1 (23B81)
REPRODUCTION CASE
Steps to setup the PoC
- Download the following files:
bypass.html,expected-no-overlay.html,expected-overlay.html,frame.html,gis.html,google-clickjacking.html, andcat.jpg. - Move all files into the same folder.
- Serve the files using a web server on port 8080 (this is important because localhost:8080 has been added as an allowed origin in Google One Tap, which is required for it to work).
Steps to reproduce the core issue
- Go to http://localhost:8080/expected-overlay.html to verify how the Intersection Observer V2 API behaves when the target iframe is covered by an overlay. It should show a red background.
- Go to http://localhost:8080/expected-no-overlay.html to verify how the API behaves when there is no overlay. It should show a green background.
- Go to http://localhost:8080/bypass.html to reproduce the issue. Even though the iframe is covered by an overlay, the background still appears green.
Steps to reproduce the Google One Tap PoC
- Make sure you are logged into your Google Account.
- Navigate to http://localhost:8080/google-clickjacking.html and click the button.
- Notice that your identity is leaked to the attacker’s page.
CREDIT INFORMATION
Reporter credit: Luan Herrera (@lbherrera_)
- http://localhost:8080/bypass.html
- http://localhost:8080/expected-no-overlay.html
- http://localhost:8080/expected-overlay.html
- http://localhost:8080/google-clickjacking.html
- https://chromium.googlesource.com/chromium/src/+/e90de993ac2b3222e6b1d6d993922a13273fae8d
- https://chromium.googlesource.com/chromium/src/+/f0d3627a2514f7119906da2012b59f6597d8605b
- https://chromium.googlesource.com/chromium/src/+log/00ceb304e8ef1a64ee7ccfb25ce6deb39f5135c3..3e2df989cf54598113c005155dc9c75995c8177b
- https://issuetracker.google.com/issues/333708039