Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Paint
DescriptionInappropriate implementation in Paint
ComponentPaint
Bug ClassLogic Error
Tracker513792140
Fix commitf2b2d0db48bb (chromium/src) +57/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

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

Files Changed

  • third_party/blink/renderer/core/paint/paint_layer.cc
  • third_party/blink/web_tests/external/wpt/intersection-observer/v2/3d-transform-overflow-control-occlusion.html
From f2b2d0db48bb2da107822fd01e14513d224bb227 Mon Sep 17 00:00:00 2001
From: Philip Rogers <[email protected]>
Date: Mon, 18 May 2026 10:35:34 -0700
Subject: [PATCH] Fix occlusion by not overwriting z_offset for overflow controls

https://crrev.com/1489781 introduced an intersection observer v2
regression due to overwriting z_offset when calculating overflow
control hit testing. This patch fixes this by using
`IsHitCandidateForDepthOrder` instead of an unconditional write.

Fixed: 513792140
Change-Id: Ie970f24104dfa8e14e6d4bd6c67880048c9a0209
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7854801
Commit-Queue: Philip Rogers <[email protected]>
Reviewed-by: Stefan Zager <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1632263}
---

diff --git a/third_party/blink/renderer/core/paint/paint_layer.cc b/third_party/blink/renderer/core/paint/paint_layer.cc
index e6f42aa3..3615b091 100644
--- a/third_party/blink/renderer/core/paint/paint_layer.cc
+++ b/third_party/blink/renderer/core/paint/paint_layer.cc
@@ -1507,10 +1507,12 @@
             recursion_data.location) &&
         GetLayoutBox()->HitTestOverflowControl(
             result, recursion_data.location, layer_fragments[0].layer_offset)) {
-      if (z_offset && local_transform_state) {
-        *z_offset = ComputeZOffset(*local_transform_state);
+      if (!z_offset || !local_transform_state ||
+          IsHitCandidateForDepthOrder(
+              this, false, z_offset, local_transform_state,
+              result.GetHitTestRequest().IsHitTestVisualOverflow())) {
+        return this;
       }
-      return this;
     }
   }
 
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/v2/3d-transform-overflow-control-occlusion.html b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/3d-transform-overflow-control-occlusion.html
new file mode 100644
index 0000000..fc1163c
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/3d-transform-overflow-control-occlusion.html
@@ -0,0 +1,52 @@
+<!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>
+  #scene {
+    transform: translateX(0px);
+    transform-style: preserve-3d;
+  }
+  #target {
+    position: absolute;
+    left: 100px;
+    top: 100px;
+    width: 200px;
+    height: 40px;
+    background: red;
+  }
+  #scroller {
+    position: absolute;
+    left: 50px;
+    top: 10px;
+    width: 155px;
+    height: 115px;
+    overflow: auto;
+    resize: both;
+    transform: translateZ(-1px);
+    background: blue;
+  }
+  #overlay {
+    position: absolute;
+    left: 100px;
+    top: 100px;
+    width: 200px;
+    height: 40px;
+    transform: translateZ(1px);
+    background: purple;
+  }
+</style>
+<div id="scene">
+  <div id="target"></div>
+  <div id="scroller"></div>
+  <div id="overlay"></div>
+</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/web_tests/external/wpt/intersection-observer/v2/3d-transform-overflow-control-occlusion.html b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/3d-transform-overflow-control-occlusion.html
new file mode 100644
index 0000000..fc1163c
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/3d-transform-overflow-control-occlusion.html
@@ -0,0 +1,52 @@
+<!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>
+  #scene {
+    transform: translateX(0px);
+    transform-style: preserve-3d;
+  }
+  #target {
+    position: absolute;
+    left: 100px;
+    top: 100px;
+    width: 200px;
+    height: 40px;
+    background: red;
+  }
+  #scroller {
+    position: absolute;
+    left: 50px;
+    top: 10px;
+    width: 155px;
+    height: 115px;
+    overflow: auto;
+    resize: both;
+    transform: translateZ(-1px);
+    background: blue;
+  }
+  #overlay {
+    position: absolute;
+    left: 100px;
+    top: 100px;
+    width: 200px;
+    height: 40px;
+    transform: translateZ(1px);
+    background: purple;
+  }
+</style>
+<div id="scene">
+  <div id="target"></div>
+  <div id="scroller"></div>
+  <div id="overlay"></div>
+</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 [email protected]

Depth-sort clobber in PaintLayer hit-testing enables occlusion bypass

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 error in Blink’s hit-testing for 3D contexts allows overflow controls to unconditionally overwrite the shared z-offset during depth sorting. This enables visually occluded elements to be incorrectly reported as the frontmost element by security mechanisms like IntersectionObserver v2. An attacker can leverage this to bypass clickjacking protections in Page Embedded Permission Controls (PEPC).

Affected files:

  • third_party/blink/renderer/core/paint/paint_layer.cc
  • third_party/blink/renderer/core/intersection_observer/intersection_geometry.cc
  • third_party/blink/renderer/core/html/html_capability_element_base.cc

Estimated timestamp from git blame: 2025-07-21

Summary

A vulnerability in PaintLayer::HitTestLayer for preserve-3d contexts allows an attacker to bypass occlusion detection. This is caused by an unconditional overwrite of the shared z-offset pointer during the hit-testing of overflow controls (such as scrollbars or resizers). As a result, elements that are visually occluded can be incorrectly identified as the frontmost element by IntersectionObserver v2 and the Page Embedded Permission Control (PEPC), potentially facilitating clickjacking attacks.

Root Cause Analysis

In PaintLayer::HitTestLayer, depth-sorting in 3D contexts is managed using a shared z_offset pointer, which acts as a running maximum. Each hit candidate is expected to compute its Z-offset and only update the shared *z_offset if its own depth is greater (closer to the viewer) than the current value.

While foreground and background hit-test paths generally follow this protocol, the overflow-controls path in third_party/blink/renderer/core/paint/paint_layer.cc does not:

if (scrollable_area_ &&
    layer_fragments[0].background_rect.Intersects(recursion_data.location) &&
    GetLayoutBox()->HitTestOverflowControl(result, recursion_data.location, layer_fragments[0].layer_offset)) {
  if (z_offset && local_transform_state) {
    *z_offset = ComputeZOffset(*local_transform_state); // Potential Unconditional Write
  }
  return this;
}

There are two primary defects here:

  1. Unconditional Clobbering: The *z_offset is overwritten even if the new z-offset is lower (further away) than the previously recorded maximum. This destroys the running maximum for the 3D rendering context, allowing visually hidden elements to incorrectly ‘win’ the depth sort.
  2. Inconsistent Coordinate Basis: The path uses local_transform_state instead of container_transform_state. In modern Blink versions where HitTestContainerTransformStateForPreserve3d is enabled, this can lead to precision issues or incorrect Z-values when comparing against siblings.

Potential Impact

An attacker could position a scrollable element with a negative Z-transform such that its overflow controls overlap the center of a target element (e.g., a <permission> element). This would clobber the z_offset of any frontmost occluder. Consequently, the target element (which is visually behind the occluder) will win the hit-test because its Z-offset (e.g., 0) is greater than the clobbered value (e.g., -1).

This bypasses security primitives that rely on hit-testing for occlusion detection:

  • PEPC Anti-Clickjacking: The occlusion check may fail to detect an opaque overlay, delivering a click to the underlying permission element.
  • IntersectionObserver v2: The isVisible property may incorrectly report occluded elements as visible.

Suggested Reproductions Steps (Potential)

  1. Create a transform-style: preserve-3d container.
  2. Add an opaque Occluder (X) at Z=1.
  3. Add a Target element (T) at Z=0 (e.g., <permission type="camera">).
  4. Add a Clobberer (A) at Z=-1 with resize: both and overflow: scroll, positioned so its resizer overlaps T.
  5. Ensure the DOM/Paint order is T, A, then X.
  6. A hit-test at T’s location will first find X (Z=1), then A will clobber the shared offset to -1, and finally T (Z=0) will win the depth sort against -1.

Modify the overflow controls path in PaintLayer::HitTestLayer to use the IsHitCandidateForDepthOrder logic or a manual comparison to ensure *z_offset is only updated if the new Z-offset is strictly greater than the current value. Additionally, use container_transform_state when appropriate to maintain coordinate consistency.

Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a


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