CVE-2026-78955
Overview
Background
- Long Animation Frame (LoAF) timing
- a Performance API entry type (
long-animation-frame, surfaced asPerformanceLongAnimationFrameTiming) that reports main-thread frames exceeding a duration threshold, withstartTimeanddurationreadable by page script through aPerformanceObserver. - Time-resolution clamping
- Blink coarsens high-resolution timestamps exposed to script (via
Performance::ClampTimeResolutionand conversions such asMonotonicTimeToDOMHighResTimeStamp) 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_infois 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.
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
- Observe LoAF entries
Ordinary page script registers a
PerformanceObserverforlong-animation-frame, as the shipped WPT testloaf-duration-clamping.htmldoes; no special permission is needed. - 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_infois null and the unclamped branch is taken. - Read the high-precision duration
Before the fix,
entry.durationcarried the rawinfo->Duration()value at sub-clamp precision, which the regression test detects by checking that it is not a multiple of the coarse resolution. - 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
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.ccthird_party/blink/web_tests/external/wpt/long-animation-frame/loaf-duration-clamping.html
Audit Directions
- Unclamped `base::TimeDelta` exposed to scriptsearch Performance API entry builders (
third_party/blink/renderer/core/timing/) forInMillisecondsF()or similar conversions on durations that reach aDOMHighResTimeStampwithout passing throughPerformance::ClampTimeResolutionorMonotonicTimeToDOMHighResTimeStamp. - Branch-asymmetric clampingwhere 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-entriescheck LoAF script attribution and other sub-entry fields (per-script durations, forced-style/layout durations, blocking durations) for the same raw-duration pattern.
Patch
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>
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.