CVE-2026-106180
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/page/page_animator.cc |
modified | |
RecordTimestampCallbackthird_party/blink/renderer/core/dom/scripted_animation_controller_test.cc |
modified | |
TEST_Fthird_party/blink/renderer/core/dom/scripted_animation_controller_test.cc |
modified | |
forthird_party/blink/renderer/core/dom/scripted_animation_controller_test.cc |
modified | |
ifthird_party/blink/renderer/core/dom/scripted_animation_controller_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/page/page_animator.ccthird_party/blink/renderer/core/animation/animation_timeline.hthird_party/blink/web_tests/fast/animation/request-animation-frame-prefix.htmlthird_party/blink/renderer/core/dom/scripted_animation_controller_test.cc
Patch
From 8c77df3fe6e18e9e12c298fd011bf0e4486e6449 Mon Sep 17 00:00:00 2001 From: Vladimir Levin <[email protected]> Date: Wed, 09 Sep 2026 14:13:42 -0700 Subject: [PATCH] rAF: Use coarse time for webkitRequestAnimationFrame. Ensure that when webkitRequestAnimationFrame is used, the time is coarsed to prevent timing attacks. [email protected] Bug: 517429254 Change-Id: Iacbf147d8bd98bbefa1dbc1ee4f13987164d3a3f Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8371542 Commit-Queue: Vladimir Levin <[email protected]> Reviewed-by: Robert Flack <[email protected]> Reviewed-by: Mason Freed <[email protected]> Cr-Commit-Position: refs/heads/main@{#1694867} --- diff --git a/third_party/blink/renderer/core/animation/animation_timeline.h b/third_party/blink/renderer/core/animation/animation_timeline.h index f2d77c91..5652022 100644 --- a/third_party/blink/renderer/core/animation/animation_timeline.h +++ b/third_party/blink/renderer/core/animation/animation_timeline.h @@ -44,6 +44,9 @@ } std::optional<AnimationTimeDelta> CurrentTime(); + std::optional<base::TimeDelta> CurrentTimeDelta() { + return CurrentTimeInternal(); + } std::optional<double> CurrentTimeMilliseconds(); std::optional<double> CurrentTimeSeconds(); diff --git a/third_party/blink/renderer/core/page/page_animator.cc b/third_party/blink/renderer/core/page/page_animator.cc index e59e059..0a52200 100644 --- a/third_party/blink/renderer/core/page/page_animator.cc +++ b/third_party/blink/renderer/core/page/page_animator.cc @@ -137,11 +137,14 @@ continue; } - controller->SetCurrentFrameTimeMs( - window->document()->Timeline().CurrentTimeMilliseconds().value()); + base::TimeDelta document_timeline_time = + window->document()->Timeline().CurrentTimeDelta().value(); + controller->SetCurrentFrameTimeMs(document_timeline_time.InMillisecondsF()); controller->SetCurrentFrameLegacyTimeMs( loader->GetTiming() - .MonotonicTimeToPseudoWallTime(monotonic_time_now) + .MonotonicTimeToPseudoWallTime( + window->document()->Timeline().CalculateZeroTime() + + document_timeline_time) .InMillisecondsF()); if (can_throttle) { continue; diff --git a/third_party/blink/renderer/core/dom/scripted_animation_controller_test.cc b/third_party/blink/renderer/core/dom/scripted_animation_controller_test.cc index b5473fe..7fcfb66 100644 --- a/third_party/blink/renderer/core/dom/scripted_animation_controller_test.cc +++ b/third_party/blink/renderer/core/dom/scripted_animation_controller_test.cc @@ -4,10 +4,13 @@ #include "third_party/blink/renderer/core/dom/scripted_animation_controller.h" +#include <cmath> #include <memory> +#include "base/memory/raw_ptr.h" #include "testing/gmock/include/gmock/gmock.h" #include "testing/gtest/include/gtest/gtest.h" +#include "third_party/blink/renderer/core/animation/document_timeline.h" #include "third_party/blink/renderer/core/dom/document.h" #include "third_party/blink/renderer/core/dom/events/event.h" #include "third_party/blink/renderer/core/dom/events/event_target.h" @@ -15,8 +18,11 @@ #include "third_party/blink/renderer/core/dom/frame_request_callback_collection.h" #include "third_party/blink/renderer/core/execution_context/execution_context.h" #include "third_party/blink/renderer/core/frame/local_frame.h" +#include "third_party/blink/renderer/core/loader/document_loader.h" +#include "third_party/blink/renderer/core/page/page.h" #include "third_party/blink/renderer/core/page/page_animator.h" #include "third_party/blink/renderer/core/testing/dummy_page_holder.h" +#include "third_party/blink/renderer/core/timing/time_clamper.h" #include "third_party/blink/renderer/platform/heap/garbage_collected.h" #include "third_party/blink/renderer/platform/testing/task_environment.h" #include "third_party/blink/renderer/platform/wtf/allocator/allocator.h" @@ -28,6 +34,7 @@ protected: void SetUp() override; + Page& GetPage() const { return dummy_page_holder_->GetPage(); } Document& GetDocument() const { return dummy_page_holder_->GetDocument(); } ScriptedAnimationController& Controller() { return *controller_; } @@ -64,6 +71,16 @@ Vector<int> order_; }; +class RecordTimestampCallback final : public FrameCallback { + public: + explicit RecordTimestampCallback(double* recorded_time) + : recorded_time_(recorded_time) {} + void Invoke(double timestamp) override { *recorded_time_ = timestamp; } + + private: + raw_ptr<double> recorded_time_; +}; + } // anonymous namespace TEST_F(ScriptedAnimationControllerTest, EnqueueOneTask) { @@ -295,4 +312,74 @@ EXPECT_EQ(2, observer.Order()[0]); } +TEST_F(ScriptedAnimationControllerTest, CoarsenedFrameTimestamps) { + ScriptedAnimationController& document_controller = + GetDocument().GetScriptedAnimationController(); + + base::TimeTicks zero_time = GetDocument().Timeline().CalculateZeroTime(); + double reference_wall_time_ms = GetDocument() + .Loader() + ->GetTiming() + .MonotonicTimeToPseudoWallTime(zero_time) + .InMillisecondsF(); + + double previous_standard_time = -1.0; + double previous_legacy_time = -1.0; + + for (int frame = 0; frame < 5; ++frame) { + double standard_time = -1.0; + double legacy_time = -1.0; + + auto* standard_cb = + MakeGarbageCollected<RecordTimestampCallback>(&standard_time); + standard_cb->SetUseLegacyTimeBase(false); + document_controller.RegisterFrameCallback(standard_cb, + FrameCallbackType::kWebExposed); + + auto* legacy_cb = + MakeGarbageCollected<RecordTimestampCallback>(&legacy_time); + legacy_cb->SetUseLegacyTimeBase(true); + document_controller.RegisterFrameCallback(legacy_cb, + FrameCallbackType::kWebExposed); + + // Frame 0 tests zero time (document_timeline_time_ms == 0). Subsequent + // frames provide non-aligned microsecond intervals: 16ms simulates a ~60Hz + // frame interval, while (13 * frame + 7) microseconds adds prime-stepping + // sub-100us jitter (7us, 20us, 33us, 46us, 59us) that is never a multiple + // of kCoarseResolutionMicroseconds (100us), ensuring TimeClamper actively + // coarsens every frame timestamp. + base::TimeTicks frame_time = frame == 0 + ? zero_time + : zero_time + base::Seconds(1) + + base::Milliseconds(16 * frame) + + base::Microseconds(13 * frame + 7); + GetPage().Animator().ServiceScriptedAnimations(frame_time); + + EXPECT_GE(standard_time, 0.0); + EXPECT_GE(legacy_time, reference_wall_time_ms); + EXPECT_NEAR(legacy_time, reference_wall_time_ms + standard_time, 0.001); + + if (frame > 0) { + double standard_delta = standard_time - previous_standard_time; + double legacy_delta = legacy_time - previous_legacy_time; + + EXPECT_NEAR(legacy_delta, standard_delta, 0.001); + + int64_t standard_delta_us = + static_cast<int64_t>(std::round(standard_delta * 1000.0)); + int64_t legacy_delta_us = + static_cast<int64_t>(std::round(legacy_delta * 1000.0)); + + EXPECT_EQ(legacy_delta_us, standard_delta_us); + EXPECT_EQ(standard_delta_us % TimeClamper::kCoarseResolutionMicroseconds, + 0); + EXPECT_EQ(legacy_delta_us % TimeClamper::kCoarseResolutionMicroseconds, + 0); + } + + previous_standard_time = standard_time; + previous_legacy_time = legacy_time; + } +} + } // namespace blink diff --git a/third_party/blink/web_tests/fast/animation/request-animation-frame-prefix.html b/third_party/blink/web_tests/fast/animation/request-animation-frame-prefix.html index 27b9237..f1628f2 100644 --- a/third_party/blink/web_tests/fast/animation/request-animation-frame-prefix.html +++ b/third_party/blink/web_tests/fast/animation/request-animation-frame-prefix.html @@ -34,7 +34,12 @@ assert_greater_than_equal(legacyFirstTimestamp, firstTimestamp); assert_greater_than_equal(legacySecondTimestamp, secondTimestamp); deltaError = Math.abs((legacySecondTimestamp - legacyFirstTimestamp) - (secondTimestamp - firstTimestamp)); - assert_less_than(deltaError, 0.3); + assert_less_than(deltaError, 0.001); + var legacyDelta = legacySecondTimestamp - legacyFirstTimestamp; + // Matches TimeClamper::kCoarseResolutionMicroseconds (100us): + const kCoarseResolutionMicroseconds = 100; + var residue = Math.round(legacyDelta * 1000) % kCoarseResolutionMicroseconds; + assert_equals(residue, 0); }); t.done(); });
Regression Test / PoC
diff --git a/third_party/blink/renderer/core/dom/scripted_animation_controller_test.cc b/third_party/blink/renderer/core/dom/scripted_animation_controller_test.cc
index b5473fe..7fcfb66 100644
--- a/third_party/blink/renderer/core/dom/scripted_animation_controller_test.cc
+++ b/third_party/blink/renderer/core/dom/scripted_animation_controller_test.cc
@@ -4,10 +4,13 @@
#include "third_party/blink/renderer/core/dom/scripted_animation_controller.h"
+#include <cmath>
#include <memory>
+#include "base/memory/raw_ptr.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/renderer/core/animation/document_timeline.h"
#include "third_party/blink/renderer/core/dom/document.h"
#include "third_party/blink/renderer/core/dom/events/event.h"
#include "third_party/blink/renderer/core/dom/events/event_target.h"
@@ -15,8 +18,11 @@
#include "third_party/blink/renderer/core/dom/frame_request_callback_collection.h"
#include "third_party/blink/renderer/core/execution_context/execution_context.h"
#include "third_party/blink/renderer/core/frame/local_frame.h"
+#include "third_party/blink/renderer/core/loader/document_loader.h"
+#include "third_party/blink/renderer/core/page/page.h"
#include "third_party/blink/renderer/core/page/page_animator.h"
#include "third_party/blink/renderer/core/testing/dummy_page_holder.h"
+#include "third_party/blink/renderer/core/timing/time_clamper.h"
#include "third_party/blink/renderer/platform/heap/garbage_collected.h"
#include "third_party/blink/renderer/platform/testing/task_environment.h"
#include "third_party/blink/renderer/platform/wtf/allocator/allocator.h"
@@ -28,6 +34,7 @@
protected:
void SetUp() override;
+ Page& GetPage() const { return dummy_page_holder_->GetPage(); }
Document& GetDocument() const { return dummy_page_holder_->GetDocument(); }
ScriptedAnimationController& Controller() { return *controller_; }
@@ -64,6 +71,16 @@
Vector<int> order_;
};
+class RecordTimestampCallback final : public FrameCallback {
+ public:
+ explicit RecordTimestampCallback(double* recorded_time)
+ : recorded_time_(recorded_time) {}
+ void Invoke(double timestamp) override { *recorded_time_ = timestamp; }
+
+ private:
+ raw_ptr<double> recorded_time_;
+};
+
} // anonymous namespace
TEST_F(ScriptedAnimationControllerTest, EnqueueOneTask) {
@@ -295,4 +312,74 @@
EXPECT_EQ(2, observer.Order()[0]);
}
+TEST_F(ScriptedAnimationControllerTest, CoarsenedFrameTimestamps) {
+ ScriptedAnimationController& document_controller =
+ GetDocument().GetScriptedAnimationController();
+
+ base::TimeTicks zero_time = GetDocument().Timeline().CalculateZeroTime();
+ double reference_wall_time_ms = GetDocument()
+ .Loader()
+ ->GetTiming()
+ .MonotonicTimeToPseudoWallTime(zero_time)
+ .InMillisecondsF();
+
+ double previous_standard_time = -1.0;
+ double previous_legacy_time = -1.0;
+
+ for (int frame = 0; frame < 5; ++frame) {
+ double standard_time = -1.0;
+ double legacy_time = -1.0;
+
+ auto* standard_cb =
+ MakeGarbageCollected<RecordTimestampCallback>(&standard_time);
+ standard_cb->SetUseLegacyTimeBase(false);
+ document_controller.RegisterFrameCallback(standard_cb,
+ FrameCallbackType::kWebExposed);
+
+ auto* legacy_cb =
+ MakeGarbageCollected<RecordTimestampCallback>(&legacy_time);
+ legacy_cb->SetUseLegacyTimeBase(true);
+ document_controller.RegisterFrameCallback(legacy_cb,
+ FrameCallbackType::kWebExposed);
+
+ // Frame 0 tests zero time (document_timeline_time_ms == 0). Subsequent
+ // frames provide non-aligned microsecond intervals: 16ms simulates a ~60Hz
+ // frame interval, while (13 * frame + 7) microseconds adds prime-stepping
+ // sub-100us jitter (7us, 20us, 33us, 46us, 59us) that is never a multiple
+ // of kCoarseResolutionMicroseconds (100us), ensuring TimeClamper actively
+ // coarsens every frame timestamp.
+ base::TimeTicks frame_time = frame == 0
+ ? zero_time
+ : zero_time + base::Seconds(1) +
+ base::Milliseconds(16 * frame) +
+ base::Microseconds(13 * frame + 7);
+ GetPage().Animator().ServiceScriptedAnimations(frame_time);
+
+ EXPECT_GE(standard_time, 0.0);
+ EXPECT_GE(legacy_time, reference_wall_time_ms);
+ EXPECT_NEAR(legacy_time, reference_wall_time_ms + standard_time, 0.001);
+
+ if (frame > 0) {
+ double standard_delta = standard_time - previous_standard_time;
+ double legacy_delta = legacy_time - previous_legacy_time;
+
+ EXPECT_NEAR(legacy_delta, standard_delta, 0.001);
+
+ int64_t standard_delta_us =
+ static_cast<int64_t>(std::round(standard_delta * 1000.0));
+ int64_t legacy_delta_us =
+ static_cast<int64_t>(std::round(legacy_delta * 1000.0));
+
+ EXPECT_EQ(legacy_delta_us, standard_delta_us);
+ EXPECT_EQ(standard_delta_us % TimeClamper::kCoarseResolutionMicroseconds,
+ 0);
+ EXPECT_EQ(legacy_delta_us % TimeClamper::kCoarseResolutionMicroseconds,
+ 0);
+ }
+
+ previous_standard_time = standard_time;
+ previous_legacy_time = legacy_time;
+ }
+}
+
} // namespace blink
diff --git a/third_party/blink/web_tests/fast/animation/request-animation-frame-prefix.html b/third_party/blink/web_tests/fast/animation/request-animation-frame-prefix.html
index 27b9237..f1628f2 100644
--- a/third_party/blink/web_tests/fast/animation/request-animation-frame-prefix.html
+++ b/third_party/blink/web_tests/fast/animation/request-animation-frame-prefix.html
@@ -34,7 +34,12 @@
assert_greater_than_equal(legacyFirstTimestamp, firstTimestamp);
assert_greater_than_equal(legacySecondTimestamp, secondTimestamp);
deltaError = Math.abs((legacySecondTimestamp - legacyFirstTimestamp) - (secondTimestamp - firstTimestamp));
- assert_less_than(deltaError, 0.3);
+ assert_less_than(deltaError, 0.001);
+ var legacyDelta = legacySecondTimestamp - legacyFirstTimestamp;
+ // Matches TimeClamper::kCoarseResolutionMicroseconds (100us):
+ const kCoarseResolutionMicroseconds = 100;
+ var residue = Math.round(legacyDelta * 1000) % kCoarseResolutionMicroseconds;
+ assert_equals(residue, 0);
});
t.done();
});
Original Bug Report
TimeClamper resolution coarsening bypass via webkitRequestAnimationFrame
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: The legacy vendor-prefixed window.webkitRequestAnimationFrame API delivers uncoarsened, high-resolution timestamps to JavaScript callbacks. This bypasses the standard TimeClamper resolution coarsening (which limits standard requestAnimationFrame to 100 microseconds). Consequently, this exposes a potential side-channel timing mechanism within the renderer process.
Affected files:
third_party/blink/renderer/core/page/page_animator.ccthird_party/blink/renderer/core/dom/frame_request_callback_collection.ccthird_party/blink/renderer/core/loader/document_load_timing.cc
Estimated timestamp from git blame: 2023-10-25
Root Cause Analysis
In PageAnimator::ServiceScriptedAnimations (third_party/blink/renderer/core/page/page_animator.cc), two frame timestamps are computed. The standard, non-prefixed requestAnimationFrame path is explicitly coarsened via TimeClamper (typically to 100 microseconds) to mitigate high-resolution timer leaks (a core defense-in-depth mitigation against microarchitectural timing attacks like Spectre).
However, the legacy prefixed webkitRequestAnimationFrame path does not apply clamping or quantization before passing the timestamp to the callback. Specifically, in third_party/blink/renderer/core/page/page_animator.cc:
controller->SetCurrentFrameLegacyTimeMs(
loader->GetTiming()
.MonotonicTimeToPseudoWallTime(monotonic_time_now)
.InMillisecondsF());
MonotonicTimeToPseudoWallTime calculates the time using a constant offset captured at navigation start without any quantization or clamping:
base::TimeDelta DocumentLoadTiming::MonotonicTimeToPseudoWallTime(
base::TimeTicks monotonic_time) const {
if (monotonic_time.is_null() || reference_monotonic_time_.is_null())
return base::TimeDelta();
return monotonic_time + reference_wall_time_ - reference_monotonic_time_;
}
Because reference_wall_time_ and reference_monotonic_time_ are constants captured raw at navigation start, the output preserves the full sub-millisecond precision (up to 1-microsecond resolution on most platforms) of base::TimeTicks (shifted by a constant offset).
Potential Trigger Path
Because our automated analysis tools do not have the capability to execute code, the following is a potential/suggested trigger sequence based on a static analysis of the codebase:
- Web content registers a frame callback using the exposed legacy API:
window.webkitRequestAnimationFrame(callback); - In
LocalDOMWindow::webkitRequestAnimationFrame, the callback is registered with the legacy flag set to true (frame_callback->SetUseLegacyTimeBase(true)). - During the next frame,
PageAnimator::ServiceScriptedAnimationscalculates the uncoarsened pseudo-wall-time and stores it incurrent_frame_legacy_time_ms_on theScriptedAnimationController. - In
FrameRequestCallbackCollection::ExecuteFrameCallbacks(third_party/blink/renderer/core/dom/frame_request_callback_collection.cc), the collection checks the legacy flag and passes the raw uncoarsened timestamp straight to JavaScript:if (callback->GetUseLegacyTimeBase()) callback->Invoke(high_res_now_ms_legacy); // Uncoarsened legacy timestamp else callback->Invoke(high_res_now_ms); // Clamped/coarsened timestamp
This behavior potentially allows a page to recover the main-thread frame ticks with sub-millisecond precision, bypassing the intended TimeClamper constraints.
Suggested Fix
Apply time-coarsening / clamping to the legacy timing base similarly to the standard timeline path. In PageAnimator::ServiceScriptedAnimations, clamp monotonic_time_now before passing it to MonotonicTimeToPseudoWallTime, or align current_frame_legacy_time_ms_ with the clamped current_frame_time_ms_ if the legacy offset is not strictly required. Alternatively, deprecate and completely phase out the legacy webkitRequestAnimationFrame API.
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
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.