CVE-2026-5875
Overview
Background
- `ReferenceFilterOperation`
- the style-level representation of a CSS
filter: url(#id)reference filter, created during style recalc and later annotated during pre-paint with its fully resolved compositor filter infilter_. - `IsEqualAssumingSameType`
- the per-
FilterOperationequality hook used when diffing old vs. new computed style to decide whether filters changed and whether a paint-property update is needed. - Paint-property update / pre-paint annotation
- the pre-paint phase that builds compositor filters and writes the resolved filter back onto the style-created
ReferenceFilterOperation; it is skipped if the style diff doesn’t flag the element for update. - Visual-overflow hit testing
- hit testing against an element’s filter-expanded visual overflow, which relies on the style-created
ReferenceFilterOperationcarrying the resolved compositor filter.
Root Cause Analysis
During style recalc a ReferenceFilterOperation can be re-created even when the filter is logically unchanged, producing a new object whose filter_ annotation is nullptr (not yet resolved by pre-paint). The pre-patch ReferenceFilterOperation::IsEqualAssumingSameType compared only url_ and resource_, so it reported the re-created operation as equal to the previous one; the style diff then did not flag filters as changed, and if nothing else marked the element for a paint-property update, pre-paint skipped building compositor filters and never annotated the new operation. Rendering still looked correct because the EffectPaintPropertyNode kept pointing at the previously computed compositor filter, but the style-created operation was left with a nullptr filter_, which broke hit testing of the filter-expanded visual overflow.
The fix tightens the equality check by also comparing filter_.Get() == other.filter_.Get(): a freshly re-created operation (with filter_ == nullptr) no longer compares equal to the annotated old one, which forces the paint-property update and re-annotation. A pointer comparison suffices because the newly created operation always starts with filter_ == nullptr.
filter_.Get() to the comparison makes the stale/unannotated operation compare unequal and forces the paint-property update that re-resolves it.Attack Path
- Apply a reference filter
Page CSS sets
filter: url(#displace)on a positioned element whose visual overflow is expanded by the SVG filter (the shipped test usesfeDisplacementMap scale="250", overflow rect-10,-10,120,120). - Force a benign style recalc
Author script mutates an unrelated property (the test sets
opacity: 1) that triggers style recalc and re-creation of theReferenceFilterOperationwithout otherwise marking the element for paint-property update. - Equality masks the change
The pre-patch
IsEqualAssumingSameTypereturns equal (onlyurl_/resource_match), so filters are not flagged as changed and pre-paint skips re-annotating the new operation, leavingfilter_ == nullptr. - Hit testing diverges Hit testing of the filter-expanded visual overflow now uses the unannotated operation and yields incorrect results, the input-targeting/policy inconsistency the advisory classifies as a policy bypass.
Impact Assessment
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
Audit Directions
- Incomplete equality operatorsAudit
FilterOperation/style-object equality hooks for fields (resolved/cached annotations, pointers populated in a later phase) that affect behavior but are excluded from the comparison that gates invalidation. - Phase-populated state vs. style diffLook for data written during pre-paint/compositing that the style-recalc equality check does not account for, where a re-created-but-equal object silently loses that annotation.
- Render-correct but hit-test-wrong pathsReview features where the compositor path and the hit-test path read from different objects (
EffectPaintPropertyNodevs. style-created operation); divergence there can produce input-targeting mismatches without visible rendering bugs.
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)