Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in Frames
DescriptionInsufficient policy enforcement in Frames
ComponentFrames
Bug ClassLogic Error
Tracker422531206
Fix commit50046fdcc3b6 (chromium/src) +64/-1
CISA KEVNot listed
CreditedLuan Herrera (@lbherrera_)
Disclosed2026-02-10

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/paint/paint_layer.cc
modified
async_test
third_party/blink/web_tests/external/wpt/intersection-observer/v2/z-index-changes.html
modified
if
third_party/blink/web_tests/external/wpt/intersection-observer/v2/z-index-changes.html
modified

Files Changed

  • third_party/blink/renderer/core/paint/paint_layer.cc
  • third_party/blink/web_tests/external/wpt/intersection-observer/v2/z-index-changes.html
From 50046fdcc3b66587530d7ec09e8f58c8b467a755 Mon Sep 17 00:00:00 2001
From: Philip Rogers <pdr@chromium.org>
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 <pdr@chromium.org>
Auto-Submit: Philip Rogers <pdr@chromium.org>
Reviewed-by: Stefan Zager <szager@chromium.org>
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>
Loading diff…

Regression Test / PoC

shipped with the fix
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>
Loading diff…

Original Bug Report

reported by he...@gmail.com

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:
  1. Attacker creates an overlay element with z-index: "-9999" positioned below the target iframe.
  2. The target iframe uses Intersection Observer V2 for visibility detection.
  3. The observer correctly reports the iframe as visible (since the overlay is below it).
  4. Attacker changes the overlay’s z-index via JavaScript using overlay.style.zIndex = "9999".
  5. The overlay moves above the iframe in the stacking order.
  6. 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
  1. Download the following files: bypass.html, expected-no-overlay.html, expected-overlay.html, frame.html, gis.html, google-clickjacking.html, and cat.jpg.
  2. Move all files into the same folder.
  3. 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
  1. 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.
  2. 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.
  3. 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
  1. Make sure you are logged into your Google Account.
  2. Navigate to http://localhost:8080/google-clickjacking.html and click the button.
  3. Notice that your identity is leaked to the attacker’s page.

CREDIT INFORMATION

Reporter credit: Luan Herrera (@lbherrera_)

View on issue tracker