Medium chrome Memory Corruption 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMemory corruption in PerformanceAPIs
DescriptionMemory corruption in PerformanceAPIs
ComponentPerformanceAPIs
Sub-componentBlink>PerformanceAPIs
Bug ClassMemory Corruption
Tracker511325076
Fix commit19cc8a1be0ca (chromium/src) +90/-5
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
for
third_party/blink/renderer/bindings/core/v8/profiler_trace_builder_test.cc
modified
TEST_F
third_party/blink/renderer/bindings/core/v8/profiler_trace_builder_test.cc
modified
if
third_party/blink/renderer/bindings/core/v8/profiler_trace_builder_test.cc
modified
for
third_party/blink/web_tests/external/wpt/js-self-profiling/time-domain.window.js
modified

Files Changed

  • third_party/blink/web_tests/external/wpt/js-self-profiling/time-domain.window.js
  • third_party/blink/renderer/bindings/core/v8/profiler_trace_builder_test.cc
  • third_party/blink/renderer/bindings/core/v8/profiler_trace_builder.h
  • third_party/blink/renderer/bindings/core/v8/profiler_trace_builder.cc
From 19cc8a1be0caaf547798cf67bb28ad16c027aa15 Mon Sep 17 00:00:00 2001
From: Mike Taylor <[email protected]>
Date: Wed, 16 Sep 2026 20:40:38 -0700
Subject: [PATCH] Clamp profiler sample timestamps based on context isolation status

ProfilerTraceBuilder::AddSample() previously converted sample timestamps
using a hardcoded cross_origin_isolated_capability parameter set to
true. Consequently, sample timestamps received fine-resolution clamping
even in contexts that were not cross-origin isolated.

This change updates ProfilerTraceBuilder::AddSample() to pass the
builder's is_cross_origin_isolated_ member instead, ensuring timestamp
resolution matches the execution context's actual isolation capability.

TAG=agy

Bug: 511325076
Change-Id: I0ac2aa2c63f7ba177db759784ddced0ac96c4433
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8416487
Reviewed-by: Kouhei Ueno <[email protected]>
Commit-Queue: Mike Taylor <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1699494}
---
diff --git a/third_party/blink/renderer/bindings/core/v8/profiler_trace_builder.cc b/third_party/blink/renderer/bindings/core/v8/profiler_trace_builder.cc
index 42d935d2..4376ea7 100644
--- a/third_party/blink/renderer/bindings/core/v8/profiler_trace_builder.cc
+++ b/third_party/blink/renderer/bindings/core/v8/profiler_trace_builder.cc
@@ -93,13 +93,9 @@
     const v8::StateTag state,
     const v8::EmbedderStateTag embedder_state) {
   auto* sample = ProfilerSample::Create();
-  // TODO(yoav): This should not use MonotonicTimeToDOMHighResTimeStamp, as
-  // these timestamps are clamped, which makes no sense for traces. Since this
-  // only exposes time to traces, it's fine to define this as statically "cross
-  // origin isolated".
   auto relative_timestamp = Performance::MonotonicTimeToDOMHighResTimeStamp(
       time_origin_, timestamp, /*allow_negative_value=*/true,
-      /*cross_origin_isolated_capability=*/true);
+      /*cross_origin_isolated_capability=*/is_cross_origin_isolated_);
 
   sample->setTimestamp(relative_timestamp);
   if (std::optional<wtf_size_t> stack_id = GetOrInsertStackId(node)) {
diff --git a/third_party/blink/renderer/bindings/core/v8/profiler_trace_builder.h b/third_party/blink/renderer/bindings/core/v8/profiler_trace_builder.h
index aee7457..fc41ebb 100644
--- a/third_party/blink/renderer/bindings/core/v8/profiler_trace_builder.h
+++ b/third_party/blink/renderer/bindings/core/v8/profiler_trace_builder.h
@@ -180,6 +180,10 @@
   FRIEND_TEST_ALL_PREFIXES(ProfilerTraceBuilderTest, AddEmbedderStateMarker);
   FRIEND_TEST_ALL_PREFIXES(ProfilerTraceBuilderTest,
                            AddEmbedderStateMarkerFeatureDisabled);
+  FRIEND_TEST_ALL_PREFIXES(ProfilerTraceBuilderTest,
+                           SampleTimestampClampingNonCrossOriginIsolated);
+  FRIEND_TEST_ALL_PREFIXES(ProfilerTraceBuilderTest,
+                           SampleTimestampClampingCrossOriginIsolated);
 };
 
 }  // namespace blink
diff --git a/third_party/blink/renderer/bindings/core/v8/profiler_trace_builder_test.cc b/third_party/blink/renderer/bindings/core/v8/profiler_trace_builder_test.cc
index 3f7f431..bebad1b 100644
--- a/third_party/blink/renderer/bindings/core/v8/profiler_trace_builder_test.cc
+++ b/third_party/blink/renderer/bindings/core/v8/profiler_trace_builder_test.cc
@@ -4,12 +4,16 @@
 
 #include "third_party/blink/renderer/bindings/core/v8/profiler_trace_builder.h"
 
+#include <cmath>
+
 #include "base/time/time.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/blink/renderer/bindings/core/v8/v8_binding_for_testing.h"
 #include "third_party/blink/renderer/bindings/core/v8/v8_profiler_marker.h"
 #include "third_party/blink/renderer/bindings/core/v8/v8_profiler_sample.h"
 #include "third_party/blink/renderer/bindings/core/v8/v8_profiler_trace.h"
+#include "third_party/blink/renderer/core/timing/performance.h"
+#include "third_party/blink/renderer/core/timing/time_clamper.h"
 #include "third_party/blink/renderer/platform/testing/runtime_enabled_features_test_helpers.h"
 #include "third_party/blink/renderer/platform/testing/task_environment.h"
 #include "v8/include/v8.h"
@@ -140,4 +144,79 @@
   EXPECT_FALSE(samples.at(2).Get()->hasMarker());
 }
 
+TEST_F(ProfilerTraceBuilderTest,
+       SampleTimestampClampingNonCrossOriginIsolated) {
+  V8TestingScope scope;
+  auto* script_state = scope.GetScriptState();
+
+  base::TimeTicks time_origin = base::TimeTicks::Now();
+  ProfilerTraceBuilder* builder = MakeGarbageCollected<ProfilerTraceBuilder>(
+      script_state, nullptr, time_origin);
+
+  // In non-cross-origin-isolated contexts, timestamps should be coarsened
+  // to kCoarseResolutionMicroseconds (100us).
+  for (int i = 1; i <= 100; ++i) {
+    base::TimeTicks sample_ticks = time_origin + base::Microseconds(i * 13);
+    builder->AddSample(nullptr, sample_ticks, v8::StateTag::JS,
+                       v8::EmbedderStateTag::EMPTY);
+  }
+
+  auto* profiler_trace = builder->GetTrace();
+  const auto& samples = profiler_trace->samples();
+  EXPECT_EQ(samples.size(), 100u);
+  for (wtf_size_t i = 0; i < samples.size(); ++i) {
+    base::TimeTicks sample_ticks =
+        time_origin + base::Microseconds((i + 1) * 13);
+    DOMHighResTimeStamp expected_timestamp =
+        Performance::MonotonicTimeToDOMHighResTimeStamp(
+            time_origin, sample_ticks, /*allow_negative_value=*/true,
+            /*cross_origin_isolated_capability=*/false);
+    EXPECT_EQ(samples.at(i).Get()->timestamp(), expected_timestamp);
+    int64_t timestamp_us = static_cast<int64_t>(
+        std::round(samples.at(i).Get()->timestamp() *
+                   base::Time::kMicrosecondsPerMillisecond));
+    EXPECT_EQ(timestamp_us % TimeClamper::kCoarseResolutionMicroseconds, 0);
+  }
+}
+
+TEST_F(ProfilerTraceBuilderTest, SampleTimestampClampingCrossOriginIsolated) {
+  V8TestingScope scope;
+  auto* script_state = scope.GetScriptState();
+
+  base::TimeTicks time_origin = base::TimeTicks::Now();
+  ProfilerTraceBuilder* builder = MakeGarbageCollected<ProfilerTraceBuilder>(
+      script_state, nullptr, time_origin);
+  builder->is_cross_origin_isolated_ = true;
+
+  // In cross-origin-isolated contexts, timestamps should be coarsened
+  // to kFineResolutionMicroseconds (5us).
+  for (int i = 1; i <= 100; ++i) {
+    base::TimeTicks sample_ticks = time_origin + base::Microseconds(i * 13);
+    builder->AddSample(nullptr, sample_ticks, v8::StateTag::JS,
+                       v8::EmbedderStateTag::EMPTY);
+  }
+
+  auto* profiler_trace = builder->GetTrace();
+  const auto& samples = profiler_trace->samples();
+  EXPECT_EQ(samples.size(), 100u);
+  int non_coarse_count = 0;
+  for (wtf_size_t i = 0; i < samples.size(); ++i) {
+    base::TimeTicks sample_ticks =
+        time_origin + base::Microseconds((i + 1) * 13);
+    DOMHighResTimeStamp expected_timestamp =
+        Performance::MonotonicTimeToDOMHighResTimeStamp(
+            time_origin, sample_ticks, /*allow_negative_value=*/true,
+            /*cross_origin_isolated_capability=*/true);
+    EXPECT_EQ(samples.at(i).Get()->timestamp(), expected_timestamp);
+    int64_t timestamp_us = static_cast<int64_t>(
+        std::round(samples.at(i).Get()->timestamp() *
+                   base::Time::kMicrosecondsPerMillisecond));
+    EXPECT_EQ(timestamp_us % TimeClamper::kFineResolutionMicroseconds, 0);
+    if (timestamp_us % TimeClamper::kCoarseResolutionMicroseconds != 0) {
+      non_coarse_count++;
+    }
+  }
+  EXPECT_GT(non_coarse_count, 0);
+}
+
 }  // namespace blink
diff --git a/third_party/blink/web_tests/external/wpt/js-self-profiling/time-domain.window.js b/third_party/blink/web_tests/external/wpt/js-self-profiling/time-domain.window.js
index 5791a3de..262c8c3 100644
--- a/third_party/blink/web_tests/external/wpt/js-self-profiling/time-domain.window.js
+++ b/third_party/blink/web_tests/external/wpt/js-self-profiling/time-domain.window.js
@@ -15,5 +15,11 @@
   assert_greater_than(trace.samples.length, 0);
   for (const sample of trace.samples) {
     assert_between_inclusive(sample.timestamp, start, end);
+    const us = Math.round(sample.timestamp * 1000);
+    const expectedResolution = self.crossOriginIsolated ? 5 : 100;
+    assert_equals(
+        us % expectedResolution, 0,
+        `sample timestamp (${sample.timestamp} ms / ${
+            us} us) should align with resolution of ${expectedResolution} us`);
   }
 }, 'sample timestamps use the current high-resolution time');
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/bindings/core/v8/profiler_trace_builder_test.cc b/third_party/blink/renderer/bindings/core/v8/profiler_trace_builder_test.cc
index 3f7f431..bebad1b 100644
--- a/third_party/blink/renderer/bindings/core/v8/profiler_trace_builder_test.cc
+++ b/third_party/blink/renderer/bindings/core/v8/profiler_trace_builder_test.cc
@@ -4,12 +4,16 @@
 
 #include "third_party/blink/renderer/bindings/core/v8/profiler_trace_builder.h"
 
+#include <cmath>
+
 #include "base/time/time.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/blink/renderer/bindings/core/v8/v8_binding_for_testing.h"
 #include "third_party/blink/renderer/bindings/core/v8/v8_profiler_marker.h"
 #include "third_party/blink/renderer/bindings/core/v8/v8_profiler_sample.h"
 #include "third_party/blink/renderer/bindings/core/v8/v8_profiler_trace.h"
+#include "third_party/blink/renderer/core/timing/performance.h"
+#include "third_party/blink/renderer/core/timing/time_clamper.h"
 #include "third_party/blink/renderer/platform/testing/runtime_enabled_features_test_helpers.h"
 #include "third_party/blink/renderer/platform/testing/task_environment.h"
 #include "v8/include/v8.h"
@@ -140,4 +144,79 @@
   EXPECT_FALSE(samples.at(2).Get()->hasMarker());
 }
 
+TEST_F(ProfilerTraceBuilderTest,
+       SampleTimestampClampingNonCrossOriginIsolated) {
+  V8TestingScope scope;
+  auto* script_state = scope.GetScriptState();
+
+  base::TimeTicks time_origin = base::TimeTicks::Now();
+  ProfilerTraceBuilder* builder = MakeGarbageCollected<ProfilerTraceBuilder>(
+      script_state, nullptr, time_origin);
+
+  // In non-cross-origin-isolated contexts, timestamps should be coarsened
+  // to kCoarseResolutionMicroseconds (100us).
+  for (int i = 1; i <= 100; ++i) {
+    base::TimeTicks sample_ticks = time_origin + base::Microseconds(i * 13);
+    builder->AddSample(nullptr, sample_ticks, v8::StateTag::JS,
+                       v8::EmbedderStateTag::EMPTY);
+  }
+
+  auto* profiler_trace = builder->GetTrace();
+  const auto& samples = profiler_trace->samples();
+  EXPECT_EQ(samples.size(), 100u);
+  for (wtf_size_t i = 0; i < samples.size(); ++i) {
+    base::TimeTicks sample_ticks =
+        time_origin + base::Microseconds((i + 1) * 13);
+    DOMHighResTimeStamp expected_timestamp =
+        Performance::MonotonicTimeToDOMHighResTimeStamp(
+            time_origin, sample_ticks, /*allow_negative_value=*/true,
+            /*cross_origin_isolated_capability=*/false);
+    EXPECT_EQ(samples.at(i).Get()->timestamp(), expected_timestamp);
+    int64_t timestamp_us = static_cast<int64_t>(
+        std::round(samples.at(i).Get()->timestamp() *
+                   base::Time::kMicrosecondsPerMillisecond));
+    EXPECT_EQ(timestamp_us % TimeClamper::kCoarseResolutionMicroseconds, 0);
+  }
+}
+
+TEST_F(ProfilerTraceBuilderTest, SampleTimestampClampingCrossOriginIsolated) {
+  V8TestingScope scope;
+  auto* script_state = scope.GetScriptState();
+
+  base::TimeTicks time_origin = base::TimeTicks::Now();
+  ProfilerTraceBuilder* builder = MakeGarbageCollected<ProfilerTraceBuilder>(
+      script_state, nullptr, time_origin);
+  builder->is_cross_origin_isolated_ = true;
+
+  // In cross-origin-isolated contexts, timestamps should be coarsened
+  // to kFineResolutionMicroseconds (5us).
+  for (int i = 1; i <= 100; ++i) {
+    base::TimeTicks sample_ticks = time_origin + base::Microseconds(i * 13);
+    builder->AddSample(nullptr, sample_ticks, v8::StateTag::JS,
+                       v8::EmbedderStateTag::EMPTY);
+  }
+
+  auto* profiler_trace = builder->GetTrace();
+  const auto& samples = profiler_trace->samples();
+  EXPECT_EQ(samples.size(), 100u);
+  int non_coarse_count = 0;
+  for (wtf_size_t i = 0; i < samples.size(); ++i) {
+    base::TimeTicks sample_ticks =
+        time_origin + base::Microseconds((i + 1) * 13);
+    DOMHighResTimeStamp expected_timestamp =
+        Performance::MonotonicTimeToDOMHighResTimeStamp(
+            time_origin, sample_ticks, /*allow_negative_value=*/true,
+            /*cross_origin_isolated_capability=*/true);
+    EXPECT_EQ(samples.at(i).Get()->timestamp(), expected_timestamp);
+    int64_t timestamp_us = static_cast<int64_t>(
+        std::round(samples.at(i).Get()->timestamp() *
+                   base::Time::kMicrosecondsPerMillisecond));
+    EXPECT_EQ(timestamp_us % TimeClamper::kFineResolutionMicroseconds, 0);
+    if (timestamp_us % TimeClamper::kCoarseResolutionMicroseconds != 0) {
+      non_coarse_count++;
+    }
+  }
+  EXPECT_GT(non_coarse_count, 0);
+}
+
 }  // namespace blink
diff --git a/third_party/blink/web_tests/external/wpt/js-self-profiling/time-domain.window.js b/third_party/blink/web_tests/external/wpt/js-self-profiling/time-domain.window.js
index 5791a3de..262c8c3 100644
--- a/third_party/blink/web_tests/external/wpt/js-self-profiling/time-domain.window.js
+++ b/third_party/blink/web_tests/external/wpt/js-self-profiling/time-domain.window.js
@@ -15,5 +15,11 @@
   assert_greater_than(trace.samples.length, 0);
   for (const sample of trace.samples) {
     assert_between_inclusive(sample.timestamp, start, end);
+    const us = Math.round(sample.timestamp * 1000);
+    const expectedResolution = self.crossOriginIsolated ? 5 : 100;
+    assert_equals(
+        us % expectedResolution, 0,
+        `sample timestamp (${sample.timestamp} ms / ${
+            us} us) should align with resolution of ${expectedResolution} us`);
   }
 }, 'sample timestamps use the current high-resolution time');
Loading diff…

Original Bug Report

reported by [email protected]

Leak of high-resolution timestamps via JS Self-Profiling API bypassing isolation checks

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 without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: The JS Self-Profiling API bypasses the 100μs timer coarsening defense intended for non-cross-origin-isolated pages. It exposes 5μs-resolution timestamps because the ProfilerTraceBuilder hardcodes the cross-origin isolation capability to true. This potentially allows an attacker to reconstruct a high-resolution timer to mount side-channel attacks like Spectre.

Affected files:

  • third_party/blink/renderer/bindings/core/v8/profiler_trace_builder.cc
  • third_party/blink/renderer/core/timing/profiler_sample.idl

Estimated timestamp from git blame: 2021-03-23

Summary

The JS Self-Profiling API (v8-profiler) leaks high-resolution timestamps (5μs resolution) to web pages that are not cross-origin isolated. In non-isolated contexts, Chrome is intended to clamp high-resolution timers to a coarse resolution of 100μs as a defense-in-depth measure against transient execution side-channel attacks like Spectre.

The profiling API bypasses this mitigation by hardcoding a flag that enables fine-grained timing (5μs resolution) during trace generation. Because the API can be enabled simply via the Document-Policy: js-profiling HTTP header—which does not require Cross-Origin Opener Policy (COOP) or Cross-Origin Embedder Policy (COEP)—an attacker can obtain a stream of 5μs-granular timestamps in a context where performance.now() is correctly clamped to 100μs.

Root Cause Analysis

The vulnerability originates in third_party/blink/renderer/bindings/core/v8/profiler_trace_builder.cc. When V8 CPU profile samples are converted into a web-exposed ProfilerTrace, the conversion of the raw base::TimeTicks timestamp to a DOMHighResTimeStamp is performed by calling Performance::MonotonicTimeToDOMHighResTimeStamp.

In ProfilerTraceBuilder::AddSample:

void ProfilerTraceBuilder::AddSample(
    const v8::CpuProfileNode* node,
    base::TimeTicks timestamp,
    const v8::StateTag state,
    const v8::EmbedderStateTag embedder_state) {
  auto* sample = ProfilerSample::Create();
  // TODO(yoav): This should not use MonotonicTimeToDOMHighResTimeStamp, as
  // these timestamps are clamped, which makes no sense for traces. Since this
  // only exposes time to traces, it's fine to define this as statically "cross
  // origin isolated".
  auto relative_timestamp = Performance::MonotonicTimeToDOMHighResTimeStamp(
      time_origin_, timestamp, /*allow_negative_value=*/true,
      /*cross_origin_isolated_capability=*/true);
  // ...
}

As the comment notes, the cross_origin_isolated_capability parameter is hardcoded to true. This boolean is passed down to TimeClamper::ClampTimeResolution (third_party/blink/renderer/core/timing/time_clamper.cc), which then uses it to determine the clamping resolution:

  int resolution = cross_origin_isolated_capability
                       ? kFineResolutionMicroseconds
                       : kCoarseResolutionMicroseconds;

Consequently, the clamper selects kFineResolutionMicroseconds (5μs) instead of the kCoarseResolutionMicroseconds (100μs) that should apply to a non-cross-origin-isolated page. The trace containing these precisely timed samples is then returned to the page’s JavaScript via the Promise resolution of profiler.stop().

Security Impact

While CPU samples are collected at discrete intervals (typically >= 10ms), the high-resolution timestamps attached to those samples allow an attacker to calibrate a JavaScript busy-loop counter with significantly higher precision than standard non-isolated timers permit.

By building a tightly calibrated busy loop, the attacker effectively restores a high-resolution timing mechanism in the non-isolated page. This represents a bypass of a side-channel defense-in-depth mitigation. The attacker could potentially use this capability to conduct cache-timing attacks (such as Spectre) to read sensitive cross-origin data residing within the same renderer process.

Potential Reproduction Steps

(Note: These are theoretical steps derived from code analysis)

  1. Serve an HTML page over HTTPS with the HTTP header Document-Policy: js-profiling.
  2. Ensure the page is not cross-origin isolated (do not send COOP/COEP headers, and verify window.crossOriginIsolated === false).
  3. In the page script, instantiate a profiler:
    const p = new Profiler({sampleInterval: 10, maxBufferSize: 10000});
    
  4. Execute a busy loop for approximately one second to generate CPU samples.
  5. Stop the profiler and retrieve the trace data:
    const trace = await p.stop();
    
  6. Inspect trace.samples[i].timestamp. Observe that the values are quantized to 0.005 ms (5μs) increments.
  7. Verify that standard timers in the same context, like performance.now(), are correctly clamped to 0.1 ms (100μs) increments.

Suggested Fix

Instead of hardcoding the cross_origin_isolated_capability to true in ProfilerTraceBuilder, the actual isolation state of the execution context should be plumbed through.

  1. Modify the ProfilerTraceBuilder constructor (and FromProfile) to accept a boolean representing the cross_origin_isolated_capability.
  2. In ProfilerGroup::StopProfiler (where ProfilerTraceBuilder::FromProfile is called), query the execution context or Performance object to determine the actual crossOriginIsolated state.
  3. Pass this real state to the ProfilerTraceBuilder so it correctly clamps the sample timestamps to 100μs in non-isolated contexts.

Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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