CVE-2026-87458
Overview
Background
- Paint layer hit testing
- Blink’s process of walking the
PaintLayertree to determine which element is under a point, used both for input routing and forIntersectionObserverv2 occlusion (visibility) checks. - `preserve-3d` / depth ordering
transform-style: preserve-3destablishes a 3D rendering context where children are composited by their Z position rather than strict paint order, so a child with largertranslateZsits 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.
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
- Craft a preserve-3d occlusion layout
Build a page with a
preserve-3dgrandparent (#GP), a child (#TC) attranslateZ(5px)containing an#occluder, and a sibling#targetat the defaultz=0, so the occluder is geometrically in front of the target (as in thePreserve3DOcclusionregression test). - Observe the target with IntersectionObserver v2
Register an
IntersectionObserverwithtrackVisibility: trueon#target. - Trigger the mis-ordered hit test
During occlusion evaluation,
IsHitCandidateForDepthOrderreceiveslocal_transform_state, so the nearer#occluderis not selected as the front-most hit and the target is judged unoccluded. - Observe the false visibility signal
The observer entry reports
isVisible = truefor a target that is actually covered, letting content misrepresent whether it is genuinely on-screen and unobstructed to the user.
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
TEST_Fthird_party/blink/renderer/core/intersection_observer/intersection_observer_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/intersection_observer/intersection_observer_test.ccthird_party/blink/renderer/core/paint/paint_layer.cc
Audit Directions
- Inconsistent transform-state selectionGrep for remaining uses of
local_transform_statein 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 correctnessAudit
IntersectionObserverv2isVisiblecomputation 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.
Patch
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");
Regression Test / PoC
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");