CVE-2026-13988
Overview
Files Changed
third_party/blink/renderer/core/layout/layout_replaced.ccthird_party/blink/renderer/core/paint/box_fragment_painter.ccthird_party/blink/renderer/core/paint/paint_layer.ccthird_party/blink/web_tests/external/wpt/intersection-observer/v2/border-radius-shadow-occlusion-replaced.htmlthird_party/blink/web_tests/external/wpt/intersection-observer/v2/border-radius-shadow-occlusion.html
Patch
From 820d6d4c0c5b969ae7559ac69c404380502507d3 Mon Sep 17 00:00:00 2001
From: Philip Rogers <pdr@chromium.org>
Date: Tue, 19 May 2026 09:29:14 -0700
Subject: [PATCH] Fix border-radius hit testing for visual overflow
When hit testing visual overflow, we cannot stop hit testing once a hit
is outside the border because effects like box-shadow can still be
present.
Fixed: 514040614
Change-Id: I4c0daacbb659966a4088277a1ef9f8ab5e75f5fb
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7858611
Commit-Queue: Philip Rogers <pdr@chromium.org>
Reviewed-by: Stefan Zager <szager@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1632916}
---
diff --git a/third_party/blink/renderer/core/layout/layout_replaced.cc b/third_party/blink/renderer/core/layout/layout_replaced.cc
index b94e2aec..a3de7f17 100644
--- a/third_party/blink/renderer/core/layout/layout_replaced.cc
+++ b/third_party/blink/renderer/core/layout/layout_replaced.cc
@@ -174,7 +174,9 @@
if (StyleRef().HasBorderRadius() &&
HitTestClippedOutByBorder(hit_test_location, accumulated_offset)) {
- return false;
+ if (!result.GetHitTestRequest().IsHitTestVisualOverflow()) {
+ return false;
+ }
}
// Now hit test ourselves.
diff --git a/third_party/blink/renderer/core/paint/box_fragment_painter.cc b/third_party/blink/renderer/core/paint/box_fragment_painter.cc
index 517dfad7..f8e3d31 100644
--- a/third_party/blink/renderer/core/paint/box_fragment_painter.cc
+++ b/third_party/blink/renderer/core/paint/box_fragment_painter.cc
@@ -2419,7 +2419,9 @@
}
} else if (style.HasBorderRadius() &&
HitTestClippedOutByBorder(hit_test.location, physical_offset)) {
- return false;
+ if (!hit_test.result->GetHitTestRequest().IsHitTestVisualOverflow()) {
+ return false;
+ }
}
bool pointer_events_bounding_box = false;
diff --git a/third_party/blink/renderer/core/paint/paint_layer.cc b/third_party/blink/renderer/core/paint/paint_layer.cc
index d867675..7e05284 100644
--- a/third_party/blink/renderer/core/paint/paint_layer.cc
+++ b/third_party/blink/renderer/core/paint/paint_layer.cc
@@ -1704,7 +1704,9 @@
bounds.HasRadius() &&
HitTestClippedOutByBorderRadius(transform_container, container_fragment,
hit_test_location, bounds)) {
- continue;
+ if (!result.GetHitTestRequest().IsHitTestVisualOverflow()) {
+ continue;
+ }
}
inside_clip_rect = true;
@@ -1763,7 +1765,9 @@
HitTestClippedOutByBorderRadius(transform_container, container_fragment,
recursion_data.location,
fragment.background_rect)) {
- continue;
+ if (!result.GetHitTestRequest().IsHitTestVisualOverflow()) {
+ continue;
+ }
}
PaintLayer* hit_layer = HitTestLayerByApplyingTransform(
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/v2/border-radius-shadow-occlusion-replaced.html b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/border-radius-shadow-occlusion-replaced.html
new file mode 100644
index 0000000..e9432b2b
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/border-radius-shadow-occlusion-replaced.html
@@ -0,0 +1,39 @@
+<!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>
+ body {
+ margin: 0;
+ }
+ #target {
+ position: absolute;
+ left: 100px;
+ top: 100px;
+ width: 100px;
+ height: 100px;
+ background: red;
+ }
+ #occluder {
+ position: absolute;
+ left: -20px;
+ top: 100px;
+ width: 100px;
+ height: 100px;
+ border-radius: 1px;
+ box-shadow: 120px 0px 0px black;
+ background: blue;
+ }
+</style>
+<div id="target"></div>
+<canvas id="occluder"></canvas>
+
+<script>
+ setup({ single_test: true });
+ const target = document.getElementById("target");
+ new IntersectionObserver(entries => {
+ // The target should be occluded by the box-shadow of the occluder.
+ assert_false(entries[0].isVisible);
+ done();
+ }, {trackVisibility: true, delay: 100}).observe(target);
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/v2/border-radius-shadow-occlusion.html b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/border-radius-shadow-occlusion.html
new file mode 100644
index 0000000..659a270d
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/border-radius-shadow-occlusion.html
@@ -0,0 +1,39 @@
+<!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>
+ body {
+ margin: 0;
+ }
+ #target {
+ position: absolute;
+ left: 100px;
+ top: 100px;
+ width: 100px;
+ height: 100px;
+ background: red;
+ }
+ #occluder {
+ position: absolute;
+ left: -20px;
+ top: 100px;
+ width: 100px;
+ height: 100px;
+ border-radius: 1px;
+ box-shadow: 120px 0px 0px black;
+ background: blue;
+ }
+</style>
+<div id="target"></div>
+<div id="occluder"></div>
+
+<script>
+ setup({ single_test: true });
+ const target = document.getElementById("target");
+ new IntersectionObserver(entries => {
+ // The target should be occluded by the box-shadow of the occluder.
+ assert_false(entries[0].isVisible);
+ done();
+ }, {trackVisibility: true, delay: 100}).observe(target);
+</script>
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/v2/border-radius-shadow-occlusion-replaced.html b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/border-radius-shadow-occlusion-replaced.html
new file mode 100644
index 0000000..e9432b2b
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/border-radius-shadow-occlusion-replaced.html
@@ -0,0 +1,39 @@
+<!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>
+ body {
+ margin: 0;
+ }
+ #target {
+ position: absolute;
+ left: 100px;
+ top: 100px;
+ width: 100px;
+ height: 100px;
+ background: red;
+ }
+ #occluder {
+ position: absolute;
+ left: -20px;
+ top: 100px;
+ width: 100px;
+ height: 100px;
+ border-radius: 1px;
+ box-shadow: 120px 0px 0px black;
+ background: blue;
+ }
+</style>
+<div id="target"></div>
+<canvas id="occluder"></canvas>
+
+<script>
+ setup({ single_test: true });
+ const target = document.getElementById("target");
+ new IntersectionObserver(entries => {
+ // The target should be occluded by the box-shadow of the occluder.
+ assert_false(entries[0].isVisible);
+ done();
+ }, {trackVisibility: true, delay: 100}).observe(target);
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/v2/border-radius-shadow-occlusion.html b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/border-radius-shadow-occlusion.html
new file mode 100644
index 0000000..659a270d
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/border-radius-shadow-occlusion.html
@@ -0,0 +1,39 @@
+<!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>
+ body {
+ margin: 0;
+ }
+ #target {
+ position: absolute;
+ left: 100px;
+ top: 100px;
+ width: 100px;
+ height: 100px;
+ background: red;
+ }
+ #occluder {
+ position: absolute;
+ left: -20px;
+ top: 100px;
+ width: 100px;
+ height: 100px;
+ border-radius: 1px;
+ box-shadow: 120px 0px 0px black;
+ background: blue;
+ }
+</style>
+<div id="target"></div>
+<div id="occluder"></div>
+
+<script>
+ setup({ single_test: true });
+ const target = document.getElementById("target");
+ new IntersectionObserver(entries => {
+ // The target should be occluded by the box-shadow of the occluder.
+ assert_false(entries[0].isVisible);
+ done();
+ }, {trackVisibility: true, delay: 100}).observe(target);
+</script>
Original Bug Report
Bypass of PEPC and IntersectionObserver V2 occlusion detection via border-radius
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 hit-testing logic error in Blink allows elements with ‘border-radius’ to incorrectly return early during hit tests, bypassing visual overflow checks. This flaw can be exploited to hide sensitive elements like Permission elements behind opaque box-shadows without being detected as occluded.
Affected files:
third_party/blink/renderer/core/paint/box_fragment_painter.ccthird_party/blink/renderer/core/layout/layout_replaced.cc
Estimated timestamp from git blame: 2018-09-12
Summary
A potential order-of-operations vulnerability exists in Blink’s hit-testing implementation within BoxFragmentPainter::NodeAtPoint and LayoutReplaced::NodeAtPoint. When a hit test is performed with the kHitTestVisualOverflow flag (primarily used by IntersectionObserver V2 and Page Embedded Permission Control), the presence of a border-radius on an occluding element can cause the hit test to return false prematurely. This allows visual overflow, such as box-shadow, to occlude a target without being detected by the security mechanisms designed to prevent clickjacking.
Root Cause Analysis
In third_party/blink/renderer/core/paint/box_fragment_painter.cc, the NodeAtPoint function contains the following logic:
// Check border-shape and border-radius clipping.
if (style.HasBorderShape()) {
// ... early return logic
} else if (style.HasBorderRadius() &&
HitTestClippedOutByBorder(hit_test.location, physical_offset)) {
return false; // Vulnerable early return
}
// ... later, visual overflow is evaluated
if (hit_test.result->GetHitTestRequest().IsHitTestVisualOverflow()) {
bounds_rect = InkOverflowIncludingFilters();
// ...
}
When HitTestForOcclusion is called, it sets the kHitTestVisualOverflow flag to ensure that non-layout visual elements like shadows are considered occluders. However, if the occluding element has a border-radius, HitTestClippedOutByBorder is called. If the hit-test point lies outside the rounded border box (even if it lies within the shadow), the function returns false at line 2366. This bypasses the logic at line 2403 that would have expanded the hit area to include the box-shadow via InkOverflowIncludingFilters().
Potential Attack Scenario
An attacker could potentially trigger this vulnerability to bypass the security checks of the <permission> element (PEPC) or any feature relying on IntersectionObserver visibility tracking:
- Place a sensitive element (e.g.,
<permission type="geolocation">) on a page. - Create a sibling ‘occluder’ element positioned slightly offset from the target.
- Apply
border-radius: 1pxand a large, opaquebox-shadowto the occluder such that the shadow completely covers the target element. - Blink’s occlusion detection (via
HitTestForOcclusion) will traverse to the occluder but returnfalsedue to the border-radius early-return bug. - The sensitive element will be marked as
isVisible: truedespite being visually hidden by the shadow. - A user clicking the opaque shadow will unknowingly interact with the hidden permission element, potentially granting the site sensitive permissions.
Suggested Fix
The early return checks for border-radius and border-shape in NodeAtPoint should be bypassed if the kHitTestVisualOverflow flag is present in the HitTestRequest. This ensures that the hit test proceeds to evaluate the full ink overflow rect, including shadows and filters.
Note: These are potential steps and analysis based on code review; a functional proof-of-concept has not yet been executed.
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.