CVE-2026-79283
Overview
Background
- IntersectionObserver v2 (visibility)
- With
trackVisibilityset, the observer reports not just whether a target intersects the viewport but whether it is actually visible, i.e. not occluded by other painted content. - Occlusion hit test
- Blink determines occlusion by hit testing with
HitTestRequest::IsHitTestVisualOverflow(), walking painted content (including ink outside layout bounds, such asdrop-shadow) that could cover the target. - Table parts
- Table structural fragments (
IsTableRow,IsTableSection, and otherIsTablePart) that are not normally themselves hit targets but can still emit ink (e.g. viafilter: drop-shadow). - `IsVisibleToPaint` / `IsVisibleToHitTest`
- Helpers deciding whether a fragment paints and whether its style/request make it a hit target; occlusion detection depends on both.
Root Cause Analysis
The free function IsVisibleToHitTest(const PhysicalFragment&, const HitTestRequest&) in box_fragment_painter.cc previously returned IsVisibleToPaint(fragment, style) && IsVisibleToHitTest(style, request), so a fragment that was not visible-to-paint was never treated as a hit target even during occlusion testing. Separately, the self-hit-testing logic (a method whose name is not in the hunk header) unconditionally set hit_test_self = false for IsTableRow()/IsTableSection(), since table rows and sections are never regular hit targets. The consequence is that a table part that is not itself visible (e.g. visibility: hidden) but casts ink over a target via drop-shadow was skipped during occlusion hit testing, so IntersectionObserver v2 reported the target as visible when it was actually occluded.
The fix relaxes both paths specifically for occlusion: IsVisibleToHitTest now returns true for request.IsHitTestVisualOverflow() && fragment.IsTablePart() even when not visible-to-paint, and the self-hit-test sets hit_test_self = hit_test.result->GetHitTestRequest().IsHitTestVisualOverflow() for rows/sections. This lets table parts contribute to occlusion so visibility is reported correctly.
drop-shadow) could hide a target while IntersectionObserver v2 still reported it visible; the fix makes table parts (and rows/sections) hit targets specifically under IsHitTestVisualOverflow().Attack Path
- Set up a tracked target
A page observes a target element with an IntersectionObserver v2 (
trackVisibility: true). - Overlay with an invisible table part
The page positions a table whose part (row, cell, section, or the table itself) is
visibility: hiddenyet usesfilter: drop-shadow(...)to cast ink over the target (as in theTableRowOcclusion/TableOcclusiontests). - Evade occlusion detection Because the invisible table part was skipped by occlusion hit testing, the observer reported the target as visible despite being covered by the drop-shadow.
- Misrepresent visibility Content or UI that relies on the visibility signal (e.g. gating an action on an element being genuinely visible) is deceived, the UI-misrepresentation/spoofing impact the advisory describes.
Impact Assessment
trackVisibility and an attacker-controlled table element casting ink over it. The advisory does not report in-the-wild exploitation, and use of this to defeat any specific visibility-gated security control is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/paint/box_fragment_painter.cc |
modified | |
TEST_Fthird_party/blink/renderer/core/intersection_observer/intersection_observer_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/paint/box_fragment_painter.ccthird_party/blink/renderer/core/intersection_observer/intersection_observer_test.cc
Audit Directions
- Occlusion vs regular hit testing divergenceReview fragment types excluded from hit testing (table parts, SVG, pseudo-content) and confirm each still contributes to
IsHitTestVisualOverflowocclusion where it can emit ink. - Ink beyond layout boundsAudit visibility/occlusion logic for effects that paint outside a box (
drop-shadow,box-shadow,outline, overflowing filters) on elements that are not themselves paint-visible. - Visibility-signal trustLook for features that gate behavior on IntersectionObserver v2 visibility and consider whether occlusion coverage is complete enough to rely on for security decisions.
Patch
From ec8e14090e80ee238dbc34ef907967f879c3662d Mon Sep 17 00:00:00 2001 From: Stefan Zager <[email protected]> Date: Fri, 10 Jul 2026 16:29:04 -0700 Subject: [PATCH] [IOv2] Hit test table parts and rows for occlusion Bug: 518035396 Change-Id: Ibfd1df5ce1e62c4d0de73728215348bd4cb8c339 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8068547 Reviewed-by: Philip Rogers <[email protected]> Commit-Queue: Stefan Zager <[email protected]> Cr-Commit-Position: refs/heads/main@{#1660595} --- 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 f90aa3b..e332e208 100644 --- a/third_party/blink/renderer/core/paint/box_fragment_painter.cc +++ b/third_party/blink/renderer/core/paint/box_fragment_painter.cc @@ -168,8 +168,19 @@ inline bool IsVisibleToHitTest(const PhysicalFragment& fragment, const HitTestRequest& request) { const ComputedStyle& style = fragment.Style(); - return IsVisibleToPaint(fragment, style) && - IsVisibleToHitTest(style, request); + if (!IsVisibleToHitTest(style, request)) { + return false; + } + if (IsVisibleToPaint(fragment, style)) { + return true; + } + // Table parts that are not themselves visible can still contribute ink + // to visible descendants. + if (request.IsHitTestVisualOverflow() && fragment.IsTablePart()) + [[unlikely]] { + return true; + } + return false; } // Hit tests inline ancestor elements of |fragment| who do not have their own @@ -2489,11 +2500,13 @@ bool pointer_events_bounding_box = false; bool hit_test_self = fragment.IsInSelfHitTestingPhase(hit_test.phase); if (hit_test_self) { - // Table row and table section are never a hit target. + // Table row and table section are never a hit target for regular hit test, + // but they are targets when testing for occlusion. // SVG <text> is not a hit target except if 'pointer-events: bounding-box'. if (GetPhysicalFragment().IsTableRow() || GetPhysicalFragment().IsTableSection()) { - hit_test_self = false; + hit_test_self = + hit_test.result->GetHitTestRequest().IsHitTestVisualOverflow(); } else if (fragment.IsSvgText()) { pointer_events_bounding_box = fragment.Style().UsedPointerEvents() == EPointerEvents::kBoundingBox; diff --git a/third_party/blink/renderer/core/intersection_observer/intersection_observer_test.cc b/third_party/blink/renderer/core/intersection_observer/intersection_observer_test.cc index 3f8e018..f0f73f2e 100644 --- a/third_party/blink/renderer/core/intersection_observer/intersection_observer_test.cc +++ b/third_party/blink/renderer/core/intersection_observer/intersection_observer_test.cc @@ -2810,6 +2810,251 @@ EXPECT_TRUE(observer_delegate->LastEntry()->isVisible()); } +TEST_F(IntersectionObserverV2Test, TableRowOcclusion) { + WebView().MainFrameViewWidget()->Resize(gfx::Size(800, 600)); + SimRequest main_resource("https://example.com/", "text/html"); + LoadURL("https://example.com/"); + main_resource.Complete(R"HTML( + <style> + #target { + width: 100px; + height: 100px; + } + #table { + position: absolute; + left: 200px; + top: 0; + visibility: hidden; + border-collapse: collapse; + } + #row { + filter: drop-shadow(-200px 0 0 black); + } + #cell { + visibility: visible; + width: 100px; + height: 100px; + background: black; + } + </style> + <div id='target'></div> + <table id='table'> + <tr id='row'> + <td id='cell'></td> + </tr> + </table> + )HTML"); + Compositor().BeginFrame(); + + IntersectionObserverInit* observer_init = IntersectionObserverInit::Create(); + observer_init->setTrackVisibility(true); + observer_init->setDelay(100); + DummyExceptionStateForTesting exception_state; + TestIntersectionObserverDelegate* observer_delegate = + MakeGarbageCollected<TestIntersectionObserverDelegate>(GetDocument()); + IntersectionObserver* observer = IntersectionObserver::Create( + observer_init, *observer_delegate, + LocalFrameUkmAggregator::kJavascriptIntersectionObserver, + exception_state); + ASSERT_FALSE(exception_state.HadException()); + Element* target = GetDocument().getElementById(AtomicString("target")); + ASSERT_TRUE(target); + observer->observe(target); + + Compositor().BeginFrame(); + test::RunPendingTasks(); + ASSERT_FALSE(Compositor().NeedsBeginFrame()); + EXPECT_EQ(observer_delegate->CallCount(), 1); + EXPECT_EQ(observer_delegate->EntryCount(), 1); + EXPECT_TRUE(observer_delegate->LastEntry()->isIntersecting()); + // The target is occluded by the drop-shadow of the table row. + EXPECT_FALSE(observer_delegate->LastEntry()->isVisible()); +} + +TEST_F(IntersectionObserverV2Test, TableCellOcclusion) { + WebView().MainFrameViewWidget()->Resize(gfx::Size(800, 600)); + SimRequest main_resource("https://example.com/", "text/html"); + LoadURL("https://example.com/"); + main_resource.Complete(R"HTML( + <style> + #target { + width: 100px; + height: 100px; + } + #table { + position: absolute; + left: 200px; + top: 0; + border-collapse: collapse; + } + #cell { + visibility: hidden; + filter: drop-shadow(-200px 0 0 black); + } + #child { + visibility: visible; + width: 100px; + height: 100px; + background: black; + } + </style> + <div id='target'></div> + <table id='table'> + <tr> + <td id='cell'><div id='child'></div></td> + </tr> + </table> + )HTML"); + Compositor().BeginFrame(); + + IntersectionObserverInit* observer_init = IntersectionObserverInit::Create(); + observer_init->setTrackVisibility(true); + observer_init->setDelay(100); + DummyExceptionStateForTesting exception_state; + TestIntersectionObserverDelegate* observer_delegate = + MakeGarbageCollected<TestIntersectionObserverDelegate>(GetDocument()); + IntersectionObserver* observer = IntersectionObserver::Create( + observer_init, *observer_delegate, + LocalFrameUkmAggregator::kJavascriptIntersectionObserver, + exception_state); + ASSERT_FALSE(exception_state.HadException()); + Element* target = GetDocument().getElementById(AtomicString("target")); + ASSERT_TRUE(target); + observer->observe(target); + + Compositor().BeginFrame(); + test::RunPendingTasks(); + ASSERT_FALSE(Compositor().NeedsBeginFrame()); + EXPECT_EQ(observer_delegate->CallCount(), 1); + EXPECT_EQ(observer_delegate->EntryCount(), 1); + EXPECT_TRUE(observer_delegate->LastEntry()->isIntersecting()); + // The target is occluded by the drop-shadow of the table cell. + EXPECT_FALSE(observer_delegate->LastEntry()->isVisible()); +} + +TEST_F(IntersectionObserverV2Test, TableHeaderGroupOcclusion) { + WebView().MainFrameViewWidget()->Resize(gfx::Size(800, 600)); + SimRequest main_resource("https://example.com/", "text/html"); + LoadURL("https://example.com/"); + main_resource.Complete(R"HTML( + <style> + #target { + width: 100px; + height: 100px; + } + #table { + position: absolute; + left: 200px; + top: 0; + visibility: hidden; + border-collapse: collapse; + } + #header { + filter: drop-shadow(-200px 0 0 black); + } + #cell { + visibility: visible; + width: 100px; + height: 100px; + background: black; + } + </style> + <div id='target'></div> + <table id='table'> + <thead id='header'> + <tr> + <td id='cell'></td> + </tr> + </thead> + </table> + )HTML"); + Compositor().BeginFrame(); + + IntersectionObserverInit* observer_init = IntersectionObserverInit::Create(); + observer_init->setTrackVisibility(true); + observer_init->setDelay(100); + DummyExceptionStateForTesting exception_state; + TestIntersectionObserverDelegate* observer_delegate = + MakeGarbageCollected<TestIntersectionObserverDelegate>(GetDocument()); + IntersectionObserver* observer = IntersectionObserver::Create( + observer_init, *observer_delegate, + LocalFrameUkmAggregator::kJavascriptIntersectionObserver, + exception_state); + ASSERT_FALSE(exception_state.HadException()); + Element* target = GetDocument().getElementById(AtomicString("target")); + ASSERT_TRUE(target); + observer->observe(target); + + Compositor().BeginFrame(); + test::RunPendingTasks(); + ASSERT_FALSE(Compositor().NeedsBeginFrame()); + EXPECT_EQ(observer_delegate->CallCount(), 1); + EXPECT_EQ(observer_delegate->EntryCount(), 1); + EXPECT_TRUE(observer_delegate->LastEntry()->isIntersecting()); + // The target is occluded by the drop-shadow of the table header group + // (thead). + EXPECT_FALSE(observer_delegate->LastEntry()->isVisible()); +} + +TEST_F(IntersectionObserverV2Test, TableOcclusion) { + WebView().MainFrameViewWidget()->Resize(gfx::Size(800, 600)); + SimRequest main_resource("https://example.com/", "text/html"); + LoadURL("https://example.com/"); + main_resource.Complete(R"HTML( + <style> + #target { + width: 100px; + height: 100px; + } + #table { + position: absolute; + left: 200px; + top: 0; + visibility: hidden; + border-collapse: collapse; + filter: drop-shadow(-200px 0 0 black); + } + #cell { + visibility: visible; + width: 100px; + height: 100px; + background: black; + } + </style> + <div id='target'></div> + <table id='table'> + <tr> + <td id='cell'></td> + </tr> + </table> + )HTML"); + Compositor().BeginFrame(); + + IntersectionObserverInit* observer_init = IntersectionObserverInit::Create(); + observer_init->setTrackVisibility(true); + observer_init->setDelay(100); + DummyExceptionStateForTesting exception_state; + TestIntersectionObserverDelegate* observer_delegate = + MakeGarbageCollected<TestIntersectionObserverDelegate>(GetDocument()); + IntersectionObserver* observer = IntersectionObserver::Create( + observer_init, *observer_delegate, + LocalFrameUkmAggregator::kJavascriptIntersectionObserver, + exception_state); + ASSERT_FALSE(exception_state.HadException()); + Element* target = GetDocument().getElementById(AtomicString("target")); + ASSERT_TRUE(target); + observer->observe(target); + + Compositor().BeginFrame(); + test::RunPendingTasks(); + ASSERT_FALSE(Compositor().NeedsBeginFrame()); + EXPECT_EQ(observer_delegate->CallCount(), 1); + EXPECT_EQ(observer_delegate->EntryCount(), 1); + EXPECT_TRUE(observer_delegate->LastEntry()->isIntersecting()); + // The target is occluded by the drop-shadow of the table. + EXPECT_FALSE(observer_delegate->LastEntry()->isVisible()); +} + TEST_F(IntersectionObserverV2Test, BasicOpacity) { WebView().MainFrameViewWidget()->Resize(gfx::Size(800, 600)); SimRequest main_resource("https://example.com/", "text/html");
Regression Test / PoC
diff --git a/third_party/blink/renderer/core/intersection_observer/intersection_observer_test.cc b/third_party/blink/renderer/core/intersection_observer/intersection_observer_test.cc
index 3f8e018..f0f73f2e 100644
--- a/third_party/blink/renderer/core/intersection_observer/intersection_observer_test.cc
+++ b/third_party/blink/renderer/core/intersection_observer/intersection_observer_test.cc
@@ -2810,6 +2810,251 @@
EXPECT_TRUE(observer_delegate->LastEntry()->isVisible());
}
+TEST_F(IntersectionObserverV2Test, TableRowOcclusion) {
+ WebView().MainFrameViewWidget()->Resize(gfx::Size(800, 600));
+ SimRequest main_resource("https://example.com/", "text/html");
+ LoadURL("https://example.com/");
+ main_resource.Complete(R"HTML(
+ <style>
+ #target {
+ width: 100px;
+ height: 100px;
+ }
+ #table {
+ position: absolute;
+ left: 200px;
+ top: 0;
+ visibility: hidden;
+ border-collapse: collapse;
+ }
+ #row {
+ filter: drop-shadow(-200px 0 0 black);
+ }
+ #cell {
+ visibility: visible;
+ width: 100px;
+ height: 100px;
+ background: black;
+ }
+ </style>
+ <div id='target'></div>
+ <table id='table'>
+ <tr id='row'>
+ <td id='cell'></td>
+ </tr>
+ </table>
+ )HTML");
+ Compositor().BeginFrame();
+
+ IntersectionObserverInit* observer_init = IntersectionObserverInit::Create();
+ observer_init->setTrackVisibility(true);
+ observer_init->setDelay(100);
+ DummyExceptionStateForTesting exception_state;
+ TestIntersectionObserverDelegate* observer_delegate =
+ MakeGarbageCollected<TestIntersectionObserverDelegate>(GetDocument());
+ IntersectionObserver* observer = IntersectionObserver::Create(
+ observer_init, *observer_delegate,
+ LocalFrameUkmAggregator::kJavascriptIntersectionObserver,
+ exception_state);
+ ASSERT_FALSE(exception_state.HadException());
+ Element* target = GetDocument().getElementById(AtomicString("target"));
+ ASSERT_TRUE(target);
+ observer->observe(target);
+
+ Compositor().BeginFrame();
+ test::RunPendingTasks();
+ ASSERT_FALSE(Compositor().NeedsBeginFrame());
+ EXPECT_EQ(observer_delegate->CallCount(), 1);
+ EXPECT_EQ(observer_delegate->EntryCount(), 1);
+ EXPECT_TRUE(observer_delegate->LastEntry()->isIntersecting());
+ // The target is occluded by the drop-shadow of the table row.
+ EXPECT_FALSE(observer_delegate->LastEntry()->isVisible());
+}
+
+TEST_F(IntersectionObserverV2Test, TableCellOcclusion) {
+ WebView().MainFrameViewWidget()->Resize(gfx::Size(800, 600));
+ SimRequest main_resource("https://example.com/", "text/html");
+ LoadURL("https://example.com/");
+ main_resource.Complete(R"HTML(
+ <style>
+ #target {
+ width: 100px;
+ height: 100px;
+ }
+ #table {
+ position: absolute;
+ left: 200px;
+ top: 0;
+ border-collapse: collapse;
+ }
+ #cell {
+ visibility: hidden;
+ filter: drop-shadow(-200px 0 0 black);
+ }
+ #child {
+ visibility: visible;
+ width: 100px;
+ height: 100px;
+ background: black;
+ }
+ </style>
+ <div id='target'></div>
+ <table id='table'>
+ <tr>
+ <td id='cell'><div id='child'></div></td>
+ </tr>
+ </table>
+ )HTML");
+ Compositor().BeginFrame();
+
+ IntersectionObserverInit* observer_init = IntersectionObserverInit::Create();
+ observer_init->setTrackVisibility(true);
+ observer_init->setDelay(100);
+ DummyExceptionStateForTesting exception_state;
+ TestIntersectionObserverDelegate* observer_delegate =
+ MakeGarbageCollected<TestIntersectionObserverDelegate>(GetDocument());
+ IntersectionObserver* observer = IntersectionObserver::Create(
+ observer_init, *observer_delegate,
+ LocalFrameUkmAggregator::kJavascriptIntersectionObserver,
+ exception_state);
+ ASSERT_FALSE(exception_state.HadException());
+ Element* target = GetDocument().getElementById(AtomicString("target"));
+ ASSERT_TRUE(target);
+ observer->observe(target);
+
+ Compositor().BeginFrame();
+ test::RunPendingTasks();
+ ASSERT_FALSE(Compositor().NeedsBeginFrame());
+ EXPECT_EQ(observer_delegate->CallCount(), 1);
+ EXPECT_EQ(observer_delegate->EntryCount(), 1);
+ EXPECT_TRUE(observer_delegate->LastEntry()->isIntersecting());
+ // The target is occluded by the drop-shadow of the table cell.
+ EXPECT_FALSE(observer_delegate->LastEntry()->isVisible());
+}
+
+TEST_F(IntersectionObserverV2Test, TableHeaderGroupOcclusion) {
+ WebView().MainFrameViewWidget()->Resize(gfx::Size(800, 600));
+ SimRequest main_resource("https://example.com/", "text/html");
+ LoadURL("https://example.com/");
+ main_resource.Complete(R"HTML(
+ <style>
+ #target {
+ width: 100px;
+ height: 100px;
+ }
+ #table {
+ position: absolute;
+ left: 200px;
+ top: 0;
+ visibility: hidden;
+ border-collapse: collapse;
+ }
+ #header {
+ filter: drop-shadow(-200px 0 0 black);
+ }
+ #cell {
+ visibility: visible;
+ width: 100px;
+ height: 100px;
+ background: black;
+ }
+ </style>
+ <div id='target'></div>
+ <table id='table'>
+ <thead id='header'>
+ <tr>
+ <td id='cell'></td>
+ </tr>
+ </thead>
+ </table>
+ )HTML");
+ Compositor().BeginFrame();
+
+ IntersectionObserverInit* observer_init = IntersectionObserverInit::Create();
+ observer_init->setTrackVisibility(true);
+ observer_init->setDelay(100);
+ DummyExceptionStateForTesting exception_state;
+ TestIntersectionObserverDelegate* observer_delegate =
+ MakeGarbageCollected<TestIntersectionObserverDelegate>(GetDocument());
+ IntersectionObserver* observer = IntersectionObserver::Create(
+ observer_init, *observer_delegate,
+ LocalFrameUkmAggregator::kJavascriptIntersectionObserver,
+ exception_state);
+ ASSERT_FALSE(exception_state.HadException());
+ Element* target = GetDocument().getElementById(AtomicString("target"));
+ ASSERT_TRUE(target);
+ observer->observe(target);
+
+ Compositor().BeginFrame();
+ test::RunPendingTasks();
+ ASSERT_FALSE(Compositor().NeedsBeginFrame());
+ EXPECT_EQ(observer_delegate->CallCount(), 1);
+ EXPECT_EQ(observer_delegate->EntryCount(), 1);
+ EXPECT_TRUE(observer_delegate->LastEntry()->isIntersecting());
+ // The target is occluded by the drop-shadow of the table header group
+ // (thead).
+ EXPECT_FALSE(observer_delegate->LastEntry()->isVisible());
+}
+
+TEST_F(IntersectionObserverV2Test, TableOcclusion) {
+ WebView().MainFrameViewWidget()->Resize(gfx::Size(800, 600));
+ SimRequest main_resource("https://example.com/", "text/html");
+ LoadURL("https://example.com/");
+ main_resource.Complete(R"HTML(
+ <style>
+ #target {
+ width: 100px;
+ height: 100px;
+ }
+ #table {
+ position: absolute;
+ left: 200px;
+ top: 0;
+ visibility: hidden;
+ border-collapse: collapse;
+ filter: drop-shadow(-200px 0 0 black);
+ }
+ #cell {
+ visibility: visible;
+ width: 100px;
+ height: 100px;
+ background: black;
+ }
+ </style>
+ <div id='target'></div>
+ <table id='table'>
+ <tr>
+ <td id='cell'></td>
+ </tr>
+ </table>
+ )HTML");
+ Compositor().BeginFrame();
+
+ IntersectionObserverInit* observer_init = IntersectionObserverInit::Create();
+ observer_init->setTrackVisibility(true);
+ observer_init->setDelay(100);
+ DummyExceptionStateForTesting exception_state;
+ TestIntersectionObserverDelegate* observer_delegate =
+ MakeGarbageCollected<TestIntersectionObserverDelegate>(GetDocument());
+ IntersectionObserver* observer = IntersectionObserver::Create(
+ observer_init, *observer_delegate,
+ LocalFrameUkmAggregator::kJavascriptIntersectionObserver,
+ exception_state);
+ ASSERT_FALSE(exception_state.HadException());
+ Element* target = GetDocument().getElementById(AtomicString("target"));
+ ASSERT_TRUE(target);
+ observer->observe(target);
+
+ Compositor().BeginFrame();
+ test::RunPendingTasks();
+ ASSERT_FALSE(Compositor().NeedsBeginFrame());
+ EXPECT_EQ(observer_delegate->CallCount(), 1);
+ EXPECT_EQ(observer_delegate->EntryCount(), 1);
+ EXPECT_TRUE(observer_delegate->LastEntry()->isIntersecting());
+ // The target is occluded by the drop-shadow of the table.
+ EXPECT_FALSE(observer_delegate->LastEntry()->isVisible());
+}
+
TEST_F(IntersectionObserverV2Test, BasicOpacity) {
WebView().MainFrameViewWidget()->Resize(gfx::Size(800, 600));
SimRequest main_resource("https://example.com/", "text/html");