Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactSide-channel information leakage in WebCodecs
DescriptionSide-channel information leakage in WebCodecs
ComponentWebCodecs
Bug ClassLogic Error
Tracker519610845
Fix commit211cf780d869 (chromium/src) +193/-127
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/modules/breakout_box/media_stream_video_track_underlying_source_test.cc
modified
MediaStreamVideoTrackUnderlyingSourceLeaseTest
third_party/blink/renderer/modules/breakout_box/media_stream_video_track_underlying_source_test.cc
modified

Files Changed

  • third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.cc
  • third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.h
  • third_party/blink/renderer/modules/breakout_box/media_stream_video_track_underlying_source_test.cc
  • third_party/blink/renderer/modules/webcodecs/image_decoder_external.cc
From 211cf780d86979929937ef73b6a27f13b9ea8e70 Mon Sep 17 00:00:00 2001
From: Dale Curtis <dalecurtis@chromium.org>
Date: Thu, 04 Jun 2026 15:48:08 -0700
Subject: [PATCH] Rework how capture timestamps are set on blink::VideoFrame. Clamp.

This moves all the capture timestamp logic to the only place which
cares about it (FrameQueueUnderlyingSource) and adds clamping.

Fixed: 519610845
Change-Id: I9d2f78accbc173e682e4b94965d3acb8763c3aa5
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7900468
Reviewed-by: Thomas Guilbert <tguilbert@chromium.org>
Commit-Queue: Dale Curtis <dalecurtis@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1641994}
---

diff --git a/third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.cc b/third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.cc
index 5909e78a..e444578 100644
--- a/third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.cc
+++ b/third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.cc
@@ -15,6 +15,7 @@
 #include "third_party/blink/renderer/core/dom/dom_exception.h"
 #include "third_party/blink/renderer/core/execution_context/execution_context.h"
 #include "third_party/blink/renderer/core/streams/readable_stream_default_controller_with_script_scope.h"
+#include "third_party/blink/renderer/core/timing/time_clamper.h"
 #include "third_party/blink/renderer/modules/webcodecs/audio_data.h"
 #include "third_party/blink/renderer/modules/webcodecs/video_frame.h"
 #include "third_party/blink/renderer/modules/webcodecs/video_frame_monitor.h"
@@ -522,12 +523,24 @@
       media_frame->timestamp(), "rt",
       media_frame->metadata().reference_time.value_or(base::TimeTicks()), "cbt",
       media_frame->metadata().capture_begin_time.value_or(base::TimeTicks()));
-  return MakeGarbageCollected<VideoFrame>(
-      std::move(media_frame), GetExecutionContext(), device_id_,
-      /*sk_image=*/nullptr,
-      /*prefer_capture_timestamp=*/
-      base::FeatureList::IsEnabled(
-          kBreakoutBoxPreferCaptureTimestampInVideoFrames));
+
+  // Timestamps emitted to the page must have a clamped resolution.
+  auto* ec = GetExecutionContext();
+  auto timestamp = media_frame->timestamp();
+  if (base::FeatureList::IsEnabled(
+          kBreakoutBoxPreferCaptureTimestampInVideoFrames)) {
+    if (auto cbt = media_frame->metadata().capture_begin_time) {
+      timestamp = time_clamper_.ClampTimeResolution(
+          *cbt - base::TimeTicks(), ec->CrossOriginIsolatedCapability());
+    } else if (auto rt = media_frame->metadata().reference_time) {
+      timestamp = time_clamper_.ClampTimeResolution(
+          *rt - base::TimeTicks(), ec->CrossOriginIsolatedCapability());
+    }
+  }
+
+  return MakeGarbageCollected<VideoFrame>(std::move(media_frame), ec,
+                                          device_id_,
+                                          /*sk_image=*/nullptr, timestamp);
 }
 
 template <>
diff --git a/third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.h b/third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.h
index 47897df..b250d68 100644
--- a/third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.h
+++ b/third_party/blink/renderer/modules/breakout_box/frame_queue_underlying_source.h
@@ -13,6 +13,7 @@
 #include "media/base/audio_buffer.h"
 #include "media/base/video_frame.h"
 #include "third_party/blink/renderer/core/streams/underlying_source_base.h"
+#include "third_party/blink/renderer/core/timing/time_clamper.h"
 #include "third_party/blink/renderer/modules/breakout_box/frame_queue.h"
 #include "third_party/blink/renderer/modules/modules_export.h"
 #include "third_party/blink/renderer/platform/heap/cross_thread_persistent.h"
@@ -180,6 +181,7 @@
   bool realm_is_boostable_context_;
 
   std::optional<base::TimeTicks> first_frame_ticks_;
+  TimeClamper time_clamper_;
 };
 
 template <>
diff --git a/third_party/blink/renderer/modules/breakout_box/media_stream_video_track_underlying_source_test.cc b/third_party/blink/renderer/modules/breakout_box/media_stream_video_track_underlying_source_test.cc
index 01a90873..433968b 100644
--- a/third_party/blink/renderer/modules/breakout_box/media_stream_video_track_underlying_source_test.cc
+++ b/third_party/blink/renderer/modules/breakout_box/media_stream_video_track_underlying_source_test.cc
@@ -80,12 +80,8 @@
   }
 
   MediaStreamTrack* CreateTrack(ExecutionContext* execution_context) {
-    return MakeGarbageCollected<MediaStreamTrackImpl>(
-        execution_context,
-        MediaStreamVideoTrack::CreateVideoTrack(
-            pushable_video_source_,
-            MediaStreamVideoSource::ConstraintsOnceCallback(),
-            /*enabled=*/true));
+    return CreateVideoMediaStreamTrack(execution_context,
+                                       pushable_video_source_);
   }
 
   MediaStreamVideoTrackUnderlyingSource* CreateSource(ScriptState* script_state,
@@ -111,15 +107,19 @@
     base::RunLoop().RunUntilIdle();
   }
 
-  void PushFrame(
-      const std::optional<base::TimeDelta>& timestamp = std::nullopt) {
+  void PushFrame(scoped_refptr<media::VideoFrame> frame,
+                 base::TimeTicks estimated_capture_time = base::TimeTicks()) {
+    pushable_video_source_->PushFrame(std::move(frame), estimated_capture_time);
+    RunIOUntilIdle();
+  }
+
+  void PushFrame(std::optional<base::TimeDelta> timestamp = std::nullopt) {
     const scoped_refptr<media::VideoFrame> frame =
         media::VideoFrame::CreateBlackFrame(gfx::Size(10, 5));
     if (timestamp) {
       frame->set_timestamp(*timestamp);
     }
-    pushable_video_source_->PushFrame(frame, base::TimeTicks());
-    RunIOUntilIdle();
+    PushFrame(std::move(frame));
   }
 
   static MediaStreamSource* CreateDevicePushableSource(
@@ -658,6 +658,73 @@
   track->stopTrack(v8_scope.GetExecutionContext());
 }
 
+TEST_F(MediaStreamVideoTrackUnderlyingSourceTest,
+       VideoFrameTimestampIsClamped) {
+  V8TestingScope v8_scope;
+  ScriptState* script_state = v8_scope.GetScriptState();
+  auto* track = CreateTrack(v8_scope.GetExecutionContext());
+  auto* source = CreateSource(script_state, track);
+  auto* stream =
+      ReadableStream::CreateWithCountQueueingStrategy(script_state, source, 0);
+
+  NonThrowableExceptionState exception_state;
+  auto* reader =
+      stream->GetDefaultReaderForTesting(script_state, exception_state);
+
+  // Use timestamps that are not multiples of the coarse resolution (100us).
+  const base::TimeDelta kUnclampedTimestamp1 = base::Microseconds(123456);
+  const base::TimeDelta kUnclampedTimestamp2 = base::Microseconds(234567);
+  const base::TimeDelta kUnclampedTimestamp3 = base::Microseconds(345678);
+  const base::TimeDelta kUnclampedTimestamp4 = base::Microseconds(456789);
+
+  ASSERT_FALSE(v8_scope.GetExecutionContext()->CrossOriginIsolatedCapability());
+  int resolution = TimeClamper::kCoarseResolutionMicroseconds;
+
+  // Frame 1: capture_begin_time only
+  auto video_frame1 = media::VideoFrame::CreateBlackFrame(gfx::Size(10, 10));
+  video_frame1->metadata().capture_begin_time =
+      base::TimeTicks() + kUnclampedTimestamp1;
+  PushFrame(std::move(video_frame1), base::TimeTicks::Now());
+
+  VideoFrame* web_video_frame1 =
+      ReadObjectFromStream<VideoFrame>(v8_scope, reader);
+  int64_t exposed_timestamp1 = web_video_frame1->timestamp();
+  EXPECT_EQ(exposed_timestamp1 % resolution, 0);
+  EXPECT_NE(exposed_timestamp1, kUnclampedTimestamp1.InMicroseconds());
+
+  // Frame 2: reference_time only
+  auto video_frame2 = media::VideoFrame::CreateBlackFrame(gfx::Size(10, 10));
+  video_frame2->metadata().reference_time =
+      base::TimeTicks() + kUnclampedTimestamp2;
+  PushFrame(std::move(video_frame2), base::TimeTicks::Now());
+
+  VideoFrame* web_video_frame2 =
+      ReadObjectFromStream<VideoFrame>(v8_scope, reader);
+  int64_t exposed_timestamp2 = web_video_frame2->timestamp();
+  EXPECT_EQ(exposed_timestamp2 % resolution, 0);
+  EXPECT_NE(exposed_timestamp2, kUnclampedTimestamp2.InMicroseconds());
+
+  // Frame 3: both set (should prefer capture_begin_time)
+  auto video_frame3 = media::VideoFrame::CreateBlackFrame(gfx::Size(10, 10));
+  video_frame3->metadata().capture_begin_time =
+      base::TimeTicks() + kUnclampedTimestamp3;
+  video_frame3->metadata().reference_time =
+      base::TimeTicks() + kUnclampedTimestamp4;
+  PushFrame(std::move(video_frame3), base::TimeTicks::Now());
+
+  VideoFrame* web_video_frame3 =
+      ReadObjectFromStream<VideoFrame>(v8_scope, reader);
+  int64_t exposed_timestamp3 = web_video_frame3->timestamp();
+  EXPECT_EQ(exposed_timestamp3 % resolution, 0);
+  EXPECT_NE(exposed_timestamp3, kUnclampedTimestamp3.InMicroseconds());
+  // It should be close to kUnclampedTimestamp3, not kUnclampedTimestamp4.
+  EXPECT_GT(kUnclampedTimestamp4.InMicroseconds() - exposed_timestamp3,
+            resolution * 2);
+
+  source->Close();
+  track->stopTrack(v8_scope.GetExecutionContext());
+}
+
 class MediaStreamVideoTrackUnderlyingSourceLeaseTest
     : public testing::Test,
       public testing::WithParamInterface<std::tuple<bool, bool, bool>> {
diff --git a/third_party/blink/renderer/modules/webcodecs/image_decoder_external.cc b/third_party/blink/renderer/modules/webcodecs/image_decoder_external.cc
index 2806eed..fb495f1 100644
--- a/third_party/blink/renderer/modules/webcodecs/image_decoder_external.cc
+++ b/third_party/blink/renderer/modules/webcodecs/image_decoder_external.cc
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/modules/breakout_box/media_stream_video_track_underlying_source_test.cc b/third_party/blink/renderer/modules/breakout_box/media_stream_video_track_underlying_source_test.cc
index 01a90873..433968b 100644
--- a/third_party/blink/renderer/modules/breakout_box/media_stream_video_track_underlying_source_test.cc
+++ b/third_party/blink/renderer/modules/breakout_box/media_stream_video_track_underlying_source_test.cc
@@ -80,12 +80,8 @@
   }
 
   MediaStreamTrack* CreateTrack(ExecutionContext* execution_context) {
-    return MakeGarbageCollected<MediaStreamTrackImpl>(
-        execution_context,
-        MediaStreamVideoTrack::CreateVideoTrack(
-            pushable_video_source_,
-            MediaStreamVideoSource::ConstraintsOnceCallback(),
-            /*enabled=*/true));
+    return CreateVideoMediaStreamTrack(execution_context,
+                                       pushable_video_source_);
   }
 
   MediaStreamVideoTrackUnderlyingSource* CreateSource(ScriptState* script_state,
@@ -111,15 +107,19 @@
     base::RunLoop().RunUntilIdle();
   }
 
-  void PushFrame(
-      const std::optional<base::TimeDelta>& timestamp = std::nullopt) {
+  void PushFrame(scoped_refptr<media::VideoFrame> frame,
+                 base::TimeTicks estimated_capture_time = base::TimeTicks()) {
+    pushable_video_source_->PushFrame(std::move(frame), estimated_capture_time);
+    RunIOUntilIdle();
+  }
+
+  void PushFrame(std::optional<base::TimeDelta> timestamp = std::nullopt) {
     const scoped_refptr<media::VideoFrame> frame =
         media::VideoFrame::CreateBlackFrame(gfx::Size(10, 5));
     if (timestamp) {
       frame->set_timestamp(*timestamp);
     }
-    pushable_video_source_->PushFrame(frame, base::TimeTicks());
-    RunIOUntilIdle();
+    PushFrame(std::move(frame));
   }
 
   static MediaStreamSource* CreateDevicePushableSource(
@@ -658,6 +658,73 @@
   track->stopTrack(v8_scope.GetExecutionContext());
 }
 
+TEST_F(MediaStreamVideoTrackUnderlyingSourceTest,
+       VideoFrameTimestampIsClamped) {
+  V8TestingScope v8_scope;
+  ScriptState* script_state = v8_scope.GetScriptState();
+  auto* track = CreateTrack(v8_scope.GetExecutionContext());
+  auto* source = CreateSource(script_state, track);
+  auto* stream =
+      ReadableStream::CreateWithCountQueueingStrategy(script_state, source, 0);
+
+  NonThrowableExceptionState exception_state;
+  auto* reader =
+      stream->GetDefaultReaderForTesting(script_state, exception_state);
+
+  // Use timestamps that are not multiples of the coarse resolution (100us).
+  const base::TimeDelta kUnclampedTimestamp1 = base::Microseconds(123456);
+  const base::TimeDelta kUnclampedTimestamp2 = base::Microseconds(234567);
+  const base::TimeDelta kUnclampedTimestamp3 = base::Microseconds(345678);
+  const base::TimeDelta kUnclampedTimestamp4 = base::Microseconds(456789);
+
+  ASSERT_FALSE(v8_scope.GetExecutionContext()->CrossOriginIsolatedCapability());
+  int resolution = TimeClamper::kCoarseResolutionMicroseconds;
+
+  // Frame 1: capture_begin_time only
+  auto video_frame1 = media::VideoFrame::CreateBlackFrame(gfx::Size(10, 10));
+  video_frame1->metadata().capture_begin_time =
+      base::TimeTicks() + kUnclampedTimestamp1;
+  PushFrame(std::move(video_frame1), base::TimeTicks::Now());
+
+  VideoFrame* web_video_frame1 =
+      ReadObjectFromStream<VideoFrame>(v8_scope, reader);
+  int64_t exposed_timestamp1 = web_video_frame1->timestamp();
+  EXPECT_EQ(exposed_timestamp1 % resolution, 0);
+  EXPECT_NE(exposed_timestamp1, kUnclampedTimestamp1.InMicroseconds());
+
+  // Frame 2: reference_time only
+  auto video_frame2 = media::VideoFrame::CreateBlackFrame(gfx::Size(10, 10));
+  video_frame2->metadata().reference_time =
+      base::TimeTicks() + kUnclampedTimestamp2;
+  PushFrame(std::move(video_frame2), base::TimeTicks::Now());
+
+  VideoFrame* web_video_frame2 =
+      ReadObjectFromStream<VideoFrame>(v8_scope, reader);
+  int64_t exposed_timestamp2 = web_video_frame2->timestamp();
+  EXPECT_EQ(exposed_timestamp2 % resolution, 0);
+  EXPECT_NE(exposed_timestamp2, kUnclampedTimestamp2.InMicroseconds());
+
+  // Frame 3: both set (should prefer capture_begin_time)
+  auto video_frame3 = media::VideoFrame::CreateBlackFrame(gfx::Size(10, 10));
+  video_frame3->metadata().capture_begin_time =
+      base::TimeTicks() + kUnclampedTimestamp3;
+  video_frame3->metadata().reference_time =
+      base::TimeTicks() + kUnclampedTimestamp4;
+  PushFrame(std::move(video_frame3), base::TimeTicks::Now());
+
+  VideoFrame* web_video_frame3 =
+      ReadObjectFromStream<VideoFrame>(v8_scope, reader);
+  int64_t exposed_timestamp3 = web_video_frame3->timestamp();
+  EXPECT_EQ(exposed_timestamp3 % resolution, 0);
+  EXPECT_NE(exposed_timestamp3, kUnclampedTimestamp3.InMicroseconds());
+  // It should be close to kUnclampedTimestamp3, not kUnclampedTimestamp4.
+  EXPECT_GT(kUnclampedTimestamp4.InMicroseconds() - exposed_timestamp3,
+            resolution * 2);
+
+  source->Close();
+  track->stopTrack(v8_scope.GetExecutionContext());
+}
+
 class MediaStreamVideoTrackUnderlyingSourceLeaseTest
     : public testing::Test,
       public testing::WithParamInterface<std::tuple<bool, bool, bool>> {
diff --git a/third_party/blink/renderer/modules/webcodecs/video_frame_test.cc b/third_party/blink/renderer/modules/webcodecs/video_frame_test.cc
index df54c7fb..40ce136 100644
--- a/third_party/blink/renderer/modules/webcodecs/video_frame_test.cc
+++ b/third_party/blink/renderer/modules/webcodecs/video_frame_test.cc
@@ -121,6 +121,37 @@
   EXPECT_EQ(nullptr, blink_frame->frame());
 }
 
+TEST_F(VideoFrameTest, ConstructorWithTimestamp) {
+  V8TestingScope scope;
+
+  scoped_refptr<media::VideoFrame> media_frame = CreateBlackMediaVideoFrame(
+      base::Microseconds(1000), media::PIXEL_FORMAT_I420,
+      gfx::Size(112, 208) /* coded_size */,
+      gfx::Size(100, 200) /* visible_size */);
+
+  // Case 1: Constructor with explicit timestamp override
+  VideoFrame* frame_with_ts = MakeGarbageCollected<VideoFrame>(
+      media_frame, scope.GetExecutionContext(), "source_id", nullptr,
+      base::Microseconds(2000));
+  EXPECT_EQ(2000, frame_with_ts->timestamp());
+  frame_with_ts->close();
+
+  // Case 2: Constructor with std::nullopt timestamp (should use media_frame
+  // timestamp)
+  VideoFrame* frame_with_nullopt =
+      MakeGarbageCollected<VideoFrame>(media_frame, scope.GetExecutionContext(),
+                                       "source_id", nullptr, std::nullopt);
+  EXPECT_EQ(1000, frame_with_nullopt->timestamp());
+  frame_with_nullopt->close();
+
+  // Case 3: Constructor with default timestamp (should use media_frame
+  // timestamp)
+  VideoFrame* frame_default = MakeGarbageCollected<VideoFrame>(
+      media_frame, scope.GetExecutionContext(), "source_id", nullptr);
+  EXPECT_EQ(1000, frame_default->timestamp());
+  frame_default->close();
+}
+
 TEST_F(VideoFrameTest, ConstructorOddSize) {
   V8TestingScope scope;
 
@@ -537,24 +568,27 @@
       SkImageInfo::MakeN32Premul(5, 5, SkColorSpace::MakeSRGB())));
   sk_sp<SkImage> sk_image = surface->makeImageSnapshot();
   auto handle_2_1 = base::MakeRefCounted<VideoFrameHandle>(
-      media_frame2, sk_image, scope.GetExecutionContext(), source1);
+      media_frame2, sk_image, std::nullopt, scope.GetExecutionContext(),
+      source1);
   verify_expectations(/* source1 */ 2, 1, 1, /* source2 */ 0, 0, 0);
 
   auto& logger = WebCodecsLogger::From(*scope.GetExecutionContext());
   auto handle_1_1b = base::MakeRefCounted<VideoFrameHandle>(
-      media_frame1, sk_image, logger.GetCloseAuditor(), source1);
+      media_frame1, sk_image, std::nullopt, logger.GetCloseAuditor(), source1);
   verify_expectations(/* source1 */ 2, 2, 1, /* source2 */ 0, 0, 0);
 
-  auto handle_1_2 =
-      base::MakeRefCounted<VideoFrameHandle>(media_frame1, sk_image, source2);
+  auto handle_1_2 = base::MakeRefCounted<VideoFrameHandle>(
+      media_frame1, sk_image, std::nullopt,
+      scoped_refptr<WebCodecsLogger::VideoFrameCloseAuditor>(), source2);
   verify_expectations(/* source1 */ 2, 2, 1, /* source2 */ 1, 1, 0);
 
   auto non_monitored1 = base::MakeRefCounted<VideoFrameHandle>(
-      media_frame2, sk_image, scope.GetExecutionContext());
+      media_frame2, sk_image, std::nullopt, scope.GetExecutionContext());
   verify_expectations(/* source1 */ 2, 2, 1, /* source2 */ 1, 1, 0);
 
-  auto non_monitored2 =
-      base::MakeRefCounted<VideoFrameHandle>(media_frame1, sk_image);
+  auto non_monitored2 = base::MakeRefCounted<VideoFrameHandle>(
+      media_frame1, sk_image, std::nullopt,
+      scoped_refptr<WebCodecsLogger::VideoFrameCloseAuditor>());
   verify_expectations(/* source1 */ 2, 2, 1, /* source2 */ 1, 1, 0);
 
   // Move constructor
Loading diff…

Original Bug Report

reported by vm...@google.com

High-resolution timer mitigation bypass in WebCodecs VideoFrame.timestamp

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 WebCodecs VideoFrame.timestamp property potentially exposes unclamped, microsecond-resolution timestamps to unprivileged web content. When video frames are captured from elements like canvas or camera streams, their raw timestamps are stored and returned without routing through TimeClamper or ClampTimeResolution. This allows a potential bypass of high-resolution timer coarsening mitigations, which are designed to prevent side-channel timing attacks.

Affected files:

  • third_party/blink/renderer/modules/webcodecs/video_frame_handle.cc
  • third_party/blink/renderer/modules/webcodecs/video_frame.cc

Estimated timestamp from git blame: 2024-04-16

Summary

A potential high-resolution timer mitigation bypass exists in WebCodecs where VideoFrame.timestamp exposes unclamped microsecond-precision timestamps. Chromium coarsens timestamps using TimeClamper to mitigate Spectre-style side-channel timing attacks, but the VideoFrame.timestamp path potentially bypasses this defense.

Root Cause Analysis

In third_party/blink/renderer/modules/webcodecs/video_frame_handle.cc, GetPreferredTimestamp computes the timestamp for a VideoFrameHandle:

base::TimeDelta GetPreferredTimestamp(bool prefer_capture_timestamp,
                                      const media::VideoFrame& video_frame) {
  if (prefer_capture_timestamp) {
    if (video_frame.metadata().capture_begin_time) {
      return *video_frame.metadata().capture_begin_time - base::TimeTicks();
    }
    if (video_frame.metadata().reference_time) {
      return *video_frame.metadata().reference_time - base::TimeTicks();
    }
  }
  return video_frame.timestamp();
}

This is returned as a raw base::TimeDelta. Subsequently, VideoFrame::timestamp() in third_party/blink/renderer/modules/webcodecs/video_frame.cc exposes this value directly to JavaScript in microseconds:

int64_t VideoFrame::timestamp() const {
  return handle_->timestamp().InMicroseconds();
}

There is no routing of this timestamp through Performance::ClampTimeResolution or TimeClamper. As a result, the time resolution is exposed at a 1-microsecond granularity.

Potential Reproduction / Trigger Steps

Note: Since our automated tooling agent does not have the ability to run code, these are potential/suggested reproduction steps based on static code analysis.

  1. Capture a stream from a <canvas> element using const stream = canvas.captureStream();
  2. Process the stream track using const processor = new MediaStreamTrackProcessor({track: stream.getVideoTracks()[0]});
  3. Periodically perform draw operations on the canvas and trigger paint invalidations;
  4. Read frames from the MediaStreamTrackProcessor readable stream reader;
  5. Query frame.timestamp in JavaScript. The returned values will potentially have a 1µs resolution rather than the clamped 5µs or 100µs resolution.

Sibling Protections

Similar interfaces that handle frame metadata (e.g., requestVideoFrameCallback in video_frame_callback_requester_impl.cc) explicitly coarsen their timestamps via Performance::ClampTimeResolution to uphold the security boundary.

Suggested Fix

Coarsen/clamp the timestamp resolution returned to JavaScript. This can be achieved by routing the microsecond delta through the document’s or execution context’s Performance::ClampTimeResolution before converting/exposing it in VideoFrame::timestamp() or during VideoFrameHandle construction.

Evaluated with Chrome root at commit: 87214e6721f6c34afd9181b80769a24c0c601c50


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