CVE-2026-11284
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
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.ccthird_party/blink/renderer/core/timing/performance_long_animation_frame_timing.hthird_party/blink/renderer/core/timing/performance_script_timing.ccthird_party/blink/renderer/core/timing/performance_script_timing.hthird_party/blink/web_tests/external/wpt/long-animation-frame/loaf-script-nested-callback.htmlthird_party/blink/web_tests/external/wpt/long-animation-frame/resources/utils.js
Patch
From caf2ad2c32337953f0d7ff469cbebddc11b29537 Mon Sep 17 00:00:00 2001
From: Yoav Weiss <yoavweiss@chromium.org>
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 <nrosenthal@google.com>
Commit-Queue: Yoav Weiss (@Shopify) <yoavweiss@chromium.org>
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");
Regression Test / PoC
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");
Original Bug Report
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.ccthird_party/blink/renderer/core/frame/animation_frame_timing_monitor.ccthird_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.
- Host a malicious page on
a.example.comthat embeds a child iframe pointing tob.example.com. - In the parent page, register a
PerformanceObserverfor the'long-animation-frame'entry type. (Note: this feature may require enabling experimental web platform features or an Origin Trial token forLongAnimationFrameStyleDuration). - Schedule a
requestAnimationFramecallback in the parent. Inside it, execute a busy-loop for >50ms to force the browser to generate a Long Animation Frame entry. - Simultaneously, trigger a style/layout change in the child iframe (e.g., by resizing the iframe container or having it load a specific payload).
- When the
PerformanceObservercallback fires, inspect thestyleDurationandlayoutDurationproperties on thePerformanceLongAnimationFrameTimingentry. - Observe that the values have microsecond precision (e.g.,
x.xx1ms), 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.