CVE-2026-5875
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fthird_party/blink/renderer/core/layout/hit_testing_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/layout/hit_testing_test.ccthird_party/blink/renderer/core/style/filter_operation.cc
Patch
From cd1b8be760040aeaa4ad11783bdcf550947920bd Mon Sep 17 00:00:00 2001 From: Stefan Zager <[email protected]> Date: Fri, 20 Feb 2026 13:22:41 -0800 Subject: [PATCH] Fix equality operator for blink::ReferenceFilterOp Filter operations are created during style recalc, but some of them (specifically, box-reflect and reference filters) need layout information to compute their visual overflow extent. For box-reflect, this is handled by always creating the filter operation ad hoc when requested[1], presumably after pre-paint has generated the necessary geometry information. For reference filters, this is handled by annotating the style-generated ReferenceFilterOp with its fully resolved compositor filter during pre-paint[2]. It can happen that a ReferenceFilterOp is re-created during style recalc, even when the operation has not changed. When this happens, the style diff passed to StyleDidChange() won't indicate that filters have changed, because the equality comparison for ReferenceFilterOp doesn't check whether the resolved compositor filters match. If nothing else in the style diff causes the element to be marked for paint property update, it will skip the building of compositor filters during pre-paint, and the ReferenceFilterOp will not get annotated with its resolved compositor filter. This doesn't break rendering, because the EffectPaintPropertyNode will continue to point to the previously-computed compositor filter. However, it *does* break hit testing of visual overflow, which relies on the style-created FilterReferenceOp being annotated with the compositor filter[3]. This CL changes the equality comparison for ReferenceFilterOp so that it considers the annotated compositor filter. A pointer comparison is sufficient; for a newly-created ReferenceFilterOp it will always be `nullptr` and should force paint property update. [1]https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/paint/paint_layer.cc;drc=d209eefee6037bd0905e43f88570fda8edab89a1;l=2397 [2]https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/paint/filter_effect_builder.cc;drc=3bbce24997c008ef45d4253542ec7f0a5a97e571;l=402 [3]https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/style/filter_operation.cc;drc=d209eefee6037bd0905e43f88570fda8edab89a1;l=46 Bug: 430198264 Change-Id: I6f3215a603a6ed831431635c37d865216d0bb333 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7597074 Commit-Queue: Stefan Zager <[email protected]> Reviewed-by: Philip Rogers <[email protected]> Cr-Commit-Position: refs/heads/main@{#1588040} --- 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 1699283..36e44fb 100644 --- a/third_party/blink/renderer/core/layout/hit_testing_test.cc +++ b/third_party/blink/renderer/core/layout/hit_testing_test.cc @@ -274,4 +274,40 @@ HitTest(PhysicalOffset(5, 5))); } +TEST_F(HitTestingTest, ReferenceFilter) { + SetBodyInnerHTML(R"HTML( +<style> + #target { + position:absolute; + top:100px; + left:100px; + width:100px; + height:100px; + background-color:blue; + filter:url(#displace); + } +</style> +<div id="target"></div> +<svg width="100" height="100" viewBox="0 0 100 100"> + <filter id="displace"> + <feFlood /> + <feDisplacementMap + scale="250" + xChannelSelector="R" + yChannelSelector="G" /> + </filter> +</svg> + )HTML"); + + Element* target = GetElementById("target"); + LayoutBox* box = To<LayoutBox>(target->GetLayoutObject()); + EXPECT_EQ(box->VisualOverflowRectIncludingFilters(), + PhysicalRect(-10, -10, 120, 120)); + + target->SetInlineStyleProperty(CSSPropertyID::kOpacity, "1"); + UpdateAllLifecyclePhasesForTest(); + EXPECT_EQ(box->VisualOverflowRectIncludingFilters(), + PhysicalRect(-10, -10, 120, 120)); +} + } // namespace blink diff --git a/third_party/blink/renderer/core/style/filter_operation.cc b/third_party/blink/renderer/core/style/filter_operation.cc index 3bfbf65c..b8a312e 100644 --- a/third_party/blink/renderer/core/style/filter_operation.cc +++ b/third_party/blink/renderer/core/style/filter_operation.cc @@ -71,7 +71,8 @@ bool ReferenceFilterOperation::IsEqualAssumingSameType( const FilterOperation& o) const { const auto& other = To<ReferenceFilterOperation>(o); - return url_ == other.url_ && resource_ == other.resource_; + return url_ == other.url_ && resource_ == other.resource_ && + filter_.Get() == other.filter_.Get(); } gfx::RectF BlurFilterOperation::MapRect(const gfx::RectF& rect) const {
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 1699283..36e44fb 100644
--- a/third_party/blink/renderer/core/layout/hit_testing_test.cc
+++ b/third_party/blink/renderer/core/layout/hit_testing_test.cc
@@ -274,4 +274,40 @@
HitTest(PhysicalOffset(5, 5)));
}
+TEST_F(HitTestingTest, ReferenceFilter) {
+ SetBodyInnerHTML(R"HTML(
+<style>
+ #target {
+ position:absolute;
+ top:100px;
+ left:100px;
+ width:100px;
+ height:100px;
+ background-color:blue;
+ filter:url(#displace);
+ }
+</style>
+<div id="target"></div>
+<svg width="100" height="100" viewBox="0 0 100 100">
+ <filter id="displace">
+ <feFlood />
+ <feDisplacementMap
+ scale="250"
+ xChannelSelector="R"
+ yChannelSelector="G" />
+ </filter>
+</svg>
+ )HTML");
+
+ Element* target = GetElementById("target");
+ LayoutBox* box = To<LayoutBox>(target->GetLayoutObject());
+ EXPECT_EQ(box->VisualOverflowRectIncludingFilters(),
+ PhysicalRect(-10, -10, 120, 120));
+
+ target->SetInlineStyleProperty(CSSPropertyID::kOpacity, "1");
+ UpdateAllLifecyclePhasesForTest();
+ EXPECT_EQ(box->VisualOverflowRectIncludingFilters(),
+ PhysicalRect(-10, -10, 120, 120));
+}
+
} // namespace blink
Original Bug Report
IntersectionObserver's visibility check can be bypassed through SVG filters
VULNERABILITY DETAILS
IntersectionObserver has the trackVisibility option to make sure that an element is visible to the user. This is used to prevent clickjacking, since a parent frame can no longer cover the element with its own graphics in order to trick the user into performing an unwanted action.
However, through some trickery, an undetectable overlay can still be created by using SVG filters. For example, it is possible to use the feDisplacementMap filter to put graphics in an area up to 10% beyond the element bounds.
Doing this would still be detected by the IntersectionObserver, but this can be bypassed by quickly changing the opacity of the element (doing so probably resets its bounds somehow?).
The result is the ability to controllably cover up an iframe with any kinds of graphics without setting off its IntersectionObserver.
VERSION
Chrome Version: Stable, 140.0.7259.2 Dev
Operating System: Windows, Android, macOS
REPRODUCTION CASE
- Download the included files.
- Open
invisible-bypass.html. - Observe how the frame on the page gets covered with a fake button, but its background is still green (it’d turn red if not visible).
- Click the fake button.
Note: To make testing the repro easier, the target frame is same-origin. This vulnerability works cross-origin too, as demonstrated in my demo video.
CREDIT INFORMATION
Externally reported security bugs may appear in Chrome release notes. If this bug is included, how would you like to be credited?
Reporter credit: Lyra Rebane (rebane2001)