Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUI misrepresentation in Geometry
DescriptionUI misrepresentation in Geometry
ComponentGeometry
Bug ClassLogic Error
Tracker518039263
Fix commit3432bcd9ac4c (chromium/src) +72/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

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

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-flattened-preserve-3d-occlusion.html
From 3432bcd9ac4cfeea6baf5c71427ca98b53d593ef Mon Sep 17 00:00:00 2001
From: Stefan Zager <[email protected]>
Date: Thu, 06 Aug 2026 14:13:30 -0700
Subject: [PATCH] [IntersectionObserver] Fix hit testing for flattened 3D context

When hit testing for occlusion in a preserve-3d context inside a
3d-flattening ancestor, checking for negative z-offset does not
guarantee the layer being tested is not occluding. The negative
z-offset will be flattened into the ancestor's plane and normal paint
order hit testing will apply.

Bug: 518039263
Change-Id: I4fd949f1c72a0a730ec3a05d82fe52e31b9ee9b8
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8202385
Reviewed-by: Philip Rogers <[email protected]>
Commit-Queue: Philip Rogers <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1675279}
---

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 6749e72..2b21bfff 100644
--- a/third_party/blink/renderer/core/layout/hit_testing_test.cc
+++ b/third_party/blink/renderer/core/layout/hit_testing_test.cc
@@ -484,4 +484,42 @@
   EXPECT_EQ(result.InnerNode(), target);
 }
 
+TEST_F(HitTestingTest, OcclusionHitTestWithFlattenedPreserve3DOccluder) {
+  SetBodyInnerHTML(R"HTML(
+    <style>
+    div {
+      position: absolute;
+      width: 100px;
+      height: 100px;
+    }
+    #target {
+      background: green;
+    }
+    #occluder {
+      background: red;
+      transform: translateZ(-10px);
+      transform-style: preserve-3d;
+    }
+    </style>
+    <div id=target></div>
+    <div id=occluder></div>
+  )HTML");
+
+  Element* target = GetElementById("target");
+  Element* occluder = GetElementById("occluder");
+
+  // The occluder paints on top of the target because the parent stacking
+  // context is flat (its translateZ is flattened away, and it comes later in
+  // DOM order).
+  HitTestResult result = HitTestForOcclusion(*target);
+  EXPECT_EQ(result.InnerNode(), occluder);
+
+  // Same with a positive z offset.
+  occluder->SetInlineStyleProperty(CSSPropertyID::kTransform,
+                                   "translateZ(10px)");
+  UpdateAllLifecyclePhasesForTest();
+  result = HitTestForOcclusion(*target);
+  EXPECT_EQ(result.InnerNode(), occluder);
+}
+
 }  // 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 597eada..a93740e 100644
--- a/third_party/blink/renderer/core/paint/paint_layer.cc
+++ b/third_party/blink/renderer/core/paint/paint_layer.cc
@@ -1243,7 +1243,7 @@
           child_z_offset = pt3.z();
         }
       }
-      if (child_z_offset < 0) {
+      if (child_z_offset < *z_offset) {
         return false;
       }
     } else {
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/v2/3d-transform-flattened-preserve-3d-occlusion.html b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/3d-transform-flattened-preserve-3d-occlusion.html
new file mode 100644
index 0000000..9999fb3b
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/3d-transform-flattened-preserve-3d-occlusion.html
@@ -0,0 +1,33 @@
+<!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>
+  div {
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 100px;
+    height: 100px;
+  }
+  #target {
+    background: red;
+  }
+  #occluder {
+    transform: translateZ(-10px);
+    transform-style: preserve-3d;
+    background: green;
+    pointer-events: none;
+  }
+</style>
+<div id="target"></div>
+<div id="occluder"></div>
+
+<script>
+  setup({ single_test: true });
+  const target = document.getElementById("target");
+  new IntersectionObserver(entries => {
+    assert_false(entries[0].isVisible);
+    done();
+  }, {trackVisibility: true, delay: 100}).observe(target);
+</script>
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 6749e72..2b21bfff 100644
--- a/third_party/blink/renderer/core/layout/hit_testing_test.cc
+++ b/third_party/blink/renderer/core/layout/hit_testing_test.cc
@@ -484,4 +484,42 @@
   EXPECT_EQ(result.InnerNode(), target);
 }
 
+TEST_F(HitTestingTest, OcclusionHitTestWithFlattenedPreserve3DOccluder) {
+  SetBodyInnerHTML(R"HTML(
+    <style>
+    div {
+      position: absolute;
+      width: 100px;
+      height: 100px;
+    }
+    #target {
+      background: green;
+    }
+    #occluder {
+      background: red;
+      transform: translateZ(-10px);
+      transform-style: preserve-3d;
+    }
+    </style>
+    <div id=target></div>
+    <div id=occluder></div>
+  )HTML");
+
+  Element* target = GetElementById("target");
+  Element* occluder = GetElementById("occluder");
+
+  // The occluder paints on top of the target because the parent stacking
+  // context is flat (its translateZ is flattened away, and it comes later in
+  // DOM order).
+  HitTestResult result = HitTestForOcclusion(*target);
+  EXPECT_EQ(result.InnerNode(), occluder);
+
+  // Same with a positive z offset.
+  occluder->SetInlineStyleProperty(CSSPropertyID::kTransform,
+                                   "translateZ(10px)");
+  UpdateAllLifecyclePhasesForTest();
+  result = HitTestForOcclusion(*target);
+  EXPECT_EQ(result.InnerNode(), occluder);
+}
+
 }  // namespace blink
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/v2/3d-transform-flattened-preserve-3d-occlusion.html b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/3d-transform-flattened-preserve-3d-occlusion.html
new file mode 100644
index 0000000..9999fb3b
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/3d-transform-flattened-preserve-3d-occlusion.html
@@ -0,0 +1,33 @@
+<!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>
+  div {
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 100px;
+    height: 100px;
+  }
+  #target {
+    background: red;
+  }
+  #occluder {
+    transform: translateZ(-10px);
+    transform-style: preserve-3d;
+    background: green;
+    pointer-events: none;
+  }
+</style>
+<div id="target"></div>
+<div id="occluder"></div>
+
+<script>
+  setup({ single_test: true });
+  const target = document.getElementById("target");
+  new IntersectionObserver(entries => {
+    assert_false(entries[0].isVisible);
+    done();
+  }, {trackVisibility: true, delay: 100}).observe(target);
+</script>
Loading diff…

Original Bug Report

reported by [email protected]

Bypass of PEPC / IO-v2 Visibility Checks via Sibling Occluder with 3D Transform

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A logic flaw in Blink’s paint and hit-testing implementation permits a potential bypass of IntersectionObserver v2 (trackVisibility) and the Permission Element (PEPC) anti-clickjacking checks. An attacker can place an occluding sibling element with a 3D transform and preserve-3d in a flat parent container to fully cover the target element, while the renderer erroneously reports the target as fully visible. This allows potential clickjacking attacks that trigger permission grants without user awareness.

Affected files:

  • third_party/blink/renderer/core/paint/paint_layer.cc
  • third_party/blink/renderer/core/layout/layout_object.cc

Estimated timestamp from git blame: 2026-05-11

Description

A logic flaw in Blink’s paint and hit-testing implementation allows a potential bypass of IntersectionObserver v2 (trackVisibility) and the anti-clickjacking security checks for the Permission Element (PEPC / <permission>). An attacker can craft a visual decoy that completely covers a target PEPC element, yet the renderer erroneously reports the PEPC element as fully visible (isVisible = true). Combined with pointer-events: none on the decoy, user clicks on the decoy are forwarded directly to the hidden PEPC element, potentially allowing deceptive permission grants.

Root Cause Analysis

To protect against 3D-transform-based occlusion bypasses, a guard in LayoutObject::HasDistortingVisualEffects() verifies that the target’s transform chain is coplanar with the local frame root using IsCoplanarWith():

// third_party/blink/renderer/core/layout/layout_object.cc
if (!paint_properties.Transform().Unalias().IsCoplanarWith(
        root_properties.Transform().Unalias())) {
  return true;
}

However, this guard only inspects the transform chain of the target element. It does not inspect the transform properties of potential occluding elements.

During occlusion hit-testing, Blink analyzes candidates. In PaintLayer::IsHitCandidateForDepthOrder(), there is an early-rejection check for elements with 3D transforms:

// third_party/blink/renderer/core/paint/paint_layer.cc
if (occlusion_hit_test && hit_layer->Has3DTransform()) {
  child_z_offset = -std::numeric_limits<double>::infinity();
  ...
  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) {
    return false;     // Erroneously rejects the occluding layer
  }
}

This rejection assumes that if an occluding layer’s Z-depth calculation is negative (i.e., child_z_offset < 0), the element must be positioned behind the target (which is assumed to reside at z = 0).

This assumption is invalid unless both elements share the same 3D rendering context. If a sibling occluding element X specifies a negative 3D translate transform (e.g., translateZ(-10px)) and transform-style: preserve-3d but resides inside a flat parent container (such as the default document body), its 3D transform is flattened to the 2D plane during visual paint. Since both elements are positioned, the standard 2D painting order dictates that X (appearing later in the DOM) is rendered directly on top of the target, fully obscuring it.

During HitTestForOcclusion, X is evaluated. Because X has preserve-3d, its transform state retains the un-flattened translateZ(-10px). The 4-corner mapping evaluates child_z_offset = -10 < 0, returning false from IsHitCandidateForDepthOrder. As a result, the occlusion check ignores X entirely, and the visibility check reports the target as fully visible.

Potential Steps to Reproduce

An attacker could serve an HTML page structured as follows:

<geolocation id="T" style="position:absolute; left:0; top:0"></geolocation>
<div id="X" style="position:absolute; left:0; top:0; width:200px; height:40px;
                 transform: translateZ(-10px); transform-style: preserve-3d;
                 background:green; pointer-events:none">Click here for a gift</div>
  1. Visual Presentation: The parent of T and X is flat. Thus, X’s 3D transform translateZ(-10px) is flattened. In the visual tree, X paints on top of T due to later DOM order and fully occludes T.
  2. Visibility Check: ComputeVisibilityInfo(T) evaluates whether T has distorting visual effects. Since T has no 3D ancestors, the IsCoplanarWith() guard evaluates to true and the function proceeds to HitTestForOcclusion.
  3. Occlusion Hit-testing: The hit-test reverse-iterates through candidate layers. Sibling X is processed first.
  4. Early Reject: IsHitCandidateForDepthOrder evaluates X’s un-flattened transform, resulting in child_z_offset = -10 < 0 and returns false, discarding X as a potential occluding element.
  5. False Positive Visibility: The loop falls back to T, which is successfully hit. The visibility tracker reports T as fully visible (isVisible = true).
  6. Deceptive Interaction: Because X has pointer-events: none, any click on the decoy X goes directly to the underlying T. The PEPC element accepts the click and triggers/grants the permission prompt without any safety delay.

Note: These are potential steps as our tooling agent currently lacks the ability to run code or compile tests.

Suggested Fix

Update the occlusion hit-testing logic in PaintLayer::IsHitCandidateForDepthOrder to ensure rejections based on negative z-offsets only occur when the occluding element and the target share the same 3D rendering context, or when the transform states have been correctly flattened relative to the nearest common flat ancestor layer.

Evaluated with Chrome root at commit: fb72408a8493c46bc75fae1c70d03daec96b3040


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker