Medium chrome Info Disclosure 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactObservable discrepancy in Animation
DescriptionObservable discrepancy in Animation
ComponentAnimation
Sub-componentRAF
Bug ClassInfo Disclosure
Tracker517429254
Fix commit8c77df3fe6e1 (chromium/src) +102/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/page/page_animator.cc
modified
RecordTimestampCallback
third_party/blink/renderer/core/dom/scripted_animation_controller_test.cc
modified
TEST_F
third_party/blink/renderer/core/dom/scripted_animation_controller_test.cc
modified
for
third_party/blink/renderer/core/dom/scripted_animation_controller_test.cc
modified
if
third_party/blink/renderer/core/dom/scripted_animation_controller_test.cc
modified

Files Changed

  • third_party/blink/renderer/core/page/page_animator.cc
  • third_party/blink/renderer/core/animation/animation_timeline.h
  • third_party/blink/web_tests/fast/animation/request-animation-frame-prefix.html
  • third_party/blink/renderer/core/dom/scripted_animation_controller_test.cc
From 8c77df3fe6e18e9e12c298fd011bf0e4486e6449 Mon Sep 17 00:00:00 2001
From: Vladimir Levin <vmpstr@chromium.org>
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.

R=flackr@chromium.org

Bug: 517429254
Change-Id: Iacbf147d8bd98bbefa1dbc1ee4f13987164d3a3f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8371542
Commit-Queue: Vladimir Levin <vmpstr@chromium.org>
Reviewed-by: Robert Flack <flackr@chromium.org>
Reviewed-by: Mason Freed <masonf@chromium.org>
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();
       });
Loading diff…

Regression Test / PoC

shipped with the fix
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();
       });
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cc
  • third_party/blink/renderer/core/dom/frame_request_callback_collection.cc
  • third_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:

  1. Web content registers a frame callback using the exposed legacy API:
    window.webkitRequestAnimationFrame(callback);
    
  2. In LocalDOMWindow::webkitRequestAnimationFrame, the callback is registered with the legacy flag set to true (frame_callback->SetUseLegacyTimeBase(true)).
  3. During the next frame, PageAnimator::ServiceScriptedAnimations calculates the uncoarsened pseudo-wall-time and stores it in current_frame_legacy_time_ms_ on the ScriptedAnimationController.
  4. 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.

View on issue tracker