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 <szager@chromium.org>
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 <szager@chromium.org>
Reviewed-by: Philip Rogers <pdr@chromium.org>
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)