Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactObservable discrepancy in PerformanceAPIs
DescriptionObservable discrepancy in PerformanceAPIs
ComponentPerformanceAPIs
Bug ClassLogic Error
Tracker514439436
Fix commit836a8841ea0e (chromium/src) +34/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Files Changed

  • third_party/blink/renderer/core/timing/performance_long_animation_frame_timing.cc
  • third_party/blink/web_tests/external/wpt/long-animation-frame/loaf-duration-clamping.html
From 836a8841ea0e74dbbee30e64a6541b185ec43cdb Mon Sep 17 00:00:00 2001
From: Noam Rosenthal <[email protected]>
Date: Wed, 15 Jul 2026 12:16:54 -0700
Subject: [PATCH] Clamp resolution of no-render LoAFs

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

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 a2d445b7..0606a22 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
@@ -33,9 +33,11 @@
       DOMWindowPerformance::performance(*source->ToLocalDOMWindow());
   DOMHighResTimeStamp startTime =
       performance->MonotonicTimeToDOMHighResTimeStamp(info->FrameStartTime());
-  double duration = paint_timing_info
-                        ? (paint_timing_info->paint_time - startTime)
-                        : info->Duration().InMillisecondsF();
+  double duration =
+      paint_timing_info
+          ? (paint_timing_info->paint_time - startTime)
+          : Performance::ClampTimeResolution(info->Duration(),
+                                             cross_origin_isolated_capability);
   PerformanceLongAnimationFrameTiming* entry =
       MakeGarbageCollected<PerformanceLongAnimationFrameTiming>(
           duration, startTime, info, time_origin,
diff --git a/third_party/blink/web_tests/external/wpt/long-animation-frame/loaf-duration-clamping.html b/third_party/blink/web_tests/external/wpt/long-animation-frame/loaf-duration-clamping.html
new file mode 100644
index 0000000..1cc3809
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/long-animation-frame/loaf-duration-clamping.html
@@ -0,0 +1,29 @@
+<!DOCTYPE HTML>
+<meta charset=utf-8>
+<title>Long Animation Frame Timing: duration clamping</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/utils.js"></script>
+<body>
+<h1>Test</h1>
+<script>
+promise_test(async t => {
+  const loaf_promise = new Promise(resolve => new PerformanceObserver(entries => {
+      resolve(entries.getEntries()[0]);
+  }).observe({type: "long-animation-frame"}));
+
+  // Block the main thread for 60ms without triggering a render
+  t.step_timeout(() => {
+    busy_wait(60);
+  }, 0);
+
+  const entry = await loaf_promise;
+
+  const COARSE_RESOLUTION = 0.005;
+  const FLOATING_POINT_ERROR_EPSILON = 0.01;
+  const coarse_duration = Math.round(entry.duration / COARSE_RESOLUTION) * COARSE_RESOLUTION;
+  assert_approx_equals(entry.duration, coarse_duration, FLOATING_POINT_ERROR_EPSILON,
+      "duration should be coarsened");
+}, "LoAF duration should be clamped even when there is no render");
+</script>
+</body>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/long-animation-frame/loaf-duration-clamping.html b/third_party/blink/web_tests/external/wpt/long-animation-frame/loaf-duration-clamping.html
new file mode 100644
index 0000000..1cc3809
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/long-animation-frame/loaf-duration-clamping.html
@@ -0,0 +1,29 @@
+<!DOCTYPE HTML>
+<meta charset=utf-8>
+<title>Long Animation Frame Timing: duration clamping</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/utils.js"></script>
+<body>
+<h1>Test</h1>
+<script>
+promise_test(async t => {
+  const loaf_promise = new Promise(resolve => new PerformanceObserver(entries => {
+      resolve(entries.getEntries()[0]);
+  }).observe({type: "long-animation-frame"}));
+
+  // Block the main thread for 60ms without triggering a render
+  t.step_timeout(() => {
+    busy_wait(60);
+  }, 0);
+
+  const entry = await loaf_promise;
+
+  const COARSE_RESOLUTION = 0.005;
+  const FLOATING_POINT_ERROR_EPSILON = 0.01;
+  const coarse_duration = Math.round(entry.duration / COARSE_RESOLUTION) * COARSE_RESOLUTION;
+  assert_approx_equals(entry.duration, coarse_duration, FLOATING_POINT_ERROR_EPSILON,
+      "duration should be coarsened");
+}, "LoAF duration should be clamped even when there is no render");
+</script>
+</body>
Loading diff…

Original Bug Report

reported by [email protected]

High-precision timing leak in Long Animation Frame API duration

Flapjack, 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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: The Long Animation Frame (LoAF) API exposes the main thread task duration to JavaScript. When a long task completes without triggering a rendering update, the exposed duration bypasses Chromium’s time-coarsening mitigations. This provides attackers with a high-precision timer that could potentially be leveraged for Cross-Site Leaks (XS-Leaks).

Affected files:

  • third_party/blink/renderer/core/timing/performance_long_animation_frame_timing.cc

Estimated timestamp from git blame: 2024-12-05

Description

The Long Animation Frame (LoAF) API provides information about long tasks on the main thread. To mitigate side-channel timing attacks such as Cross-Site Leaks (XS-Leaks), Chromium requires all web-exposed timing data to be coarsened (clamped) to a specific resolution (typically 100μs, or 5μs in cross-origin isolated contexts).

A potential vulnerability exists in the LoAF implementation where the duration attribute can bypass this clamping mechanism, exposing microsecond-level precision to JavaScript.

Root Cause Analysis

In third_party/blink/renderer/core/timing/performance_long_animation_frame_timing.cc, the PerformanceLongAnimationFrameTiming::Create method computes the entry’s duration:

  double duration = paint_timing_info
                        ? (paint_timing_info->paint_time - startTime)
                        : info->Duration().InMillisecondsF();

When paint_timing_info is absent, the code falls back to info->Duration().InMillisecondsF(). The info->Duration() method returns a base::TimeDelta representing the exact difference between two raw base::TimeTicks (RenderEndTime and FrameStartTime).

Because this raw value is converted directly to a double without passing through Performance::ClampTimeResolution, the resulting value retains uncoarsened microsecond-level precision.

The absence of paint_timing_info occurs when a long task does not schedule a rendering update. This happens in AnimationFrameTimingMonitor::OnTaskCompleted when client_.RequestedMainFramePending() evaluates to false.

Suggested Attacker Steps

Note: Our agent does not run arbitrary code, so these are potential steps detailing how an attacker might exploit this based on static analysis.

  1. An attacker creates a malicious page and embeds a same-site cross-origin victim frame.
  2. The attacker’s JavaScript initiates a synchronous task and triggers a sensitive operation in the victim iframe (e.g., accessing a property that forces a synchronous layout). The execution time will depend on the victim’s cross-origin state.
  3. Within the same task, the attacker’s script executes a busy-wait loop until the total task execution time exceeds 50ms (kLongAnimationFrameDuration).
  4. The attacker ensures no DOM mutations are made in their own frame, keeping RequestedMainFramePending() false and bypassing the rendering update path.
  5. The browser constructs the LoAF entry using the uncoarsened info->Duration() fallback and dispatches it to the attacker’s PerformanceObserver.
  6. The attacker reads the .duration attribute, acquiring a microsecond-precision measurement of the task, allowing them to isolate the victim’s execution time and conduct an XS-Leak.

Suggested Fix

The fallback duration calculation in PerformanceLongAnimationFrameTiming::Create must be properly clamped before being exposed to JavaScript.

A potential fix is to pass the value through the existing clamping utilities:

  double duration = paint_timing_info
                        ? (paint_timing_info->paint_time - startTime)
                        : performance->ClampTimeResolution(info->Duration().InMillisecondsF(), cross_origin_isolated_capability);

Alternatively, duration could be computed as the difference between the clamped end time and the clamped start time to ensure consistency across the API.

Evaluated with Chrome root at commit: b7d0c4d810da1b31400f198c70d9720fc8f0e5a0


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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