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

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactObservable discrepancy in PerformanceAPIs
DescriptionObservable discrepancy in PerformanceAPIs
ComponentPerformanceAPIs
Bug ClassInfo Disclosure
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
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.
Tracker514439436
Fix commit836a8841ea0e (chromium/src) +34/-3
CWECWE-203
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-18 (per vendor advisory)
Fix landed2026-07-15 (public git)
Disclosed2026-08-25
Patch gap41 days — fix public in source before the advisory

Background

Long Animation Frame (LoAF) timing
a Performance API entry type (long-animation-frame, surfaced as PerformanceLongAnimationFrameTiming) that reports main-thread frames exceeding a duration threshold, with startTime and duration readable by page script through a PerformanceObserver.
Time-resolution clamping
Blink coarsens high-resolution timestamps exposed to script (via Performance::ClampTimeResolution and conversions such as MonotonicTimeToDOMHighResTimeStamp) to a granularity that depends on whether the context is cross-origin isolated, to blunt timing side channels.
`cross_origin_isolated_capability`
the flag passed into the clamping helper that selects the finer (cross-origin-isolated) or coarser (non-isolated) resolution for the current context.
No-render LoAF
a long animation frame in which no rendering/paint occurred, so paint_timing_info is null and the duration must come from the frame info itself rather than from a paint timestamp.

Root Cause Analysis

In performance_long_animation_frame_timing.cc, the code that builds a PerformanceLongAnimationFrameTiming entry computes duration in one of two ways (the enclosing function’s name is not shown in the hunk). When paint_timing_info is present, duration is paint_timing_info->paint_time - startTime, where startTime has already been produced by MonotonicTimeToDOMHighResTimeStamp and so is coarsened. When there was no render, however, the removed line used info->Duration().InMillisecondsF() directly, exposing the raw, unclamped base::TimeDelta to script. This violated the invariant that every time value exposed through the Performance APIs is clamped to the context’s permitted resolution, giving a higher-precision timer than the platform otherwise allows.

The fix routes the no-render branch through Performance::ClampTimeResolution(info->Duration(), cross_origin_isolated_capability), so both branches now yield a coarsened duration consistent with the rest of the API.

Key insight
The no-render branch of the LoAF duration computation bypassed time-resolution clamping and returned the full-precision info->Duration(); the fix applies Performance::ClampTimeResolution on that branch with the context’s cross_origin_isolated_capability.

Attack Path

  1. Observe LoAF entries Ordinary page script registers a PerformanceObserver for long-animation-frame, as the shipped WPT test loaf-duration-clamping.html does; no special permission is needed.
  2. Produce a frame with no render Script blocks the main thread long enough to cross the LoAF threshold (the test busy-waits ~60 ms in a timeout task) without triggering a paint, so paint_timing_info is null and the unclamped branch is taken.
  3. Read the high-precision duration Before the fix, entry.duration carried the raw info->Duration() value at sub-clamp precision, which the regression test detects by checking that it is not a multiple of the coarse resolution.
  4. Use as a fine-grained timer An attacker could use this un-coarsened duration as a higher-resolution clock than the clamped performance.now() allows, to measure the timing of operations within the frame for side-channel purposes (inferred).

Impact Assessment

Web content in the renderer could obtain a time measurement finer than Blink’s clamped resolution for its isolation state, an observable timing discrepancy that weakens timer-coarsening defenses against timing side channels. It requires only that the long-animation-frame entry type be available to the page and that a long frame occur without rendering; no compromised renderer is needed. Any concrete cross-origin data leak built on top of this precision is (inferred); the advisory classes it as an observable discrepancy / information disclosure and states no in-the-wild exploitation.

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

Audit Directions

  • Unclamped `base::TimeDelta` exposed to script
    search Performance API entry builders (third_party/blink/renderer/core/timing/) for InMillisecondsF() or similar conversions on durations that reach a DOMHighResTimeStamp without passing through Performance::ClampTimeResolution or MonotonicTimeToDOMHighResTimeStamp.
  • Branch-asymmetric clamping
    where a value is computed from coarsened timestamps on one path and from a raw delta on another (e.g. render vs. no-render, with vs. without paint info), verify every branch applies the same coarsening.
  • Nested timing sub-entries
    check LoAF script attribution and other sub-entry fields (per-script durations, forced-style/layout durations, blocking durations) for the same raw-duration pattern.
From 836a8841ea0e74dbbee30e64a6541b185ec43cdb Mon Sep 17 00:00:00 2001
From: Noam Rosenthal <nrosenthal@chromium.org>
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) <yoavweiss@chromium.org>
Commit-Queue: Noam Rosenthal <nrosenthal@google.com>
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 rj...@google.com

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