Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Geometry
DescriptionInappropriate implementation in Geometry
ComponentGeometry
Bug ClassLogic Error
Tracker479203484
Fix commitbd827703400c (chromium/src) +145/-12
CISA KEVNot listed
CreditedLuan Herrera (@lbherrera_)
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
TEST_F
third_party/blink/renderer/core/layout/hit_testing_test.cc
modified
if
third_party/blink/renderer/core/paint/paint_layer.cc
modified
for
third_party/blink/renderer/core/paint/paint_layer.cc
modified

Files Changed

  • third_party/blink/renderer/core/layout/hit_testing_test.cc
  • third_party/blink/renderer/core/paint/paint_layer.cc
  • third_party/blink/web_tests/external/wpt/intersection-observer/v2/3d-transform-occlusion.html
From bd827703400c306193f3a9e5ca30abc3f694147c Mon Sep 17 00:00:00 2001
From: Stefan Zager <[email protected]>
Date: Mon, 11 May 2026 21:33:55 -0700
Subject: [PATCH] [IntersectionObserver] Fix occlusion detection with 3D transform

For regular (i.e. event-targeting) hit testing, z-axis ordering is
based on the z-axis position of the center of a PaintLayer. That's
not good enough for occlusion testing.

With this CL, when a hit test for occlusion encounters a 3d transform,
it computes the z-axis position of the four corners of the PaintLayer,
and if any of them are above any point in the occlusion target (i.e.
the HitTestRequest::stop_node_) then the PaintLayer is considered
occluding. This is not 100% accurate, but it will never result in a
false positive (i.e., reporting the target as unoccluded when it
actually is), which is a hard requirement of IntersectionObserver.

The corner-checking code makes a simplifying assumption that the
stop_node_ has no 3D projection, which is enforced by a call to
LayoutObject::HasDistortingVisualEffects from IntersectionObserver.

Bug: 479203484
Change-Id: Ida70f919efc73149d32112900b019987f27a5a7e
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7835432
Reviewed-by: Philip Rogers <[email protected]>
Commit-Queue: Stefan Zager <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1629048}
---

diff --git a/third_party/blink/renderer/core/layout/hit_testing_test.cc b/third_party/blink/renderer/core/layout/hit_testing_test.cc
index 06d8d4a..6749e72 100644
--- a/third_party/blink/renderer/core/layout/hit_testing_test.cc
+++ b/third_party/blink/renderer/core/layout/hit_testing_test.cc
@@ -446,4 +446,42 @@
             PhysicalRect(-10, -10, 120, 120));
 }
 
+TEST_F(HitTestingTest, OcclusionHitTestWith3DTransform) {
+  SetBodyInnerHTML(R"HTML(
+    <style>
+    body {
+      transform-style: preserve-3d;
+    }
+    div {
+      position: absolute;
+      width: 100px;
+      height: 100px;
+    }
+    #target {
+      background: green;
+    }
+    #occluder {
+      background: red;
+      transform: translateZ(-10px) rotateY(30deg);
+      transform-origin: center;
+    }
+    </style>
+    <div id=occluder></div>
+    <div id=target></div>
+  )HTML");
+
+  Element* target = GetElementById("target");
+  Element* occluder = GetElementById("occluder");
+
+  HitTestResult result = HitTestForOcclusion(*target);
+  EXPECT_EQ(result.InnerNode(), occluder);
+
+  // Place the occluder entirely behind the target.
+  occluder->SetInlineStyleProperty(CSSPropertyID::kTransform,
+                                   "translateZ(-10px) rotateY(10deg)");
+  UpdateAllLifecyclePhasesForTest();
+  result = HitTestForOcclusion(*target);
+  EXPECT_EQ(result.InnerNode(), target);
+}
+
 }  // namespace blink
diff --git a/third_party/blink/renderer/core/paint/paint_layer.cc b/third_party/blink/renderer/core/paint/paint_layer.cc
index 76d1741..cd95602 100644
--- a/third_party/blink/renderer/core/paint/paint_layer.cc
+++ b/third_party/blink/renderer/core/paint/paint_layer.cc
@@ -1182,7 +1182,8 @@
     const PaintLayer* hit_layer,
     bool can_depth_sort,
     double* z_offset,
-    const HitTestingTransformState* transform_state) {
+    const HitTestingTransformState* transform_state,
+    bool occlusion_hit_test) {
   if (!hit_layer)
     return false;
 
@@ -1199,9 +1200,37 @@
   DCHECK(!z_offset || transform_state ||
          hit_layer->GetLayoutObject().IsSVGForeignObject());
   if (z_offset && transform_state) {
-    // This is actually computing our z, but that's OK because the hitLayer is
-    // coplanar with us.
-    double child_z_offset = ComputeZOffset(*transform_state);
+    double child_z_offset;
+    // When performing a hit test for occlusion, we consider a layer to be
+    // occluding if any part of it has a z-axis position greater than or equal
+    // to the z-axis position of the occlusion target. The occlusion target is
+    // assumed not to have a 3D projection, so its z-axis position is uniformly
+    // zero (see the call to target->HasDistortingVisualEffects() in
+    // intersection_geometry.cc). For the layer under test, we compute the
+    // z-axis position at all four corners to find the max.
+    if (occlusion_hit_test && hit_layer->Has3DTransform()) {
+      child_z_offset = -std::numeric_limits<double>::infinity();
+      PhysicalRect rect = hit_layer->GetLayoutObject().VisualOverflowRect();
+      gfx::QuadF local_quad{gfx::RectF(rect)};
+      gfx::PointF pts[4] = {local_quad.p1(), local_quad.p2(), local_quad.p3(),
+                            local_quad.p4()};
+      for (const auto& pt : pts) {
+        gfx::Point3F pt3(pt.x(), pt.y(), 0);
+        pt3 = transform_state->AccumulatedTransform().MapPoint(pt3);
+        if (pt3.z() > child_z_offset) {
+          child_z_offset = pt3.z();
+        }
+      }
+      if (child_z_offset >= 0) {
+        *z_offset = child_z_offset;
+        return true;
+      }
+      return false;
+    } else {
+      // This is actually computing our z, but that's OK because the hitLayer is
+      // coplanar with us.
+      child_z_offset = ComputeZOffset(*transform_state);
+    }
     if (child_z_offset > *z_offset) {
       *z_offset = child_z_offset;
       return true;
@@ -1534,12 +1563,14 @@
               RuntimeEnabledFeatures::
                       HitTestContainerTransformStateForPreserve3dEnabled()
                   ? container_transform_state
-                  : local_transform_state) &&
+                  : local_transform_state,
+              temp_result.GetHitTestRequest().IsHitTestVisualOverflow()) &&
           IsHitCandidateForStopNode(GetLayoutObject(), stop_node)) {
-        if (result.GetHitTestRequest().ListBased())
+        if (result.GetHitTestRequest().ListBased()) {
           result.Append(temp_result);
-        else
+        } else {
           result = temp_result;
+        }
         if (!depth_sort_descendants)
           return this;
         // Foreground can depth-sort with descendant layers, so keep this as a
@@ -1578,12 +1609,14 @@
             RuntimeEnabledFeatures::
                     HitTestContainerTransformStateForPreserve3dEnabled()
                 ? container_transform_state
-                : local_transform_state) &&
+                : local_transform_state,
+            temp_result.GetHitTestRequest().IsHitTestVisualOverflow()) &&
         IsHitCandidateForStopNode(GetLayoutObject(), stop_node)) {
-      if (result.GetHitTestRequest().ListBased())
+      if (result.GetHitTestRequest().ListBased()) {
         result.Append(temp_result);
-      else
+      } else {
         result = temp_result;
+      }
       return this;
     }
     if (inside_fragment_background_rect &&
@@ -1904,8 +1937,9 @@
       result.Append(temp_result);
     }
 
-    if (IsHitCandidateForDepthOrder(hit_layer, depth_sort_descendants, z_offset,
-                                    local_transform_state)) {
+    if (IsHitCandidateForDepthOrder(
+            hit_layer, depth_sort_descendants, z_offset, local_transform_state,
+            result.GetHitTestRequest().IsHitTestVisualOverflow())) {
       result_layer = hit_layer;
       if (!result.GetHitTestRequest().ListBased())
         result = temp_result;
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/v2/3d-transform-occlusion.html b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/3d-transform-occlusion.html
new file mode 100644
index 0000000..e7161521
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/3d-transform-occlusion.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>
+
+<style>
+ body {
+   transform-style: preserve-3d;
+ }
+ div {
+   position: absolute;
+   width: 100px;
+   height: 100px;
+ }
+ #target1, #target2 {
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/layout/hit_testing_test.cc b/third_party/blink/renderer/core/layout/hit_testing_test.cc
index 06d8d4a..6749e72 100644
--- a/third_party/blink/renderer/core/layout/hit_testing_test.cc
+++ b/third_party/blink/renderer/core/layout/hit_testing_test.cc
@@ -446,4 +446,42 @@
             PhysicalRect(-10, -10, 120, 120));
 }
 
+TEST_F(HitTestingTest, OcclusionHitTestWith3DTransform) {
+  SetBodyInnerHTML(R"HTML(
+    <style>
+    body {
+      transform-style: preserve-3d;
+    }
+    div {
+      position: absolute;
+      width: 100px;
+      height: 100px;
+    }
+    #target {
+      background: green;
+    }
+    #occluder {
+      background: red;
+      transform: translateZ(-10px) rotateY(30deg);
+      transform-origin: center;
+    }
+    </style>
+    <div id=occluder></div>
+    <div id=target></div>
+  )HTML");
+
+  Element* target = GetElementById("target");
+  Element* occluder = GetElementById("occluder");
+
+  HitTestResult result = HitTestForOcclusion(*target);
+  EXPECT_EQ(result.InnerNode(), occluder);
+
+  // Place the occluder entirely behind the target.
+  occluder->SetInlineStyleProperty(CSSPropertyID::kTransform,
+                                   "translateZ(-10px) rotateY(10deg)");
+  UpdateAllLifecyclePhasesForTest();
+  result = HitTestForOcclusion(*target);
+  EXPECT_EQ(result.InnerNode(), target);
+}
+
 }  // namespace blink
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/v2/3d-transform-occlusion.html b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/3d-transform-occlusion.html
new file mode 100644
index 0000000..e7161521
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/3d-transform-occlusion.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>
+
+<style>
+ body {
+   transform-style: preserve-3d;
+ }
+ div {
+   position: absolute;
+   width: 100px;
+   height: 100px;
+ }
+ #target1, #target2 {
+   background: green;
+ }
+ #target2 {
+   left: 150px;
+ }
+ #occluder {
+   background: red;
+   transform: translateZ(-10px) rotateY(30deg);
+   transform-origin: center;
+ }
+ #behind {
+   left: 150px;
+   background: red;
+   transform: translateZ(-10px) rotateY(10deg);
+   transform-origin: center;
+ }
+</style>
+
+<div id=occluder></div>
+<div id=target1></div>
+
+<div id=behind></div>
+<div id=target2></div>
+
+<script>
+  promise_test(() => {
+    return Promise.all([
+      new Promise((resolve, reject) => {
+        const target = document.getElementById("target1");
+        new IntersectionObserver(entries => {
+          // part of #occluder is visible above #target1
+          assert_false(entries[0].isVisible);
+          resolve();
+        }, {trackVisibility:true, delay:100}).observe(target);
+      }),
+      new Promise((resolve, reject) => {
+        const target = document.getElementById("target2");
+        new IntersectionObserver(entries => {
+          // #behind is entirely behind #target2
+          assert_true(entries[0].isVisible);
+          resolve();
+        }, {trackVisibility:true, delay:100}).observe(target);
+      })
+    ]);
+  }, "Intersection observer V2 test with occlusion by an element with a 3D transform.");
+</script>
Loading diff…

Original Bug Report

reported by [email protected]

Intersection Observer v2 API fails to correctly determine target's visibility when overlay uses CSS 3D transforms, 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 when the occluding element uses CSS 3D transforms. By placing an overlay element with transform-style: preserve-3d and 3D transform properties such as translateZ and rotateY above the target iframe, 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 a container with transform-style: preserve-3d.
  2. The target iframe is placed inside this container.
  3. An overlay element is placed above the iframe with CSS 3D transforms (translateZ(1px) rotateY(1deg)).
  4. The target iframe uses Intersection Observer V2 for visibility detection with trackVisibility: true.
  5. The observer incorrectly reports the iframe as visible, even though the overlay completely covers it visually.

Since the Intersection Observer v2 API does not reliably determine visibility in 3D transform scenarios, 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.mp4) and the Google One Tap SDK attack (repro-tap.mp4).

BISECT

By doing an initial bisect, it was identified that the affected ranges are between 626286 and 626301 (https://chromium.googlesource.com/chromium/src/+log/c9d9b04bf831ea737d25dea59a31bd4c9f870fb2..0b65cb95ed32a8737c3cf4e82d7f602ac6624987).

The commit responsible for that was: https://chromium.googlesource.com/chromium/src/+/0b65cb95ed32a8737c3cf4e82d7f602ac6624987.

Looking into it, this commit enabled the IOv2 feature by default. By running the bisect again with the following command:

python3 bisect-builds.py -a win64 -b M76 -g M65 --verify-range -- --no-first-run --enable-blink-features=IntersectionObserverV2 --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/1c149502277c1441eec693c3ec160462e150000b..4acd4805db0d79872a6ec904e28f1587bada7389

After investigating, it became clear that the commit that introduced the issue is https://chromium.googlesource.com/chromium/src/+/7bb6c9acc4a534866c72afb15c2ca33a3f78e34f.

VERSION

Chrome Version: 144.0.7559.97 (Stable)
Chrome Version: 145.0.7632.18 (Beta)
Chrome Version: 146.0.7647.4 (Dev)
Chrome Version: 146.0.7653.0 (Canary)
Operating System: Windows 11 24H2

REPRODUCTION CASE

Steps to setup the PoC
  1. Download the following files: bypass.html, expected-overlay.html, frame.html, gis.html and google-clickjacking.html.
  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/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