Medium watch CVSS 5.4 chrome Spoofing 🔧 Commit mapped

Overview

Medium
Severity
5.4
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUI misrepresentation in Geometry
DescriptionUI misrepresentation in Geometry
ComponentGeometry
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.
Tracker517072005
Fix commita0ceaf275d46 (chromium/src) +70/-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-27 (per vendor advisory)
Fix landed2026-07-29 (public git)
Disclosed2026-09-08
Patch gap41 days — fix public in source before the advisory

Background

Paint layer hit testing
Blink’s process of walking the PaintLayer tree to determine which element is under a point, used both for input routing and for IntersectionObserver v2 occlusion (visibility) checks.
`preserve-3d` / depth ordering
transform-style: preserve-3d establishes a 3D rendering context where children are composited by their Z position rather than strict paint order, so a child with larger translateZ sits in front.
`local_transform_state` vs `container_transform_state`
two accumulated transform states during the hit-test walk; the container state reflects the 3D-preserving ancestor’s coordinate space needed to compare depths correctly, while the local state reflects only the immediate layer.
`IsHitCandidateForDepthOrder`
the helper that decides whether a just-hit descendant layer should replace the current best candidate based on its Z depth in the supplied transform state.

Root Cause Analysis

An earlier CL (crbug/7442380) switched hit-test depth comparisons to use container_transform_state when computing 3D depth order under preserve-3d, but missed one call site in paint_layer.cc that still passed local_transform_state into IsHitCandidateForDepthOrder. Because that call site used the wrong transform state, the Z depth of a hit descendant was evaluated in the wrong coordinate space, so a preserve-3d child positioned in front (e.g. translateZ(5px)) was not correctly recognized as occluding a sibling at z=0. IntersectionObserver v2 relies on this hit test to report isVisible; with the wrong depth ordering it concluded the target was unoccluded and reported isVisible = true even though a nearer element covered it.

The fix gates the corrected call site behind RuntimeEnabledFeatures::HitTestContainerTransformStateForPreserve3dEnabled() and passes container_transform_state when enabled, restoring consistent depth ordering so occlusion is detected correctly.

Key insight
A single hit-test call site still used local_transform_state instead of container_transform_state, evaluating preserve-3d depth in the wrong coordinate space; passing the container state (feature-gated) makes occlusion depth ordering consistent so an occluded target is no longer reported as visible.

Attack Path

  1. Craft a preserve-3d occlusion layout Build a page with a preserve-3d grandparent (#GP), a child (#TC) at translateZ(5px) containing an #occluder, and a sibling #target at the default z=0, so the occluder is geometrically in front of the target (as in the Preserve3DOcclusion regression test).
  2. Observe the target with IntersectionObserver v2 Register an IntersectionObserver with trackVisibility: true on #target.
  3. Trigger the mis-ordered hit test During occlusion evaluation, IsHitCandidateForDepthOrder receives local_transform_state, so the nearer #occluder is not selected as the front-most hit and the target is judged unoccluded.
  4. Observe the false visibility signal The observer entry reports isVisible = true for a target that is actually covered, letting content misrepresent whether it is genuinely on-screen and unobstructed to the user.

Impact Assessment

An attacker-controlled page can make IntersectionObserver v2 report a covered element as visible, defeating the visibility gating that trusted UI (e.g. click-to-consent or ad-visibility) relies on; the advisory classifies this as UI misrepresentation/spoofing in the Geometry component. The effect is confined to the renderer’s visibility reporting and requires only a normally-rendered page with a specific preserve-3d layout; no memory-safety or privilege escalation is demonstrated by the patch.

Changed Functions

FunctionChangeNotes
TEST_F
third_party/blink/renderer/core/intersection_observer/intersection_observer_test.cc
modified

Files Changed

  • third_party/blink/renderer/core/intersection_observer/intersection_observer_test.cc
  • third_party/blink/renderer/core/paint/paint_layer.cc

Audit Directions

  • Inconsistent transform-state selection
    Grep for remaining uses of local_transform_state in hit-test and depth-ordering paths and confirm each is the intended coordinate space, since one missed site reintroduced this bug after the original CL.
  • Visibility oracle correctness
    Audit IntersectionObserver v2 isVisible computation against 3D/preserve-3d, opacity, filter, and clip layouts where an occluder and target live in different stacking/transform contexts.
  • Follow-up CLs that "miss a location"
    When a fix rewrites one call site’s argument, search the whole file (and siblings) for identical call patterns that were not updated in the same change.
From a0ceaf275d46280067394f9f8e61c4a90df61f6f Mon Sep 17 00:00:00 2001
From: Stefan Zager <szager@chromium.org>
Date: Tue, 28 Jul 2026 17:45:46 -0700
Subject: [PATCH] Fix hit testing depth order when testing children

This CL:

https://chromium-review.googlesource.com/c/chromium/src/+/7442380

... missed one location for using container_transform_state instead of
local_transform_state.

Bug: 464173566,517072005
Change-Id: Ia33ad813f3f89b35562f0fcd3c154675c2801163
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8128190
Reviewed-by: Philip Rogers <pdr@chromium.org>
Commit-Queue: Philip Rogers <pdr@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1669908}
---
diff --git a/third_party/blink/renderer/core/paint/paint_layer.cc b/third_party/blink/renderer/core/paint/paint_layer.cc
index 3b4d36c..597eada 100644
--- a/third_party/blink/renderer/core/paint/paint_layer.cc
+++ b/third_party/blink/renderer/core/paint/paint_layer.cc
@@ -1976,7 +1976,11 @@
     }
 
     if (IsHitCandidateForDepthOrder(
-            hit_layer, depth_sort_descendants, z_offset, local_transform_state,
+            hit_layer, depth_sort_descendants, z_offset,
+            RuntimeEnabledFeatures::
+                    HitTestContainerTransformStateForPreserve3dEnabled()
+                ? container_transform_state
+                : local_transform_state,
             result.GetHitTestRequest().IsHitTestVisualOverflow())) {
       result_layer = hit_layer;
       if (!result.GetHitTestRequest().ListBased())
diff --git a/third_party/blink/renderer/core/intersection_observer/intersection_observer_test.cc b/third_party/blink/renderer/core/intersection_observer/intersection_observer_test.cc
index c7f4d5a..339a52f 100644
--- a/third_party/blink/renderer/core/intersection_observer/intersection_observer_test.cc
+++ b/third_party/blink/renderer/core/intersection_observer/intersection_observer_test.cc
@@ -2871,6 +2871,71 @@
   EXPECT_FALSE(observer_delegate->LastEntry()->isVisible());
 }
 
+TEST_F(IntersectionObserverV2Test, Preserve3DOcclusion) {
+  WebView().MainFrameViewWidget()->Resize(gfx::Size(800, 600));
+  SimRequest main_resource("https://example.com/", "text/html");
+  LoadURL("https://example.com/");
+  main_resource.Complete(R"HTML(
+    <style>
+      #GP {
+        transform: translateX(0);
+        transform-style: preserve-3d;
+      }
+      #TC {
+        position: absolute;
+        width: 1px; height: 1px;
+        transform: translateZ(5px);
+        transform-style: preserve-3d;
+      }
+      #L {
+        position: absolute;
+        width: 1px; height: 1px;
+        isolation: isolate;
+      }
+      #occluder, #target {
+        position: absolute;
+        left: 100px; top: 100px;
+        width: 100; height: 100;
+      }
+    </style>
+    <div id="GP">
+      <div id="TC">
+        <div>
+          <div id="L">
+            <div id="occluder"></div>
+          </div>
+        </div>
+      </div>
+      <div id="target"></div>
+    </div>
+  )HTML");
+  Compositor().BeginFrame();
+
+  IntersectionObserverInit* observer_init = IntersectionObserverInit::Create();
+  observer_init->setTrackVisibility(true);
+  observer_init->setDelay(100);
+  DummyExceptionStateForTesting exception_state;
+  TestIntersectionObserverDelegate* observer_delegate =
+      MakeGarbageCollected<TestIntersectionObserverDelegate>(GetDocument());
+  IntersectionObserver* observer = IntersectionObserver::Create(
+      observer_init, *observer_delegate,
+      LocalFrameUkmAggregator::kJavascriptIntersectionObserver,
+      exception_state);
+  ASSERT_FALSE(exception_state.HadException());
+  Element* target = GetDocument().getElementById(AtomicString("target"));
+  ASSERT_TRUE(target);
+  observer->observe(target);
+
+  Compositor().BeginFrame();
+  test::RunPendingTasks();
+  ASSERT_FALSE(Compositor().NeedsBeginFrame());
+  EXPECT_EQ(observer_delegate->CallCount(), 1);
+  EXPECT_EQ(observer_delegate->EntryCount(), 1);
+  EXPECT_TRUE(observer_delegate->LastEntry()->isIntersecting());
+  // #target is occluded by #occluder which is in front of it (z=5 vs z=0).
+  EXPECT_FALSE(observer_delegate->LastEntry()->isVisible());
+}
+
 TEST_F(IntersectionObserverV2Test, TableCellOcclusion) {
   WebView().MainFrameViewWidget()->Resize(gfx::Size(800, 600));
   SimRequest main_resource("https://example.com/", "text/html");
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/intersection_observer/intersection_observer_test.cc b/third_party/blink/renderer/core/intersection_observer/intersection_observer_test.cc
index c7f4d5a..339a52f 100644
--- a/third_party/blink/renderer/core/intersection_observer/intersection_observer_test.cc
+++ b/third_party/blink/renderer/core/intersection_observer/intersection_observer_test.cc
@@ -2871,6 +2871,71 @@
   EXPECT_FALSE(observer_delegate->LastEntry()->isVisible());
 }
 
+TEST_F(IntersectionObserverV2Test, Preserve3DOcclusion) {
+  WebView().MainFrameViewWidget()->Resize(gfx::Size(800, 600));
+  SimRequest main_resource("https://example.com/", "text/html");
+  LoadURL("https://example.com/");
+  main_resource.Complete(R"HTML(
+    <style>
+      #GP {
+        transform: translateX(0);
+        transform-style: preserve-3d;
+      }
+      #TC {
+        position: absolute;
+        width: 1px; height: 1px;
+        transform: translateZ(5px);
+        transform-style: preserve-3d;
+      }
+      #L {
+        position: absolute;
+        width: 1px; height: 1px;
+        isolation: isolate;
+      }
+      #occluder, #target {
+        position: absolute;
+        left: 100px; top: 100px;
+        width: 100; height: 100;
+      }
+    </style>
+    <div id="GP">
+      <div id="TC">
+        <div>
+          <div id="L">
+            <div id="occluder"></div>
+          </div>
+        </div>
+      </div>
+      <div id="target"></div>
+    </div>
+  )HTML");
+  Compositor().BeginFrame();
+
+  IntersectionObserverInit* observer_init = IntersectionObserverInit::Create();
+  observer_init->setTrackVisibility(true);
+  observer_init->setDelay(100);
+  DummyExceptionStateForTesting exception_state;
+  TestIntersectionObserverDelegate* observer_delegate =
+      MakeGarbageCollected<TestIntersectionObserverDelegate>(GetDocument());
+  IntersectionObserver* observer = IntersectionObserver::Create(
+      observer_init, *observer_delegate,
+      LocalFrameUkmAggregator::kJavascriptIntersectionObserver,
+      exception_state);
+  ASSERT_FALSE(exception_state.HadException());
+  Element* target = GetDocument().getElementById(AtomicString("target"));
+  ASSERT_TRUE(target);
+  observer->observe(target);
+
+  Compositor().BeginFrame();
+  test::RunPendingTasks();
+  ASSERT_FALSE(Compositor().NeedsBeginFrame());
+  EXPECT_EQ(observer_delegate->CallCount(), 1);
+  EXPECT_EQ(observer_delegate->EntryCount(), 1);
+  EXPECT_TRUE(observer_delegate->LastEntry()->isIntersecting());
+  // #target is occluded by #occluder which is in front of it (z=5 vs z=0).
+  EXPECT_FALSE(observer_delegate->LastEntry()->isVisible());
+}
+
 TEST_F(IntersectionObserverV2Test, TableCellOcclusion) {
   WebView().MainFrameViewWidget()->Resize(gfx::Size(800, 600));
   SimRequest main_resource("https://example.com/", "text/html");
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.