CVE-2026-17800
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TESTthird_party/blink/renderer/modules/mediarecorder/media_recorder_unittest.cc |
modified | |
TimecodeCaptureListenerthird_party/blink/renderer/modules/mediarecorder/media_recorder_unittest.cc |
modified | |
ifthird_party/blink/renderer/modules/mediarecorder/media_recorder_unittest.cc |
modified | |
ifthird_party/blink/web_tests/external/wpt/mediacapture-record/MediaRecorder-blob-timecode.https.html |
modified |
Files Changed
third_party/blink/renderer/modules/mediarecorder/media_recorder.ccthird_party/blink/renderer/modules/mediarecorder/media_recorder_unittest.ccthird_party/blink/web_tests/external/wpt/mediacapture-record/MediaRecorder-blob-timecode.https.html
Patch
From 9ebf4302210513a012c901d87a2668b3aadf8cc1 Mon Sep 17 00:00:00 2001
From: Markus Handell <handellm@google.com>
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 <handellm@google.com>
Reviewed-by: Guido Urdaneta <guidou@chromium.org>
Commit-Queue: Markus Handell <handellm@google.com>
Commit-Queue: Guido Urdaneta <guidou@chromium.org>
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 => {
Regression Test / PoC
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 => {
Original Bug Report
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.)
- An attacker silently creates a synthetic media stream via
const stream = document.createElement('canvas').captureStream();. - The attacker instantiates a
MediaRecorderwith the stream and callsrecorder.start(). - The attacker calls
recorder.requestData(). This synchronously flushes buffers and callsCreateBlobEvent, setting theblob_event_first_chunk_timecode_baseline usingTimeTicks::Now(). - The attacker immediately executes their microarchitectural target operation (e.g., executing a Spectre gadget or timing a cache access).
- The attacker immediately calls
recorder.requestData()a second time. This synchronously captures a secondTimeTicks::Now()snapshot immediately after the sensitive operation. - The browser dispatches two
dataavailableevents to the JavaScript context. The attacker reads thetimecodeattribute from the secondBlobEventto 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.