Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactSide-channel information leakage in PerformanceAPIs
DescriptionSide-channel information leakage in PerformanceAPIs
ComponentPerformanceAPIs
Bug ClassLogic Error
Tracker502073069
Fix commitcaf2ad2c3233 (chromium/src) +39/-17
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
cross_origin_isolated_capability_
third_party/blink/renderer/core/timing/performance_script_timing.cc
modified

Files Changed

  • third_party/blink/renderer/core/timing/performance_long_animation_frame_timing.cc
  • third_party/blink/renderer/core/timing/performance_long_animation_frame_timing.h
  • third_party/blink/renderer/core/timing/performance_script_timing.cc
  • third_party/blink/renderer/core/timing/performance_script_timing.h
  • third_party/blink/web_tests/external/wpt/long-animation-frame/loaf-script-nested-callback.html
  • third_party/blink/web_tests/external/wpt/long-animation-frame/resources/utils.js
From caf2ad2c32337953f0d7ff469cbebddc11b29537 Mon Sep 17 00:00:00 2001
From: Yoav Weiss <[email protected]>
Date: Mon, 13 Apr 2026 04:49:41 -0700
Subject: [PATCH] LoAF: durations fix

See bug for details.

Bug: 502073069
Change-Id: I39b9328f87899b9f302c3d3fe36aaa7d5dc90010
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7749166
Reviewed-by: Noam Rosenthal <[email protected]>
Commit-Queue: Yoav Weiss (@Shopify) <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1613631}
---

diff --git a/third_party/blink/renderer/core/timing/performance_long_animation_frame_timing.cc b/third_party/blink/renderer/core/timing/performance_long_animation_frame_timing.cc
index adbfd7eb..db7794f 100644
--- a/third_party/blink/renderer/core/timing/performance_long_animation_frame_timing.cc
+++ b/third_party/blink/renderer/core/timing/performance_long_animation_frame_timing.cc
@@ -73,9 +73,15 @@
           info->FirstUIEventTime(),
           /*allow_negative_value=*/false,
           cross_origin_isolated_capability)),
-      blocking_duration_(info->TotalBlockingDuration().InMillisecondsF()),
-      style_duration_(info->StyleDuration().InMillisecondsF()),
-      layout_duration_(info->LayoutDuration().InMillisecondsF()) {
+      blocking_duration_(
+          Performance::ClampTimeResolution(info->TotalBlockingDuration(),
+                                           cross_origin_isolated_capability)),
+      style_duration_(
+          Performance::ClampTimeResolution(info->StyleDuration(),
+                                           cross_origin_isolated_capability)),
+      layout_duration_(
+          Performance::ClampTimeResolution(info->LayoutDuration(),
+                                           cross_origin_isolated_capability)) {
   CHECK(source->ToLocalDOMWindow());
   const SecurityOrigin* security_origin =
       source->ToLocalDOMWindow()->GetSecurityOrigin();
diff --git a/third_party/blink/renderer/core/timing/performance_long_animation_frame_timing.h b/third_party/blink/renderer/core/timing/performance_long_animation_frame_timing.h
index 9ba5cb4..2b378707 100644
--- a/third_party/blink/renderer/core/timing/performance_long_animation_frame_timing.h
+++ b/third_party/blink/renderer/core/timing/performance_long_animation_frame_timing.h
@@ -61,9 +61,9 @@
   DOMHighResTimeStamp render_start_;
   DOMHighResTimeStamp style_and_layout_start_;
   DOMHighResTimeStamp first_ui_event_timestamp_;
-  double blocking_duration_;
-  double style_duration_;
-  double layout_duration_;
+  DOMHighResTimeStamp blocking_duration_;
+  DOMHighResTimeStamp style_duration_;
+  DOMHighResTimeStamp layout_duration_;
   PerformanceScriptVector scripts_;
 };
 
diff --git a/third_party/blink/renderer/core/timing/performance_script_timing.cc b/third_party/blink/renderer/core/timing/performance_script_timing.cc
index 105a3788..6dad7726 100644
--- a/third_party/blink/renderer/core/timing/performance_script_timing.cc
+++ b/third_party/blink/renderer/core/timing/performance_script_timing.cc
@@ -32,7 +32,7 @@
     bool cross_origin_isolated_capability,
     DOMWindow* source,
     uint32_t navigation_id)
-    : PerformanceEntry((info->EndTime() - info->StartTime()).InMilliseconds(),
+    : PerformanceEntry(0,
                        performance_entry_names::kScript,
                        Performance::MonotonicTimeToDOMHighResTimeStamp(
                            time_origin,
@@ -40,7 +40,13 @@
                            false,
                            cross_origin_isolated_capability),
                        source,
-                       navigation_id) {
+                       navigation_id),
+      cross_origin_isolated_capability_(cross_origin_isolated_capability) {
+  DOMHighResTimeStamp end_time =
+      Performance::MonotonicTimeToDOMHighResTimeStamp(
+          time_origin, info->EndTime(), false,
+          cross_origin_isolated_capability);
+  duration_ = end_time - startTime();
   info_ = info;
   if (!info_->Window() || !source) {
     window_attribution_ = V8ScriptWindowAttribution::Enum::kOther;
@@ -129,19 +135,24 @@
 
 DOMHighResTimeStamp PerformanceScriptTiming::forcedStyleAndLayoutDuration()
     const {
-  return (info_->StyleDuration() + info_->LayoutDuration()).InMilliseconds();
+  return Performance::ClampTimeResolution(
+      info_->StyleDuration() + info_->LayoutDuration(),
+      cross_origin_isolated_capability_);
 }
 
 DOMHighResTimeStamp PerformanceScriptTiming::forcedStyleDuration() const {
-  return info_->StyleDuration().InMilliseconds();
+  return Performance::ClampTimeResolution(info_->StyleDuration(),
+                                          cross_origin_isolated_capability_);
 }
 
 DOMHighResTimeStamp PerformanceScriptTiming::forcedLayoutDuration() const {
-  return info_->LayoutDuration().InMilliseconds();
+  return Performance::ClampTimeResolution(info_->LayoutDuration(),
+                                          cross_origin_isolated_capability_);
 }
 
 DOMHighResTimeStamp PerformanceScriptTiming::pauseDuration() const {
-  return info_->PauseDuration().InMilliseconds();
+  return Performance::ClampTimeResolution(info_->PauseDuration(),
+                                          cross_origin_isolated_capability_);
 }
 
 LocalDOMWindow* PerformanceScriptTiming::window() const {
diff --git a/third_party/blink/renderer/core/timing/performance_script_timing.h b/third_party/blink/renderer/core/timing/performance_script_timing.h
index 16171ca..d5f991d6 100644
--- a/third_party/blink/renderer/core/timing/performance_script_timing.h
+++ b/third_party/blink/renderer/core/timing/performance_script_timing.h
@@ -59,6 +59,7 @@
   Member<ScriptTimingInfo> info_;
   V8ScriptWindowAttribution::Enum window_attribution_;
   DOMHighResTimeStamp execution_start_;
+  bool cross_origin_isolated_capability_;
 };
 
 }  // namespace blink
diff --git a/third_party/blink/web_tests/external/wpt/long-animation-frame/loaf-script-nested-callback.html b/third_party/blink/web_tests/external/wpt/long-animation-frame/loaf-script-nested-callback.html
index c6fe2c8e..45bb684 100644
--- a/third_party/blink/web_tests/external/wpt/long-animation-frame/loaf-script-nested-callback.html
+++ b/third_party/blink/web_tests/external/wpt/long-animation-frame/loaf-script-nested-callback.html
@@ -22,7 +22,8 @@
   }, script =>
     script.invoker === new URL("resources/loaf-after-callback.js", location.href).href,
     t);
-  assert_greater_than_equal(script.duration, very_long_frame_duration);
+  // Allow small epsilon for time clamping.
+  assert_greater_than_equal(script.duration + 1, very_long_frame_duration);
 }, "a callback inside a script block should not mask LoAFs that come afterwards")
 
 promise_test(async t => {
@@ -35,7 +36,8 @@
   }, script =>
     script.invoker === new URL("resources/loaf-in-microtask-after-callback.js", location.href).href,
     t);
-  assert_greater_than_equal(script.duration, very_long_frame_duration);
+  // Allow small epsilon for time clamping.
+  assert_greater_than_equal(script.duration + 1, very_long_frame_duration);
 }, "a callback inside a script block should not mask LoAFs in a microtask")
 </script>
 </body>
diff --git a/third_party/blink/web_tests/external/wpt/long-animation-frame/resources/utils.js b/third_party/blink/web_tests/external/wpt/long-animation-frame/resources/utils.js
index 6e94c1c1..fd75eaff 100644
--- a/third_party/blink/web_tests/external/wpt/long-animation-frame/resources/utils.js
+++ b/third_party/blink/web_tests/external/wpt/long-animation-frame/resources/utils.js
@@ -135,9 +135,11 @@
         script.invoker.startsWith(invoker)), t);
 
     assert_true(!!entry, "Entry detected");
-    assert_greater_than_equal(entry.duration, script.duration);
-    assert_greater_than_equal(script.executionStart, script.startTime);
-    assert_greater_than_equal(script.startTime, entry.startTime)
+    // Allow small epsilon for independent time clamping of entry vs script.
+    const clamping_epsilon = 1;
+    assert_greater_than_equal(entry.duration + clamping_epsilon, script.duration);
+    assert_greater_than_equal(script.executionStart + clamping_epsilon, script.startTime);
+    assert_greater_than_equal(script.startTime + clamping_epsilon, entry.startTime)
     assert_equals(script.window, window);
     assert_equals(script.forcedStyleAndLayoutDuration, 0);
     assert_equals(script.windowAttribution, "self");
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/long-animation-frame/loaf-script-nested-callback.html b/third_party/blink/web_tests/external/wpt/long-animation-frame/loaf-script-nested-callback.html
index c6fe2c8e..45bb684 100644
--- a/third_party/blink/web_tests/external/wpt/long-animation-frame/loaf-script-nested-callback.html
+++ b/third_party/blink/web_tests/external/wpt/long-animation-frame/loaf-script-nested-callback.html
@@ -22,7 +22,8 @@
   }, script =>
     script.invoker === new URL("resources/loaf-after-callback.js", location.href).href,
     t);
-  assert_greater_than_equal(script.duration, very_long_frame_duration);
+  // Allow small epsilon for time clamping.
+  assert_greater_than_equal(script.duration + 1, very_long_frame_duration);
 }, "a callback inside a script block should not mask LoAFs that come afterwards")
 
 promise_test(async t => {
@@ -35,7 +36,8 @@
   }, script =>
     script.invoker === new URL("resources/loaf-in-microtask-after-callback.js", location.href).href,
     t);
-  assert_greater_than_equal(script.duration, very_long_frame_duration);
+  // Allow small epsilon for time clamping.
+  assert_greater_than_equal(script.duration + 1, very_long_frame_duration);
 }, "a callback inside a script block should not mask LoAFs in a microtask")
 </script>
 </body>
diff --git a/third_party/blink/web_tests/external/wpt/long-animation-frame/resources/utils.js b/third_party/blink/web_tests/external/wpt/long-animation-frame/resources/utils.js
index 6e94c1c1..fd75eaff 100644
--- a/third_party/blink/web_tests/external/wpt/long-animation-frame/resources/utils.js
+++ b/third_party/blink/web_tests/external/wpt/long-animation-frame/resources/utils.js
@@ -135,9 +135,11 @@
         script.invoker.startsWith(invoker)), t);
 
     assert_true(!!entry, "Entry detected");
-    assert_greater_than_equal(entry.duration, script.duration);
-    assert_greater_than_equal(script.executionStart, script.startTime);
-    assert_greater_than_equal(script.startTime, entry.startTime)
+    // Allow small epsilon for independent time clamping of entry vs script.
+    const clamping_epsilon = 1;
+    assert_greater_than_equal(entry.duration + clamping_epsilon, script.duration);
+    assert_greater_than_equal(script.executionStart + clamping_epsilon, script.startTime);
+    assert_greater_than_equal(script.startTime + clamping_epsilon, entry.startTime)
     assert_equals(script.window, window);
     assert_equals(script.forcedStyleAndLayoutDuration, 0);
     assert_equals(script.windowAttribution, "self");
Loading diff…

Original Bug Report

reported by [email protected]

Microsecond timing leak in Long Animation Frame API durations

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team.

Overview: The Long Animation Frame (LoAF) API exposes style, layout, and blocking durations with microsecond precision, bypassing standard time coarsening mitigations. Because same-site, cross-origin iframes share a local frame tree on desktop, their computation times are aggregated together. This allows a malicious parent frame to conduct high-precision XS-Leak timing attacks against cross-origin child iframes.

Affected files:

  • third_party/blink/renderer/core/timing/performance_long_animation_frame_timing.cc
  • third_party/blink/renderer/core/frame/animation_frame_timing_monitor.cc
  • third_party/blink/renderer/core/timing/performance_long_animation_frame_timing.h

Estimated timestamp from git blame: 2026-03-05

Summary

The Long Animation Frame (LoAF) API fails to apply standard time coarsening to several exposed duration attributes. styleDuration, layoutDuration, and blockingDuration are exposed to JavaScript with raw microsecond precision. This bypasses the typical 100 µs TimeClamper mitigation designed to prevent timing attacks.

Vulnerability Details

In Blink, performance APIs generally coarsen time values (via Performance::MonotonicTimeToDOMHighResTimeStamp and TimeClamper) before exposing them to the web. However, in third_party/blink/renderer/core/timing/performance_long_animation_frame_timing.cc, the LoAF constructor directly initializes several durations using base::TimeDelta::InMillisecondsF():

blocking_duration_(info->TotalBlockingDuration().InMillisecondsF()),
style_duration_(info->StyleDuration().InMillisecondsF()),
layout_duration_(info->LayoutDuration().InMillisecondsF())

These durations originate from AnimationFrameTimingMonitor, which uses high-resolution base::TimeTicks::Now() to measure probe::RecalculateStyle and probe::UpdateLayout events.

Critically, same-site cross-origin iframes (e.g., a.example.com and b.example.com) typically share a renderer process on desktop platforms. As a result, they share the same local frame tree and inherit the same CoreProbeSink. The AnimationFrameTimingMonitor attached to the parent frame’s CoreProbeSink will aggregate style and layout durations from all frames in the tree, including the cross-origin child iframe.

Impact

A malicious parent frame can observe the aggregated styleDuration and layoutDuration of a cross-origin child iframe at 1 µs granularity. This high-resolution timing channel facilitates Cross-Site Leaks (XS-Leaks), allowing attackers to reliably infer private cross-origin state, such as the presence of specific DOM structures or :visited link status inside the iframe.

Potential Reproduction Steps

Note: The following are suggested steps to trigger the vulnerability; our tooling agent does not have the ability to run code or execute a working Proof of Concept.

  1. Host a malicious page on a.example.com that embeds a child iframe pointing to b.example.com.
  2. In the parent page, register a PerformanceObserver for the 'long-animation-frame' entry type. (Note: this feature may require enabling experimental web platform features or an Origin Trial token for LongAnimationFrameStyleDuration).
  3. Schedule a requestAnimationFrame callback in the parent. Inside it, execute a busy-loop for >50ms to force the browser to generate a Long Animation Frame entry.
  4. Simultaneously, trigger a style/layout change in the child iframe (e.g., by resizing the iframe container or having it load a specific payload).
  5. When the PerformanceObserver callback fires, inspect the styleDuration and layoutDuration properties on the PerformanceLongAnimationFrameTiming entry.
  6. Observe that the values have microsecond precision (e.g., x.xx1 ms), leaking the exact computation time of the cross-origin child’s rendering update.

Suggested Fix

The duration values should be appropriately coarsened before being exposed to JavaScript. A standard approach is to pass the base::TimeDelta through a dedicated clamping function similar to TimeClamper::ClampTimeResolution(). Alternatively, durations could be computed by taking the difference of already-clamped start and end timestamps, ensuring the resulting duration aligns with the browser’s intended security resolution (e.g., 100 µs).

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker