CVE-2026-78955
Overview
Files Changed
third_party/blink/renderer/core/timing/performance_long_animation_frame_timing.ccthird_party/blink/web_tests/external/wpt/long-animation-frame/loaf-duration-clamping.html
Patch
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>
Regression Test / PoC
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>
Original Bug Report
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.
- An attacker creates a malicious page and embeds a same-site cross-origin victim frame.
- 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.
- Within the same task, the attacker’s script executes a busy-wait loop until the total task execution time exceeds 50ms (
kLongAnimationFrameDuration). - The attacker ensures no DOM mutations are made in their own frame, keeping
RequestedMainFramePending()false and bypassing the rendering update path. - The browser constructs the LoAF entry using the uncoarsened
info->Duration()fallback and dispatches it to the attacker’sPerformanceObserver. - The attacker reads the
.durationattribute, 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.