CVE-2026-113289
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/bindings/core/v8/profiler_trace_builder.cc |
modified | |
time_origin_third_party/blink/renderer/bindings/core/v8/profiler_trace_builder.cc |
modified | |
time_origin_third_party/blink/renderer/core/timing/profiler.h |
modified |
Files Changed
third_party/blink/web_tests/external/wpt/js-self-profiling/cross-origin-script-redirect-no-cors.sub.htmlthird_party/blink/renderer/bindings/core/v8/profiler_trace_builder_test.ccthird_party/blink/renderer/bindings/core/v8/profiler_trace_builder.hthird_party/blink/renderer/core/timing/profiler_group.ccthird_party/blink/renderer/bindings/core/v8/profiler_trace_builder.ccthird_party/blink/renderer/core/timing/profiler.h
Patch
From ad6b432816fadfb0e37ddc1ba3f6d9574ea756bf Mon Sep 17 00:00:00 2001 From: Mike Taylor <[email protected]> Date: Fri, 18 Sep 2026 05:18:37 -0700 Subject: [PATCH] [js-self-profiling] Filter stack frames on the script's muted errors ProfilerTraceBuilder::ShouldIncludeStackFrame() included a frame if v8::CpuProfileNode::IsScriptSharedCrossOrigin() was true, or, as a fallback, if the origin of GetScriptResourceNameStr() was same-origin with allowed_origin_. GetScriptResourceNameStr() holds the initial pre-redirect request URL, so a classic script requested from a same-origin URL that redirected to a cross-origin response without CORS satisfied the fallback, exposing its function names and source locations in the trace. Drop the fallback and decide inclusion on IsScriptSharedCrossOrigin() alone, which is the inverse of the script's muted-errors state. This matches step 7 of the "get a frame ID" algorithm in the JS Self-Profiling specification, and it derives from the response rather than the request: true for same-origin classic scripts, CORS-enabled cross-origin scripts, inline scripts, and module scripts, and false for opaque no-cors responses no matter how the request got there. Traces no longer describe scripts that already report "Script error." to window.onerror. Keep the guard that omits frames whose script has no parseable resource name, such as eval() and new Function(). The specification includes those frames, and they expose nothing opaque, as V8 propagates is_shared_cross_origin to eval()ed scripts and defaults it to false for entries that have no script. Including them would newly emit frames that carry a line and column but no resource, which consumers have never seen. With the fallback gone, nothing needs the profiling context's origin. Remove Profiler::source_origin_, Profiler::SourceOrigin(), ProfilerTraceBuilder::allowed_origin_, and the GetSecurityOrigin() call in ProfilerGroup::CreateProfiler(). Rename script_same_origin_cache_ to script_inclusion_cache_, as it now caches the whole decision rather than a same-origin check. Add cross-origin-script-redirect-no-cors.sub.html, which fails before this change and passes after it. Bug: 511311494 Change-Id: If605837e57a7084729930d7acec982a28bd785eb Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8409345 Reviewed-by: Kouhei Ueno <[email protected]> Commit-Queue: Mike Taylor <[email protected]> Reviewed-by: Michal Mocny <[email protected]> Cr-Commit-Position: refs/heads/main@{#1700662} --- 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 4376ea7..a78dc84 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 @@ -25,11 +25,10 @@ ProfilerTrace* ProfilerTraceBuilder::FromProfile( ScriptState* script_state, const v8::CpuProfile* profile, - const SecurityOrigin* allowed_origin, base::TimeTicks time_origin) { TRACE_EVENT0("blink", "ProfilerTraceBuilder::FromProfile"); - ProfilerTraceBuilder* builder = MakeGarbageCollected<ProfilerTraceBuilder>( - script_state, allowed_origin, time_origin); + ProfilerTraceBuilder* builder = + MakeGarbageCollected<ProfilerTraceBuilder>(script_state, time_origin); if (profile) { for (int i = 0; i < profile->GetSamplesCount(); i++) { const auto* node = profile->GetSample(i); @@ -44,11 +43,8 @@ } ProfilerTraceBuilder::ProfilerTraceBuilder(ScriptState* script_state, - const SecurityOrigin* allowed_origin, base::TimeTicks time_origin) - : script_state_(script_state), - allowed_origin_(allowed_origin), - time_origin_(time_origin) { + : script_state_(script_state), time_origin_(time_origin) { ExecutionContext* execution_context = ExecutionContext::From(script_state_); if (execution_context) { is_cross_origin_isolated_ = @@ -217,23 +213,25 @@ int script_id = resource_node->GetScriptId(); - // If we already tested whether or not this script was cross-origin, return - // the cached results. - auto it = script_same_origin_cache_.find(script_id); - if (it != script_same_origin_cache_.end()) + // If we already tested whether or not this script's frames may be included, + // return the cached result. + auto it = script_inclusion_cache_.find(script_id); + if (it != script_inclusion_cache_.end()) { return it->value; + } - KURL resource_url(resource_node->GetScriptResourceNameStr()); - if (!resource_url.IsValid()) - return false; - - auto origin = SecurityOrigin::Create(resource_url); - // Omit frames that don't pass a cross-origin check. + // Omit frames whose script's response was not CORS-same-origin with the + // profiling context. This mirrors the muted-errors check used for + // window.onerror, and accounts for redirects in the script's request chain. + // + // Also omit frames whose script has no parseable resource name, such as + // eval() and new Function(). + // // Do this at the stack level (rather than the frame level) to avoid // including skeleton frames without data. - bool allowed = resource_node->IsScriptSharedCrossOrigin() || - origin->IsSameOriginWith(allowed_origin_); - script_same_origin_cache_.Set(script_id, allowed); + bool allowed = resource_node->IsScriptSharedCrossOrigin() && + KURL(resource_node->GetScriptResourceNameStr()).IsValid(); + script_inclusion_cache_.Set(script_id, allowed); return allowed; } 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 fc41ebb..fb5ca66 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 @@ -15,7 +15,6 @@ #include "third_party/blink/renderer/platform/heap/collection_support/heap_vector.h" #include "third_party/blink/renderer/platform/heap/garbage_collected.h" #include "third_party/blink/renderer/platform/heap/member.h" -#include "third_party/blink/renderer/platform/weborigin/security_origin.h" #include "third_party/blink/renderer/platform/wtf/forward.h" #include "third_party/blink/renderer/platform/wtf/hash_map.h" #include "third_party/blink/renderer/platform/wtf/text/string_hash.h" @@ -73,12 +72,9 @@ public: static ProfilerTrace* FromProfile(ScriptState*, const v8::CpuProfile* profile, - const SecurityOrigin* allowed_origin, base::TimeTicks time_origin); - explicit ProfilerTraceBuilder(ScriptState*, - const SecurityOrigin* allowed_origin, - base::TimeTicks time_origin); + explicit ProfilerTraceBuilder(ScriptState*, base::TimeTicks time_origin); ProfilerTraceBuilder(const ProfilerTraceBuilder&) = delete; ProfilerTraceBuilder& operator=(const ProfilerTraceBuilder&) = delete; @@ -146,13 +142,13 @@ const v8::StateTag fallback_state); // Discards metadata frames and performs an origin check on the given stack - // frame, returning true if it either has the same origin as the profiler, or - // if it should be shared cross origin. + // frame, returning true if its attributed script may be shared with the + // profiling context (i.e. its errors are not muted) and has a parseable + // resource name. bool ShouldIncludeStackFrame(const v8::CpuProfileNode* node); Member<ScriptState> script_state_; - const SecurityOrigin* allowed_origin_; const base::TimeTicks time_origin_; bool is_cross_origin_isolated_ = false; @@ -168,9 +164,9 @@ HashMap<const v8::CpuProfileNode*, wtf_size_t, ProfilerNodeFrameHashTraits> node_to_frame_map_; - // A mapping from a V8 internal script ID to whether or not it passes the - // same-origin policy for the ScriptState that the trace belongs to. - HashMap<int, bool> script_same_origin_cache_; + // A mapping from a V8 internal script ID to whether or not frames attributed + // to it may be included in the trace. + HashMap<int, bool> script_inclusion_cache_; FRIEND_TEST_ALL_PREFIXES(ProfilerTraceBuilderTest, AddVMStateMarkerCrossOriginIsolated); diff --git a/third_party/blink/renderer/core/timing/profiler.h b/third_party/blink/renderer/core/timing/profiler.h index 190d659..4ab5f9c7 100644 --- a/third_party/blink/renderer/core/timing/profiler.h +++ b/third_party/blink/renderer/core/timing/profiler.h @@ -14,7 +14,6 @@ #include "third_party/blink/renderer/platform/bindings/script_wrappable.h" #include "third_party/blink/renderer/platform/heap/member.h" #include "third_party/blink/renderer/platform/heap/prefinalizer.h" -#include "third_party/blink/renderer/platform/weborigin/security_origin.h" #include "third_party/blink/renderer/platform/wtf/forward.h" namespace blink { @@ -36,13 +35,11 @@ ScriptState* script_state, const String& profiler_id, int target_sample_rate, - scoped_refptr<const SecurityOrigin> source_origin, base::TimeTicks time_origin) : profiler_group_(profiler_group), script_state_(script_state), profiler_id_(profiler_id), target_sample_rate_(target_sample_rate), - source_origin_(source_origin), time_origin_(time_origin) {} ~Profiler() override = default; @@ -57,7 +54,6 @@ String ProfilerId() const { return profiler_id_; } int TargetSampleRate() const { return target_sample_rate_; } - const SecurityOrigin* SourceOrigin() const { return source_origin_.get(); } base::TimeTicks TimeOrigin() const { return time_origin_; } // Overrides from extending EventTarget @@ -75,7 +71,6 @@ Member<ScriptState> script_state_; const String profiler_id_; const int target_sample_rate_; - const scoped_refptr<const SecurityOrigin> source_origin_; const base::TimeTicks time_origin_; }; diff --git a/third_party/blink/renderer/core/timing/profiler_group.cc b/third_party/blink/renderer/core/timing/profiler_group.cc index 43fcc49..0be78c14 100644 --- a/third_party/blink/renderer/core/timing/profiler_group.cc +++ b/third_party/blink/renderer/core/timing/profiler_group.cc @@ -25,7 +25,6 @@ #include "third_party/blink/renderer/platform/bindings/exception_state.h" #include "third_party/blink/renderer/platform/bindings/script_state.h" #include "third_party/blink/renderer/platform/scheduler/public/thread_scheduler.h" -#include "third_party/blink/renderer/platform/weborigin/security_origin.h" #include "third_party/blink/renderer/platform/wtf/functional.h" #include "third_party/blink/renderer/platform/wtf/text/format.h" #include "third_party/blink/renderer/platform/wtf/vector.h" @@ -241,12 +240,6 @@ return nullptr; } case v8::CpuProfilingStatus::kStarted: { - // Limit non-crossorigin script frames to the origin that started the - // profiler. - auto* execution_context = ExecutionContext::From(script_state); - scoped_refptr<const SecurityOrigin> source_origin( - execution_context->GetSecurityOrigin()); - // The V8 CPU profiler ticks in multiples of the base sampling interval. // This effectively means that we gather samples at the multiple of the // base sampling interval that's greater than or equal to the requested @@ -262,7 +255,7 @@ auto* profiler = MakeGarbageCollected<Profiler>( this, script_state, profiler_id, effective_sample_interval_ms, - source_origin, time_origin); + time_origin); profilers_.insert(profiler); num_active_profilers_++; return profiler; @@ -338,8 +331,8 @@ v8::Local<v8::String> profiler_id = V8String(isolate_, profiler->ProfilerId()); auto* profile = cpu_profiler_->StopProfiling(profiler_id); - auto* trace = ProfilerTraceBuilder::FromProfile( - script_state, profile, profiler->SourceOrigin(), profiler->TimeOrigin()); + auto* trace = ProfilerTraceBuilder::FromProfile(script_state, profile, + profiler->TimeOrigin()); resolver->Resolve(trace); if (profile) 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 bebad1b..9d4c75b5 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 @@ -37,7 +37,7 @@ ScopedExperimentalJSProfilerMarkersForTest enable_markers(true); ProfilerTraceBuilder* builder = MakeGarbageCollected<ProfilerTraceBuilder>( - script_state, nullptr, base::TimeTicks::Now()); + script_state, base::TimeTicks::Now()); builder->is_cross_origin_isolated_ = true; base::TimeTicks sample_ticks = base::TimeTicks::Now(); @@ -58,7 +58,7 @@ ScopedExperimentalJSProfilerMarkersForTest enable_markers(true); ProfilerTraceBuilder* builder = MakeGarbageCollected<ProfilerTraceBuilder>( - script_state, nullptr, base::TimeTicks::Now()); + script_state, base::TimeTicks::Now()); builder->is_cross_origin_isolated_ = true; base::TimeTicks sample_ticks = base::TimeTicks::Now(); @@ -83,7 +83,7 @@ ScopedExperimentalJSProfilerMarkersForTest enable_markers(true); ProfilerTraceBuilder* builder = MakeGarbageCollected<ProfilerTraceBuilder>( - script_state, nullptr, base::TimeTicks::Now()); + script_state, base::TimeTicks::Now()); base::TimeTicks sample_ticks = base::TimeTicks::Now(); builder->AddSample(nullptr, sample_ticks, v8::StateTag::GC, @@ -103,7 +103,7 @@ ScopedExperimentalJSProfilerMarkersForTest enable_markers(true); ProfilerTraceBuilder* builder = MakeGarbageCollected<ProfilerTraceBuilder>( - script_state, nullptr, base::TimeTicks::Now()); + script_state, base::TimeTicks::Now()); base::TimeTicks sample_ticks = base::TimeTicks::Now(); builder->AddSample(nullptr, sample_ticks, v8::StateTag::IDLE, @@ -127,7 +127,7 @@ ScopedExperimentalJSProfilerMarkersForTest disable_markers(false); ProfilerTraceBuilder* builder = MakeGarbageCollected<ProfilerTraceBuilder>( - script_state, nullptr, base::TimeTicks::Now()); + script_state, base::TimeTicks::Now()); base::TimeTicks sample_ticks = base::TimeTicks::Now(); builder->AddSample(nullptr, sample_ticks, v8::StateTag::IDLE, @@ -150,8 +150,8 @@ auto* script_state = scope.GetScriptState(); base::TimeTicks time_origin = base::TimeTicks::Now(); - ProfilerTraceBuilder* builder = MakeGarbageCollected<ProfilerTraceBuilder>( - script_state, nullptr, time_origin); + ProfilerTraceBuilder* builder = + MakeGarbageCollected<ProfilerTraceBuilder>(script_state, time_origin); // In non-cross-origin-isolated contexts, timestamps should be coarsened // to kCoarseResolutionMicroseconds (100us). @@ -184,8 +184,8 @@ auto* script_state = scope.GetScriptState(); base::TimeTicks time_origin = base::TimeTicks::Now(); - ProfilerTraceBuilder* builder = MakeGarbageCollected<ProfilerTraceBuilder>( - script_state, nullptr, time_origin); + ProfilerTraceBuilder* builder = + MakeGarbageCollected<ProfilerTraceBuilder>(script_state, time_origin); builder->is_cross_origin_isolated_ = true; // In cross-origin-isolated contexts, timestamps should be coarsened diff --git a/third_party/blink/web_tests/external/wpt/js-self-profiling/cross-origin-script-redirect-no-cors.sub.html b/third_party/blink/web_tests/external/wpt/js-self-profiling/cross-origin-script-redirect-no-cors.sub.html new file mode 100644 index 0000000..05ce442 --- /dev/null +++ b/third_party/blink/web_tests/external/wpt/js-self-profiling/cross-origin-script-redirect-no-cors.sub.html @@ -0,0 +1,24 @@ +<!DOCTYPE html> +<html> +<head> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + + <script src="resources/profile-utils.js"></script> + + <script src="/common/redirect.py?location=https://{{hosts[alt][]}}:{{ports[https][0]}}/js-self-profiling/resources/profiling-script.js"></script> +</head> +<body> + <script> + promise_test(async t => { + const trace = await ProfilingScript.profileBuiltinsInNewTask(); + + // Ensure that no signal from the external script was gathered. + assert_equals(trace.resources.length, 0); + assert_equals(trace.frames.length, 0); + assert_equals(trace.stacks.length, 0); + assert_greater_than(trace.samples.length, 0); + }, 'no-cors cross-origin script redirected from same-origin is not observable'); + </script> +</body> +</html>
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 bebad1b..9d4c75b5 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
@@ -37,7 +37,7 @@
ScopedExperimentalJSProfilerMarkersForTest enable_markers(true);
ProfilerTraceBuilder* builder = MakeGarbageCollected<ProfilerTraceBuilder>(
- script_state, nullptr, base::TimeTicks::Now());
+ script_state, base::TimeTicks::Now());
builder->is_cross_origin_isolated_ = true;
base::TimeTicks sample_ticks = base::TimeTicks::Now();
@@ -58,7 +58,7 @@
ScopedExperimentalJSProfilerMarkersForTest enable_markers(true);
ProfilerTraceBuilder* builder = MakeGarbageCollected<ProfilerTraceBuilder>(
- script_state, nullptr, base::TimeTicks::Now());
+ script_state, base::TimeTicks::Now());
builder->is_cross_origin_isolated_ = true;
base::TimeTicks sample_ticks = base::TimeTicks::Now();
@@ -83,7 +83,7 @@
ScopedExperimentalJSProfilerMarkersForTest enable_markers(true);
ProfilerTraceBuilder* builder = MakeGarbageCollected<ProfilerTraceBuilder>(
- script_state, nullptr, base::TimeTicks::Now());
+ script_state, base::TimeTicks::Now());
base::TimeTicks sample_ticks = base::TimeTicks::Now();
builder->AddSample(nullptr, sample_ticks, v8::StateTag::GC,
@@ -103,7 +103,7 @@
ScopedExperimentalJSProfilerMarkersForTest enable_markers(true);
ProfilerTraceBuilder* builder = MakeGarbageCollected<ProfilerTraceBuilder>(
- script_state, nullptr, base::TimeTicks::Now());
+ script_state, base::TimeTicks::Now());
base::TimeTicks sample_ticks = base::TimeTicks::Now();
builder->AddSample(nullptr, sample_ticks, v8::StateTag::IDLE,
@@ -127,7 +127,7 @@
ScopedExperimentalJSProfilerMarkersForTest disable_markers(false);
ProfilerTraceBuilder* builder = MakeGarbageCollected<ProfilerTraceBuilder>(
- script_state, nullptr, base::TimeTicks::Now());
+ script_state, base::TimeTicks::Now());
base::TimeTicks sample_ticks = base::TimeTicks::Now();
builder->AddSample(nullptr, sample_ticks, v8::StateTag::IDLE,
@@ -150,8 +150,8 @@
auto* script_state = scope.GetScriptState();
base::TimeTicks time_origin = base::TimeTicks::Now();
- ProfilerTraceBuilder* builder = MakeGarbageCollected<ProfilerTraceBuilder>(
- script_state, nullptr, time_origin);
+ ProfilerTraceBuilder* builder =
+ MakeGarbageCollected<ProfilerTraceBuilder>(script_state, time_origin);
// In non-cross-origin-isolated contexts, timestamps should be coarsened
// to kCoarseResolutionMicroseconds (100us).
@@ -184,8 +184,8 @@
auto* script_state = scope.GetScriptState();
base::TimeTicks time_origin = base::TimeTicks::Now();
- ProfilerTraceBuilder* builder = MakeGarbageCollected<ProfilerTraceBuilder>(
- script_state, nullptr, time_origin);
+ ProfilerTraceBuilder* builder =
+ MakeGarbageCollected<ProfilerTraceBuilder>(script_state, time_origin);
builder->is_cross_origin_isolated_ = true;
// In cross-origin-isolated contexts, timestamps should be coarsened
diff --git a/third_party/blink/web_tests/external/wpt/js-self-profiling/cross-origin-script-redirect-no-cors.sub.html b/third_party/blink/web_tests/external/wpt/js-self-profiling/cross-origin-script-redirect-no-cors.sub.html
new file mode 100644
index 0000000..05ce442
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/js-self-profiling/cross-origin-script-redirect-no-cors.sub.html
@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <script src="/resources/testharness.js"></script>
+ <script src="/resources/testharnessreport.js"></script>
+
+ <script src="resources/profile-utils.js"></script>
+
+ <script src="/common/redirect.py?location=https://{{hosts[alt][]}}:{{ports[https][0]}}/js-self-profiling/resources/profiling-script.js"></script>
+</head>
+<body>
+ <script>
+ promise_test(async t => {
+ const trace = await ProfilingScript.profileBuiltinsInNewTask();
+
+ // Ensure that no signal from the external script was gathered.
+ assert_equals(trace.resources.length, 0);
+ assert_equals(trace.frames.length, 0);
+ assert_equals(trace.stacks.length, 0);
+ assert_greater_than(trace.samples.length, 0);
+ }, 'no-cors cross-origin script redirected from same-origin is not observable');
+ </script>
+</body>
+</html>
Original Bug Report
XS-Leak in JS Self-Profiling API via Same-Origin Redirect
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 may leak execution metadata (like function names and line numbers) from opaque cross-origin scripts when loaded via a same-origin redirect. The vulnerability stems from the profiler’s origin-validation logic checking the initial request URL rather than the final response URL. This could allow an attacker to infer sensitive cross-origin user states.
Affected files:
third_party/blink/renderer/bindings/core/v8/profiler_trace_builder.ccthird_party/blink/renderer/core/script/classic_script.ccthird_party/blink/renderer/platform/loader/fetch/resource.ccthird_party/blink/renderer/core/timing/profiler_group.cc
Estimated timestamp from git blame: 2022-08-17
Summary
A potential vulnerability in the JS Self-Profiling API allows an attacker to bypass cross-origin isolation checks for opaque scripts. By initiating a same-origin request that redirects to a cross-origin script, an attacker can trick the profiler into including stack frames from the cross-origin script in its output. This leaks sensitive information such as function names and line numbers, enabling a Cross-Site Leak (XS-Leak).
Root Cause Analysis
When a script is fetched, Blink creates a Resource object to manage the network request. If the server responds with an HTTP redirect, Resource::WillFollowRedirect handles the redirect but does not mutate its internal resource_request_ object, which retains the initial request URL.
After the script is downloaded, Blink calls ClassicScript::CreateFromResource (third_party/blink/renderer/core/script/classic_script.cc). It initializes source_url using resource->Url(), which returns the unmodified initial request URL. This initial, pre-redirect URL is then passed to V8 via v8::ScriptOrigin during script compilation.
When a Profiler is active, the V8 CPU profiler records executing stack frames and associates them with this source_url. Later, when the profiler is stopped, ProfilerTraceBuilder::ShouldIncludeStackFrame (third_party/blink/renderer/bindings/core/v8/profiler_trace_builder.cc) filters the frames to enforce cross-origin protections. It checks if the frame’s origin matches the document’s origin:
KURL resource_url(resource_node->GetScriptResourceNameStr());
auto origin = SecurityOrigin::Create(resource_url);
bool allowed = resource_node->IsScriptSharedCrossOrigin() ||
origin->IsSameOriginWith(allowed_origin_);
Because resource_url reflects the initial same-origin request URL, origin->IsSameOriginWith(allowed_origin_) evaluates to true. This causes the check to pass, bypassing the fact that IsScriptSharedCrossOrigin() evaluates to false for the opaque response. The profiler then improperly includes the cross-origin metadata in the resulting web-exposed trace.
Potential Attack Scenario
Note: The following steps are a suggested theoretical attack flow based on static analysis, as our tooling agent currently lacks the ability to run code and provide a working proof-of-concept.
- An attacker serves a webpage on their origin (e.g.,
https://attacker.example) with theDocument-Policy: js-profilingheader to enable the API. - The attacker hosts a redirect endpoint at
https://attacker.example/redirectthat returns an HTTP302 Foundpointing to a sensitive cross-origin script (e.g.,https://victim.example/app.js). - The attacker’s webpage executes
const p = new Profiler({sampleInterval: 0, maxBufferSize: 10000});. - The attacker’s webpage forces a load of the redirect endpoint by appending a
<script src="/redirect"></script>tag to the DOM. - The browser fetches the initial URL, follows the redirect, and executes the cross-origin script. V8 profiles the execution.
- The attacker calls
await p.stop()to retrieve the trace. - The
ProfilerTraceBuilderincorrectly validates the script against the pre-redirecthttps://attacker.example/redirectURL. The attacker receives a trace containing the execution paths of the credentialed victim script, which can be analyzed to infer the user’s state.
Suggested Fix
To fix this issue, ProfilerTraceBuilder::ShouldIncludeStackFrame must not rely on a same-origin check against the resource_name string for scripts that underwent a cross-origin redirect.
One potential solution is to modify ClassicScript::CreateScriptOrigin to pass the final response URL (resource->GetResponse().ResponseUrl()) to V8, or to pass a separate, reliable flag that dictates cross-origin opacity to the trace builder. Alternatively, ShouldIncludeStackFrame could be restricted to rely exclusively on IsScriptSharedCrossOrigin() for all external scripts.
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.