CVE-2026-87484
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fthird_party/blink/renderer/core/layout/hit_testing_test.cc |
modified | |
ifthird_party/blink/renderer/core/paint/paint_layer.cc |
modified |
Files Changed
third_party/blink/renderer/core/layout/hit_testing_test.ccthird_party/blink/renderer/core/paint/paint_layer.ccthird_party/blink/web_tests/external/wpt/intersection-observer/v2/3d-transform-flattened-preserve-3d-occlusion.html
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/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>
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.