Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactSide-channel information leakage in MediaRecording
DescriptionSide-channel information leakage in MediaRecording
ComponentMediaRecording
Bug ClassLogic Error
Tracker514480948
Fix commit9ebf43022105 (chromium/src) +79/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
TEST
third_party/blink/renderer/modules/mediarecorder/media_recorder_unittest.cc
modified
TimecodeCaptureListener
third_party/blink/renderer/modules/mediarecorder/media_recorder_unittest.cc
modified
if
third_party/blink/renderer/modules/mediarecorder/media_recorder_unittest.cc
modified
if
third_party/blink/web_tests/external/wpt/mediacapture-record/MediaRecorder-blob-timecode.https.html
modified

Files Changed

  • third_party/blink/renderer/modules/mediarecorder/media_recorder.cc
  • third_party/blink/renderer/modules/mediarecorder/media_recorder_unittest.cc
  • third_party/blink/web_tests/external/wpt/mediacapture-record/MediaRecorder-blob-timecode.https.html
From 9ebf4302210513a012c901d87a2668b3aadf8cc1 Mon Sep 17 00:00:00 2001
From: Markus Handell <[email protected]>
Date: Tue, 02 Jun 2026 09:12:18 -0700
Subject: [PATCH] Prevent side-channel timing attacks using BlobEvent timecode.

Bug: 514480948
Change-Id: Ic1ecb60aa92f1da169ba8a32633a69a6524e9925
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7895059
Auto-Submit: Markus Handell <[email protected]>
Reviewed-by: Guido Urdaneta <[email protected]>
Commit-Queue: Markus Handell <[email protected]>
Commit-Queue: Guido Urdaneta <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1640215}
---

diff --git a/third_party/blink/renderer/modules/mediarecorder/media_recorder.cc b/third_party/blink/renderer/modules/mediarecorder/media_recorder.cc
index 49e7e03..c84e540 100644
--- a/third_party/blink/renderer/modules/mediarecorder/media_recorder.cc
+++ b/third_party/blink/renderer/modules/mediarecorder/media_recorder.cc
@@ -22,6 +22,8 @@
 #include "third_party/blink/renderer/core/fileapi/blob.h"
 #include "third_party/blink/renderer/core/frame/local_dom_window.h"
 #include "third_party/blink/renderer/core/inspector/console_message.h"
+#include "third_party/blink/renderer/core/timing/dom_window_performance.h"
+#include "third_party/blink/renderer/core/timing/window_performance.h"
 #include "third_party/blink/renderer/modules/event_target_modules.h"
 #include "third_party/blink/renderer/modules/mediarecorder/blob_event.h"
 #include "third_party/blink/renderer/modules/mediarecorder/media_recorder_handler.h"
@@ -498,8 +500,15 @@
   if (!blob_event_first_chunk_timecode_.has_value()) {
     blob_event_first_chunk_timecode_ = now;
   } else {
-    timecode =
-        (now - blob_event_first_chunk_timecode_.value()).InMillisecondsF();
+    if (LocalDOMWindow* window =
+            DynamicTo<LocalDOMWindow>(GetExecutionContext())) {
+      if (WindowPerformance* performance =
+              DOMWindowPerformance::performance(*window)) {
+        timecode = performance->MonotonicTimeToDOMHighResTimeStamp(now) -
+                   performance->MonotonicTimeToDOMHighResTimeStamp(
+                       blob_event_first_chunk_timecode_.value());
+      }
+    }
   }
 
   ScheduleDispatchEvent(MakeGarbageCollected<BlobEvent>(
diff --git a/third_party/blink/renderer/modules/mediarecorder/media_recorder_unittest.cc b/third_party/blink/renderer/modules/mediarecorder/media_recorder_unittest.cc
index 596e8f4..0f5f239a6 100644
--- a/third_party/blink/renderer/modules/mediarecorder/media_recorder_unittest.cc
+++ b/third_party/blink/renderer/modules/mediarecorder/media_recorder_unittest.cc
@@ -9,6 +9,11 @@
 #include "testing/gmock/include/gmock/gmock.h"
 #include "third_party/blink/public/web/web_heap.h"
 #include "third_party/blink/renderer/bindings/core/v8/v8_binding_for_testing.h"
+#include "third_party/blink/renderer/core/dom/events/native_event_listener.h"
+#include "third_party/blink/renderer/core/frame/local_dom_window.h"
+#include "third_party/blink/renderer/core/timing/dom_window_performance.h"
+#include "third_party/blink/renderer/core/timing/window_performance.h"
+#include "third_party/blink/renderer/modules/mediarecorder/blob_event.h"
 #include "third_party/blink/renderer/modules/mediastream/media_stream_track_impl.h"
 #include "third_party/blink/renderer/modules/mediastream/media_stream_video_track.h"
 #include "third_party/blink/renderer/modules/mediastream/mock_media_stream_registry.h"
@@ -73,4 +78,65 @@
   EXPECT_FALSE(recorder->HasPendingActivity());
 }
 
+TEST(MediaRecorderTest, BlobEventTimecodeIsCoarsened) {
+  class TimecodeCaptureListener : public NativeEventListener {
+   public:
+    explicit TimecodeCaptureListener(base::OnceClosure quit_closure)
+        : quit_closure_(std::move(quit_closure)) {}
+
+    void Invoke(ExecutionContext*, Event* event) override {
+      if (event->type() == event_type_names::kDataavailable) {
+        last_timecode_ = static_cast<BlobEvent*>(event)->timecode();
+        if (quit_closure_) {
+          std::move(quit_closure_).Run();
+        }
+      }
+    }
+    double last_timecode() const { return last_timecode_; }
+    void reset(base::OnceClosure quit_closure) {
+      quit_closure_ = std::move(quit_closure);
+    }
+
+   private:
+    double last_timecode_ = 0;
+    base::OnceClosure quit_closure_;
+  };
+  test::TaskEnvironment task_environment(
+      test::TaskEnvironment::TimeSource::MOCK_TIME);
+  ScopedTestingPlatformSupport<IOTaskRunnerTestingPlatformSupport> platform;
+  V8TestingScope scope;
+  MediaStream* stream = CreateMediaStream(&scope);
+  MediaRecorder* recorder = MakeGarbageCollected<MediaRecorder>(
+      scope.GetExecutionContext(), stream, MediaRecorderOptions::Create(),
+      scope.GetExceptionState());
+  base::RunLoop run_loop1;
+  auto* listener =
+      MakeGarbageCollected<TimecodeCaptureListener>(run_loop1.QuitClosure());
+  recorder->addEventListener(event_type_names::kDataavailable, listener);
+  recorder->start(scope.GetExceptionState());
+  const base::TimeTicks t0 = base::TimeTicks::Now();
+  recorder->WriteData(base::span<const uint8_t>(), /*last_in_slice=*/true,
+                      /*error_event=*/nullptr);
+  run_loop1.Run();
+  EXPECT_EQ(listener->last_timecode(), 0.0);
+  base::RunLoop run_loop2;
+  listener->reset(run_loop2.QuitClosure());
+  task_environment.FastForwardBy(base::Microseconds(110));
+  recorder->WriteData(base::span<const uint8_t>(), /*last_in_slice=*/true,
+                      /*error_event=*/nullptr);
+  run_loop2.Run();
+
+  // To prevent test flakiness, we query the expected coarsened duration using
+  // the exact same WindowPerformance instance. Blink's TimeClamper implements
+  // security mitigations by applying pseudorandom jitter using a randomized
+  // seed (secret_) generated at startup, even in the unit test environment.
+  LocalDOMWindow* window = To<LocalDOMWindow>(scope.GetExecutionContext());
+  WindowPerformance* performance = DOMWindowPerformance::performance(*window);
+  double expected_timecode =
+      performance->MonotonicTimeToDOMHighResTimeStamp(t0 +
+                                                      base::Microseconds(110)) -
+      performance->MonotonicTimeToDOMHighResTimeStamp(t0);
+  EXPECT_EQ(listener->last_timecode(), expected_timecode);
+}
+
 }  // namespace blink
diff --git a/third_party/blink/web_tests/external/wpt/mediacapture-record/MediaRecorder-blob-timecode.https.html b/third_party/blink/web_tests/external/wpt/mediacapture-record/MediaRecorder-blob-timecode.https.html
index ec7715fa..bf039e78 100644
--- a/third_party/blink/web_tests/external/wpt/mediacapture-record/MediaRecorder-blob-timecode.https.html
+++ b/third_party/blink/web_tests/external/wpt/mediacapture-record/MediaRecorder-blob-timecode.https.html
@@ -27,12 +27,13 @@
       if (combinedSize === 0) {
         assert_equals(timecode, 0, "first chunk timecode must be 0");
       } else {
-        assert_greater_than(timecode, previous_timecode, "timecode must increase monotonically");
+        assert_greater_than_equal(timecode, previous_timecode, "timecode must increase monotonically");
       }
       previous_timecode = timecode;
       combinedSize += data.size;
     }
     recorder.stop();
+    assert_greater_than(previous_timecode, 0, "last_timecode must be greater than 0");
   }
 
   promise_test(async t => {
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/modules/mediarecorder/media_recorder_unittest.cc b/third_party/blink/renderer/modules/mediarecorder/media_recorder_unittest.cc
index 596e8f4..0f5f239a6 100644
--- a/third_party/blink/renderer/modules/mediarecorder/media_recorder_unittest.cc
+++ b/third_party/blink/renderer/modules/mediarecorder/media_recorder_unittest.cc
@@ -9,6 +9,11 @@
 #include "testing/gmock/include/gmock/gmock.h"
 #include "third_party/blink/public/web/web_heap.h"
 #include "third_party/blink/renderer/bindings/core/v8/v8_binding_for_testing.h"
+#include "third_party/blink/renderer/core/dom/events/native_event_listener.h"
+#include "third_party/blink/renderer/core/frame/local_dom_window.h"
+#include "third_party/blink/renderer/core/timing/dom_window_performance.h"
+#include "third_party/blink/renderer/core/timing/window_performance.h"
+#include "third_party/blink/renderer/modules/mediarecorder/blob_event.h"
 #include "third_party/blink/renderer/modules/mediastream/media_stream_track_impl.h"
 #include "third_party/blink/renderer/modules/mediastream/media_stream_video_track.h"
 #include "third_party/blink/renderer/modules/mediastream/mock_media_stream_registry.h"
@@ -73,4 +78,65 @@
   EXPECT_FALSE(recorder->HasPendingActivity());
 }
 
+TEST(MediaRecorderTest, BlobEventTimecodeIsCoarsened) {
+  class TimecodeCaptureListener : public NativeEventListener {
+   public:
+    explicit TimecodeCaptureListener(base::OnceClosure quit_closure)
+        : quit_closure_(std::move(quit_closure)) {}
+
+    void Invoke(ExecutionContext*, Event* event) override {
+      if (event->type() == event_type_names::kDataavailable) {
+        last_timecode_ = static_cast<BlobEvent*>(event)->timecode();
+        if (quit_closure_) {
+          std::move(quit_closure_).Run();
+        }
+      }
+    }
+    double last_timecode() const { return last_timecode_; }
+    void reset(base::OnceClosure quit_closure) {
+      quit_closure_ = std::move(quit_closure);
+    }
+
+   private:
+    double last_timecode_ = 0;
+    base::OnceClosure quit_closure_;
+  };
+  test::TaskEnvironment task_environment(
+      test::TaskEnvironment::TimeSource::MOCK_TIME);
+  ScopedTestingPlatformSupport<IOTaskRunnerTestingPlatformSupport> platform;
+  V8TestingScope scope;
+  MediaStream* stream = CreateMediaStream(&scope);
+  MediaRecorder* recorder = MakeGarbageCollected<MediaRecorder>(
+      scope.GetExecutionContext(), stream, MediaRecorderOptions::Create(),
+      scope.GetExceptionState());
+  base::RunLoop run_loop1;
+  auto* listener =
+      MakeGarbageCollected<TimecodeCaptureListener>(run_loop1.QuitClosure());
+  recorder->addEventListener(event_type_names::kDataavailable, listener);
+  recorder->start(scope.GetExceptionState());
+  const base::TimeTicks t0 = base::TimeTicks::Now();
+  recorder->WriteData(base::span<const uint8_t>(), /*last_in_slice=*/true,
+                      /*error_event=*/nullptr);
+  run_loop1.Run();
+  EXPECT_EQ(listener->last_timecode(), 0.0);
+  base::RunLoop run_loop2;
+  listener->reset(run_loop2.QuitClosure());
+  task_environment.FastForwardBy(base::Microseconds(110));
+  recorder->WriteData(base::span<const uint8_t>(), /*last_in_slice=*/true,
+                      /*error_event=*/nullptr);
+  run_loop2.Run();
+
+  // To prevent test flakiness, we query the expected coarsened duration using
+  // the exact same WindowPerformance instance. Blink's TimeClamper implements
+  // security mitigations by applying pseudorandom jitter using a randomized
+  // seed (secret_) generated at startup, even in the unit test environment.
+  LocalDOMWindow* window = To<LocalDOMWindow>(scope.GetExecutionContext());
+  WindowPerformance* performance = DOMWindowPerformance::performance(*window);
+  double expected_timecode =
+      performance->MonotonicTimeToDOMHighResTimeStamp(t0 +
+                                                      base::Microseconds(110)) -
+      performance->MonotonicTimeToDOMHighResTimeStamp(t0);
+  EXPECT_EQ(listener->last_timecode(), expected_timecode);
+}
+
 }  // namespace blink
diff --git a/third_party/blink/web_tests/external/wpt/mediacapture-record/MediaRecorder-blob-timecode.https.html b/third_party/blink/web_tests/external/wpt/mediacapture-record/MediaRecorder-blob-timecode.https.html
index ec7715fa..bf039e78 100644
--- a/third_party/blink/web_tests/external/wpt/mediacapture-record/MediaRecorder-blob-timecode.https.html
+++ b/third_party/blink/web_tests/external/wpt/mediacapture-record/MediaRecorder-blob-timecode.https.html
@@ -27,12 +27,13 @@
       if (combinedSize === 0) {
         assert_equals(timecode, 0, "first chunk timecode must be 0");
       } else {
-        assert_greater_than(timecode, previous_timecode, "timecode must increase monotonically");
+        assert_greater_than_equal(timecode, previous_timecode, "timecode must increase monotonically");
       }
       previous_timecode = timecode;
       combinedSize += data.size;
     }
     recorder.stop();
+    assert_greater_than(previous_timecode, 0, "last_timecode must be greater than 0");
   }
 
   promise_test(async t => {
Loading diff…

Original Bug Report

reported by [email protected]

MediaRecorder BlobEvent.timecode bypasses high-resolution timer mitigation

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 MediaRecorder API exposes a high-precision timecode attribute via BlobEvent without applying the standard security coarsening mandated for the web platform. This allows an attacker to construct a high-resolution timer by invoking requestData() synchronously, bypassing anti-Spectre mitigations designed to limit clock resolution. The raw microsecond-precision values can be abused as a primitive for microarchitectural timing side-channel attacks.

Affected files:

  • third_party/blink/renderer/modules/mediarecorder/media_recorder.cc

Estimated timestamp from git blame: 2024-10-01

Vulnerability Details

Standard web platform security policy dictates that exposed timestamps (like DOMHighResTimeStamp) must be artificially coarsened (typically to 100µs, or 5µs in cross-origin isolated contexts) and jittered. This is implemented in Blink via Performance::MonotonicTimeToDOMHighResTimeStamp and ClampTimeResolution to prevent fine-grained timing measurements required for attacks like Spectre.

The MediaRecorder API violates this policy by exposing uncoarsened base::TimeTicks data through the BlobEvent.timecode attribute. When a recording is active, the MediaRecorder::CreateBlobEvent function samples the system clock using base::TimeTicks::Now() and computes the elapsed duration as a raw double via .InMillisecondsF().

// third_party/blink/renderer/modules/mediarecorder/media_recorder.cc
void MediaRecorder::CreateBlobEvent(Blob* blob) {
  const base::TimeTicks now = base::TimeTicks::Now();
  double timecode = 0;
  if (!blob_event_first_chunk_timecode_.has_value()) {
    blob_event_first_chunk_timecode_ = now;
  } else {
    timecode =
        (now - blob_event_first_chunk_timecode_.value()).InMillisecondsF();
  }

  ScheduleDispatchEvent(MakeGarbageCollected<BlobEvent>(
      event_type_names::kDataavailable, blob, timecode));
}

Because the MediaRecorder::requestData() API executes synchronously through to CreateBlobEvent, an attacker can trigger deterministic, on-demand clock reads without yielding to the event loop. This effectively provides a reliable, microsecond-accurate stopwatch in JavaScript.

Potential Exploitation Steps

(Note: These are potential steps based on static analysis. We do not currently have a running PoC for this issue.)

  1. An attacker silently creates a synthetic media stream via const stream = document.createElement('canvas').captureStream();.
  2. The attacker instantiates a MediaRecorder with the stream and calls recorder.start().
  3. The attacker calls recorder.requestData(). This synchronously flushes buffers and calls CreateBlobEvent, setting the blob_event_first_chunk_timecode_ baseline using TimeTicks::Now().
  4. The attacker immediately executes their microarchitectural target operation (e.g., executing a Spectre gadget or timing a cache access).
  5. The attacker immediately calls recorder.requestData() a second time. This synchronously captures a second TimeTicks::Now() snapshot immediately after the sensitive operation.
  6. The browser dispatches two dataavailable events to the JavaScript context. The attacker reads the timecode attribute from the second BlobEvent to retrieve the uncoarsened microsecond-precision measurement of their operation, successfully bypassing the standard time clamp.

Suggested Fix

Ensure that the timecode generated in MediaRecorder::CreateBlobEvent is properly clamped using the execution context’s Performance interface before exposing it to the DOM. Alternatively, do not expose a relative timecode at all, or route the calculation through Performance::MonotonicTimeToDOMHighResTimeStamp().

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.

View on issue tracker