Medium watch CVSS 6.5 chrome Race 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace in Frames
DescriptionRace in Frames
ComponentFrames
Sub-componentBlink>Geometry
Bug ClassRace
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker371247941
Fix commit7b2e3f7ff30d (chromium/src) +105/-19
CWECWE-362 (Race condition)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
CISA KEVNot listed
CreditedHafiizh
Reported2024-10-03 · bounty $5000 (per vendor advisory)
Fix landed2024-10-24 (public git)
Disclosed2025-01-14
Patch gap82 days — fix public in source before the advisory

Background

`IntersectionObserver` v2
a Web API that, when trackVisibility is enabled, reports not only whether a target intersects the viewport but whether it is actually visible (unoccluded), so pages can gate actions like clicks on genuine visibility.
`FrameOcclusionState`
a mojom::blink enum whose values (kGuaranteedNotOccluded, kPossiblyOccluded, kUnknown) describe whether a frame is known to be unobscured by other content.
Occlusion propagation
the process in frame_view.cc by which a child frame derives its occlusion state from its parent’s state and, when needed, an actual geometry test of the child within the parent.
Lifecycle throttling
Blink may skip document lifecycle updates (kPrePaintClean and beyond) for out-of-view or cross-origin frames, leaving their occlusion data stale or unavailable.

Root Cause Analysis

The vulnerable path lived in LocalFrameView’s occlusion computation in frame_view.cc, which propagated the parent frame’s kGuaranteedNotOccluded state to a child without ever performing an occlusion test on that child when tracking was not requested or the parent lifecycle was throttled. The invariant that “visibility must never be asserted without a positive occlusion test on the frame itself” was violated, because a child could inherit kGuaranteedNotOccluded purely from its parent. Compounding this, IntersectionObservation::ShouldCompute in intersection_observation.cc only deferred computation when the state was kUnknown, so once a target had been reported visible it could remain latched as visible even as the frame occlusion state degraded.

The fix forces occlusion_state to kUnknown whenever the parent is kGuaranteedNotOccluded unless an actual test on this frame is scheduled, computes a real kGuaranteedNotOccluded/kPossiblyOccluded result from geometry.IsVisible(), and makes deferral also trigger when a currently-visible target sees kUnknown — closing the race where stale or inherited state let occluded content be reported as visible.

Key insight
The core mistake was treating a parent frame’s “guaranteed visible” occlusion state as sufficient to declare a child frame visible without independently testing the child, so a transition to kUnknown occlusion never demoted an already-“visible” target. The fix propagates kUnknown unless the child is actually occlusion-tested, and demotes any currently-visible target when its frame’s state becomes kUnknown.

Attack Path

  1. Establish a tracked target in a nested frame An attacker page embeds a cross-origin iframe that runs an IntersectionObserver with trackVisibility: true over a clickable target.
  2. Get the target reported visible At a moment when the parent frame’s FrameOcclusionState is kGuaranteedNotOccluded, the child inherits that state and the target is reported as isVisible: true, latching last_is_visible_.
  3. Race the occlusion state to "unknown" The attacker throttles or de-syncs the parent’s lifecycle (e.g. scrolling, cross-origin throttling) so the frame’s occlusion state becomes kUnknown while the target stays latched as visible.
  4. Overlay or reposition to occlude the target Because the stale visible report is not demoted, the attacker places overlapping content over the target while the API still reports it visible.
  5. Trigger a gated action A user gesture or script-observed “visible” state drives a clickjacking-style action against a target the user cannot actually see unobstructed.

Impact Assessment

An attacker gains the ability to have occluded or obscured content falsely reported as visible through IntersectionObserver v2, defeating the visibility guarantees that pages rely on to prevent clickjacking, in the renderer process across a cross-origin frame boundary. Exploitation requires the attacker to control a page embedding a tracked cross-origin frame and to win a timing race that drives the frame’s FrameOcclusionState to kUnknown while a target is latched visible. The result is a visibility-integrity bypass enabling UI redress attacks rather than direct memory corruption.

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/frame/frame_view.cc
modified
async_test
third_party/blink/web_tests/external/wpt/intersection-observer/v2/nested-cross-origin.sub.html
modified

Files Changed

  • third_party/blink/renderer/core/frame/frame_view.cc
  • third_party/blink/renderer/core/intersection_observer/intersection_observation.cc
  • third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-midframe.sub.html
  • third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-subframe.html
  • third_party/blink/web_tests/external/wpt/intersection-observer/v2/nested-cross-origin.sub.html

Audit Directions

  • Inherited-without-verification state
    Look for places where a child frame or object copies a parent’s positive/safe security state (visibility, occlusion, trust) without an independent test of the child itself.
  • Latched positive results across state degradation
    Audit code that caches a “true/visible/allowed” result and only re-evaluates on specific transitions, missing degradations to kUnknown/unavailable states during lifecycle throttling.
  • Throttling and lifecycle race windows
    Examine cross-origin or throttled frame paths where stale lifecycle data (kPrePaintClean gating) can be raced to make security-relevant computations report optimistic answers.
From 7b2e3f7ff30d5dcf17cd5c00f0554a44eec2c2a1 Mon Sep 17 00:00:00 2001
From: Stefan Zager <szager@chromium.org>
Date: Thu, 24 Oct 2024 02:35:39 +0000
Subject: [PATCH] IntersectionObserver -- properly handle "unknown" occlusion state

If we most recently reported a target as "guaranteed visible", then in
the interest of avoiding false positives we must transition to "not
guaranteed visible" if the frame occlusion state becomes "unknown".

This CL also makes a child frame inherit its parent's "not visible"
occlusion state rather than calling it "unknown", which is technically
more correct.

Bug: chromium:371247941
Change-Id: I4d721dd252d013deac14a12f1f2922830ef2a8a4
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/5950965
Reviewed-by: Xianzhu Wang <wangxianzhu@chromium.org>
Commit-Queue: Stefan Zager <szager@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1373093}
---

diff --git a/third_party/blink/renderer/core/frame/frame_view.cc b/third_party/blink/renderer/core/frame/frame_view.cc
index 3d6d9a2..29981ce52 100644
--- a/third_party/blink/renderer/core/frame/frame_view.cc
+++ b/third_party/blink/renderer/core/frame/frame_view.cc
@@ -77,15 +77,20 @@
   gfx::Transform main_frame_transform_matrix;
   DocumentLifecycle::LifecycleState parent_lifecycle_state =
       owner_document.Lifecycle().GetState();
+
+  bool should_compute_occlusion = false;
   mojom::blink::FrameOcclusionState occlusion_state =
       owner_document.GetFrame()->GetOcclusionState();
-  bool should_compute_occlusion =
-      needs_occlusion_tracking &&
-      occlusion_state ==
-          mojom::blink::FrameOcclusionState::kGuaranteedNotOccluded &&
-      parent_lifecycle_state >= DocumentLifecycle::kPrePaintClean;
-  if (!should_compute_occlusion) {
+  if (occlusion_state ==
+      mojom::blink::FrameOcclusionState::kGuaranteedNotOccluded) {
+    // We can't propagate kGuaranteedNotOccluded from the parent without testing
+    // occlusion of this frame. If we don't ultimately do an occlusion test on
+    // this frame, then we should propagate "unknown".
     occlusion_state = mojom::blink::FrameOcclusionState::kUnknown;
+    if (needs_occlusion_tracking &&
+        parent_lifecycle_state >= DocumentLifecycle::kPrePaintClean) {
+      should_compute_occlusion = true;
+    }
   }
 
   LayoutEmbeddedContent* owner_layout_object =
@@ -159,8 +164,12 @@
         rect_in_parent_stable_since_for_iov2_ = base::TimeTicks::Now();
       }
     }
-    if (should_compute_occlusion && !geometry.IsVisible())
-      occlusion_state = mojom::blink::FrameOcclusionState::kPossiblyOccluded;
+    if (should_compute_occlusion) {
+      occlusion_state =
+          geometry.IsVisible()
+              ? mojom::blink::FrameOcclusionState::kGuaranteedNotOccluded
+              : mojom::blink::FrameOcclusionState::kPossiblyOccluded;
+    }
 
     // Generate matrix to transform from the space of the containing document
     // to the space of the iframe's contents.
@@ -248,11 +257,6 @@
     }
     main_frame_transform_matrix =
         child_frame_to_root_frame.AccumulatedTransform();
-  } else if (occlusion_state ==
-             mojom::blink::FrameOcclusionState::kGuaranteedNotOccluded) {
-    // If the parent LocalFrameView is throttled and out-of-date, then we can't
-    // get any useful information.
-    occlusion_state = mojom::blink::FrameOcclusionState::kUnknown;
   }
 
   // An iframe's content is always pixel-snapped, even if the iframe element has
diff --git a/third_party/blink/renderer/core/intersection_observer/intersection_observation.cc b/third_party/blink/renderer/core/intersection_observer/intersection_observation.cc
index a05620d..160254b6 100644
--- a/third_party/blink/renderer/core/intersection_observer/intersection_observation.cc
+++ b/third_party/blink/renderer/core/intersection_observer/intersection_observation.cc
@@ -179,11 +179,14 @@
       Observer()->trackVisibility()) {
     mojom::blink::FrameOcclusionState occlusion_state =
         target_->GetDocument().GetFrame()->GetOcclusionState();
-    // If we're tracking visibility, and we don't have occlusion information
-    // from our parent frame, then postpone computing intersections until a
-    // later lifecycle when the occlusion information is known.
-    if (occlusion_state == mojom::blink::FrameOcclusionState::kUnknown)
+    // If we're tracking visibility, and we aren't currently reporting the
+    // target visible, and we don't have occlusion information from our parent
+    // frame, then postpone computing intersections until a later lifecycle when
+    // the occlusion information is known.
+    if (!last_is_visible_ &&
+        occlusion_state == mojom::blink::FrameOcclusionState::kUnknown) {
       return false;
+    }
   }
   return true;
 }
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-midframe.sub.html b/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-midframe.sub.html
new file mode 100644
index 0000000..b9b055b6
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-midframe.sub.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<style>
+iframe {
+  width: 300px;
+  height: 150px;
+  border: none;
+}
+</style>
+
+<iframe src="http://{{domains[www2]}}:{{ports[http][0]}}/intersection-observer/resources/v2-subframe.html"></iframe>
+
+<script>
+window.addEventListener("message", event => {
+  requestAnimationFrame(() => setTimeout(() => {
+    document.querySelector('iframe').contentWindow.postMessage(event.data, "*");
+  }));
+});
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-subframe.html b/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-subframe.html
index 295bbf04..cfc82646 100644
--- a/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-subframe.html
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-subframe.html
@@ -16,7 +16,7 @@
 
 window.addEventListener("message", event => {
   waitForNotification(() => {
-    window.parent.postMessage(results.map(e => e.isVisible), "*");
+    window.top.postMessage(results.map(e => e.isVisible), "*");
     results = [];
   });
 });
@@ -27,6 +27,6 @@
     results = entries;
   }, {trackVisibility: true, delay: delay});
   observer.observe(document.getElementById("target"));
-  window.parent.postMessage("", "*");
+  window.top.postMessage("", "*");
 };
 </script>
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/v2/nested-cross-origin.sub.html b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/nested-cross-origin.sub.html
new file mode 100644
index 0000000..42a69bf
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/nested-cross-origin.sub.html
@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="../resources/intersection-observer-test-utils.js"></script>
+
+<style>
+pre, #log {
+  position: absolute;
+  top: 0;
+  left: 200px;
+}
+iframe {
+  width: 300px;
+  height: 150px;
+  border: none;
+}
+</style>
+
+<iframe src="http://{{domains[www1]}}:{{ports[http][0]}}/intersection-observer/resources/v2-midframe.sub.html"></iframe>
+
+<script>
+async_test(function(t) {
+  let iframe = document.querySelector("iframe");
+
+  function step0(event) {
+    assert_equals(event.data,"");
+  }
+
+  function step1(event) {
+    assert_equals(JSON.stringify(event.data),
+                  JSON.stringify([true]));
+    iframe.style.opacity = "0.9";
+  }
+
+  function step2(event) {
+    assert_equals(JSON.stringify(event.data),
+                  JSON.stringify([false]));
+    iframe.style.opacity = "";
+  }
+
+  function step3(event) {
+    assert_equals(JSON.stringify(event.data),
+                  JSON.stringify([true]));
+  }
+
+  let steps = [step0, step1, step2, step3];
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-midframe.sub.html b/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-midframe.sub.html
new file mode 100644
index 0000000..b9b055b6
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-midframe.sub.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<style>
+iframe {
+  width: 300px;
+  height: 150px;
+  border: none;
+}
+</style>
+
+<iframe src="http://{{domains[www2]}}:{{ports[http][0]}}/intersection-observer/resources/v2-subframe.html"></iframe>
+
+<script>
+window.addEventListener("message", event => {
+  requestAnimationFrame(() => setTimeout(() => {
+    document.querySelector('iframe').contentWindow.postMessage(event.data, "*");
+  }));
+});
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-subframe.html b/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-subframe.html
index 295bbf04..cfc82646 100644
--- a/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-subframe.html
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/resources/v2-subframe.html
@@ -16,7 +16,7 @@
 
 window.addEventListener("message", event => {
   waitForNotification(() => {
-    window.parent.postMessage(results.map(e => e.isVisible), "*");
+    window.top.postMessage(results.map(e => e.isVisible), "*");
     results = [];
   });
 });
@@ -27,6 +27,6 @@
     results = entries;
   }, {trackVisibility: true, delay: delay});
   observer.observe(document.getElementById("target"));
-  window.parent.postMessage("", "*");
+  window.top.postMessage("", "*");
 };
 </script>
diff --git a/third_party/blink/web_tests/external/wpt/intersection-observer/v2/nested-cross-origin.sub.html b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/nested-cross-origin.sub.html
new file mode 100644
index 0000000..42a69bf
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/intersection-observer/v2/nested-cross-origin.sub.html
@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="../resources/intersection-observer-test-utils.js"></script>
+
+<style>
+pre, #log {
+  position: absolute;
+  top: 0;
+  left: 200px;
+}
+iframe {
+  width: 300px;
+  height: 150px;
+  border: none;
+}
+</style>
+
+<iframe src="http://{{domains[www1]}}:{{ports[http][0]}}/intersection-observer/resources/v2-midframe.sub.html"></iframe>
+
+<script>
+async_test(function(t) {
+  let iframe = document.querySelector("iframe");
+
+  function step0(event) {
+    assert_equals(event.data,"");
+  }
+
+  function step1(event) {
+    assert_equals(JSON.stringify(event.data),
+                  JSON.stringify([true]));
+    iframe.style.opacity = "0.9";
+  }
+
+  function step2(event) {
+    assert_equals(JSON.stringify(event.data),
+                  JSON.stringify([false]));
+    iframe.style.opacity = "";
+  }
+
+  function step3(event) {
+    assert_equals(JSON.stringify(event.data),
+                  JSON.stringify([true]));
+  }
+
+  let steps = [step0, step1, step2, step3];
+
+  window.addEventListener("message", event => {
+    if (steps.length) {
+      t.step(steps.shift(), t, event);
+      waitForFrame(t, () => {
+        iframe.contentWindow.postMessage("", "*");
+      });
+    } else {
+      t.done();
+    }
+  });
+
+}, "Intersection observer V2 test with nested cross-origin iframes.");
+</script>
Loading diff…

Original Bug Report

reported by sa...@gmail.com

Bypass of https://issues.chromium.org/issues/333708039

VULNERABILITY DETAILS

This vulnerability is similar to https://issues.chromium.org/issues/333708039, in this bug when the cursor focus on google one tap button after that the opacity the frame set 0 (obscured by “click me see funny cats” button) the focus still to google tap button lead to click jacking

VERSION Chrome Version 131.0.6755.0 (Official Build) canary (64-bit) Operating System: Windows 10

REPRODUCTION CASE

  1. open https://thundering-unruly-windflower.glitch.me/spoofh.html
  2. click on “click me see funny cats” button

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: Hafiizh (https://www.linkedin.com/in/hafiizh-7aa6bb31/)

View on issue tracker