Medium watch CVSS 5.4 chrome Spoofing 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
5.4
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUI misrepresentation in Geometry
DescriptionUI misrepresentation in Geometry
ComponentGeometry
Sub-componentIntersectionObserver
Bug ClassSpoofing
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker518039263
Fix commit3432bcd9ac4c (chromium/src) +72/-1
CWECWE-451
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:L
CISA KEVNot listed
CreditedGoogle
Reported2026-05-29 (per vendor advisory)
Fix landed2026-08-06 (public git)
Disclosed2026-09-08
Patch gap33 days — fix public in source before the advisory

Background

Occlusion hit testing
the internal hit test Blink runs (via HitTestForOcclusion) to determine whether another element paints on top of a target, used by IntersectionObserver v2 (trackVisibility) to report whether an element is actually visible.
`preserve-3d` / 3D flattening
transform-style: preserve-3d keeps descendants in a shared 3D space, but when an ancestor stacking context is flat, a child’s translateZ offset is flattened away and paint order reverts to normal (later-in-DOM = on top).
`z_offset`
a pointer, dereferenced as *z_offset, holding the z depth of the current hit-test reference plane against which a candidate layer’s child_z_offset is compared.
`child_z_offset`
the z depth computed for the candidate (potential occluder) layer, derived from its transformed hit point (pt3.z()) in the diff.

Root Cause Analysis

In third_party/blink/renderer/core/paint/paint_layer.cc, the occlusion hit-test path compared the candidate layer’s depth against a hardcoded zero (if (child_z_offset < 0)) and, when true, executed return false to treat that layer as non-occluding. This assumed the reference plane was always at z=0, which is invalid inside a preserve-3d context nested under a 3D-flattening ancestor: there, a negative translateZ on the candidate is flattened into the ancestor’s plane, so the layer still paints on top per normal paint order even though its raw child_z_offset is negative. The removed comment behavior therefore mis-classified a real occluder as behind the target, causing the occlusion test to miss it.

The fix replaces the constant with the actual reference depth, if (child_z_offset < *z_offset), so a layer is only discarded as non-occluding when it is genuinely deeper than the current reference plane — in the flattened case the two depths coincide and the layer is correctly retained as an occluder. This is a check correction only; the patch changes a comparison operand and adds no new sink, allocation, or dereference.

Key insight
The single mistake was comparing child_z_offset against the literal 0 instead of against the true reference depth *z_offset, which silently baked in a “reference plane is at z=0” assumption that breaks under 3D flattening; the fix restores the correct relative comparison so flattened-away negative (and positive) z offsets no longer let a real occluder be treated as non-occluding.

Attack Path

  1. Construct a flattened preserve-3d occluder An attacker page places a #target element and, later in DOM order, an #occluder with transform: translateZ(-10px); transform-style: preserve-3d inside a flat parent stacking context, so the occluder visually paints on top of the target.
  2. Trigger occlusion hit testing The page observes #target with IntersectionObserver using {trackVisibility: true}, which invokes HitTestForOcclusion and reaches the vulnerable comparison in paint_layer.cc.
  3. Mis-classify the occluder Because child_z_offset (-10) is < 0, the pre-patch code hits return false and drops the occluder, so the hit test reports no occlusion and the observer delivers an entry with isVisible == true even though the target is fully covered.
  4. Misrepresent visibility to script The attacker’s page now believes/asserts the target is visible while it is actually hidden behind attacker-controlled content, defeating the occlusion guarantee trackVisibility is meant to provide. Any downstream exploitation of that false visibility signal (e.g. clickjacking-style tricking of an embedded or protected element into believing it is unobscured) is (inferred) and not demonstrated by the diff.

Impact Assessment

An attacker who controls page content gains the ability to make IntersectionObserver v2 report an occluded element as visible (isVisible == true) within the renderer process, i.e. a UI-misrepresentation / spoofing primitive, with the sole precondition being an occluder styled with preserve-3d and a z offset inside a flattened 3D context. The advisory classifies this as a Spoofing bug (“UI misrepresentation in Geometry”) and reports no memory corruption; any escalation to clickjacking of visibility-gated content or other UI-redress attacks is (inferred) beyond what the patch establishes. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 5.4. A specific severity rating are not stated in the provided advisory metadata.

Changed Functions

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

Files Changed

  • third_party/blink/web_tests/external/wpt/intersection-observer/v2/3d-transform-flattened-preserve-3d-occlusion.html
  • third_party/blink/renderer/core/paint/paint_layer.cc
  • third_party/blink/renderer/core/layout/hit_testing_test.cc

Audit Directions

  • Hardcoded reference-frame assumptions
    Search occlusion, paint-order, and z-depth comparisons for literal 0 (or other constant) operands where a live reference value (like *z_offset) should be used; flattening or transform-space changes can invalidate the “reference is at origin” assumption.
  • 3D flattening vs. preserve-3d edge cases
    Review hit-testing and stacking code that special-cases transform-style: preserve-3d, checking that behavior is correct when a flattening ancestor collapses descendant z offsets back into normal paint order.
  • Security-relevant visibility APIs
    Audit other consumers of HitTestForOcclusion and IntersectionObserver trackVisibility/isVisible for paths where an incorrect occlusion result yields a false visibility signal usable for UI misrepresentation.
From 3432bcd9ac4cfeea6baf5c71427ca98b53d593ef Mon Sep 17 00:00:00 2001
From: Stefan Zager <szager@chromium.org>
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 <pdr@chromium.org>
Commit-Queue: Philip Rogers <pdr@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1675279}
---
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/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…

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 vm...@google.com

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