CVE-2026-87484
Overview
Background
- Occlusion hit testing
- the internal hit test Blink runs (via
HitTestForOcclusion) to determine whether another element paints on top of a target, used byIntersectionObserverv2 (trackVisibility) to report whether an element is actually visible. - `preserve-3d` / 3D flattening
transform-style: preserve-3dkeeps descendants in a shared 3D space, but when an ancestor stacking context is flat, a child’stranslateZoffset 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’schild_z_offsetis 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.
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
- Construct a flattened preserve-3d occluder
An attacker page places a
#targetelement and, later in DOM order, an#occluderwithtransform: translateZ(-10px); transform-style: preserve-3dinside a flat parent stacking context, so the occluder visually paints on top of the target. - Trigger occlusion hit testing
The page observes
#targetwithIntersectionObserverusing{trackVisibility: true}, which invokesHitTestForOcclusionand reaches the vulnerable comparison inpaint_layer.cc. - Mis-classify the occluder
Because
child_z_offset(-10) is< 0, the pre-patch code hitsreturn falseand drops the occluder, so the hit test reports no occlusion and the observer delivers an entry withisVisible == trueeven though the target is fully covered. - 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
trackVisibilityis 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
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
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/paint/paint_layer.cc |
modified | |
TEST_Fthird_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.htmlthird_party/blink/renderer/core/paint/paint_layer.ccthird_party/blink/renderer/core/layout/hit_testing_test.cc
Audit Directions
- Hardcoded reference-frame assumptionsSearch 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 casesReview 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 APIsAudit other consumers of
HitTestForOcclusionandIntersectionObservertrackVisibility/isVisiblefor paths where an incorrect occlusion result yields a false visibility signal usable for UI misrepresentation.
Patch
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/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>
Regression Test / PoC
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>
Original Bug Report
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.ccthird_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>
- Visual Presentation: The parent of
TandXis flat. Thus,X’s 3D transformtranslateZ(-10px)is flattened. In the visual tree,Xpaints on top ofTdue to later DOM order and fully occludesT. - Visibility Check:
ComputeVisibilityInfo(T)evaluates whetherThas distorting visual effects. SinceThas no 3D ancestors, theIsCoplanarWith()guard evaluates totrueand the function proceeds toHitTestForOcclusion. - Occlusion Hit-testing: The hit-test reverse-iterates through candidate layers. Sibling
Xis processed first. - Early Reject:
IsHitCandidateForDepthOrderevaluatesX’s un-flattened transform, resulting inchild_z_offset = -10 < 0and returnsfalse, discardingXas a potential occluding element. - False Positive Visibility: The loop falls back to
T, which is successfully hit. The visibility tracker reportsTas fully visible (isVisible = true). - Deceptive Interaction: Because
Xhaspointer-events: none, any click on the decoyXgoes directly to the underlyingT. 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.