CVE-2026-113146
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/renderer/bindings/core/v8/profiler_trace_builder_test.cc |
modified | |
TEST_Fthird_party/blink/renderer/bindings/core/v8/profiler_trace_builder_test.cc |
modified | |
ifthird_party/blink/renderer/bindings/core/v8/profiler_trace_builder_test.cc |
modified | |
forthird_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.jsthird_party/blink/renderer/bindings/core/v8/profiler_trace_builder_test.ccthird_party/blink/renderer/bindings/core/v8/profiler_trace_builder.hthird_party/blink/renderer/bindings/core/v8/profiler_trace_builder.cc
Patch
From 19cc8a1be0caaf547798cf67bb28ad16c027aa15 Mon Sep 17 00:00:00 2001
From: Mike Taylor <miketaylr@chromium.org>
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 <kouhei@chromium.org>
Commit-Queue: Mike Taylor <miketaylr@chromium.org>
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');
Regression Test / PoC
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');
Original Bug Report
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.ccthird_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)
- Serve an HTML page over HTTPS with the HTTP header
Document-Policy: js-profiling. - Ensure the page is not cross-origin isolated (do not send COOP/COEP headers, and verify
window.crossOriginIsolated === false). - In the page script, instantiate a profiler:
const p = new Profiler({sampleInterval: 10, maxBufferSize: 10000}); - Execute a busy loop for approximately one second to generate CPU samples.
- Stop the profiler and retrieve the trace data:
const trace = await p.stop(); - Inspect
trace.samples[i].timestamp. Observe that the values are quantized to 0.005 ms (5μs) increments. - 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.
- Modify the
ProfilerTraceBuilderconstructor (andFromProfile) to accept a boolean representing thecross_origin_isolated_capability. - In
ProfilerGroup::StopProfiler(whereProfilerTraceBuilder::FromProfileis called), query the execution context orPerformanceobject to determine the actualcrossOriginIsolatedstate. - Pass this real state to the
ProfilerTraceBuilderso 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.