CVE-2026-5876
Overview
Background
- Same-document navigation
- a navigation (e.g. a fragment/hash change) that updates history and URL within the existing document without a full document load.
- Cross-origin owner `load` event
- Blink fires
loadon an embeddingiframeelement (FrameOwner::DispatchLoad) for a same-document navigation initiated by a cross-origin frame, specifically so the cross-origin parent cannot distinguish same- vs. cross-document navigations. - `TaskHandle` / `PostDelayedCancellableTask`
- Blink primitives for scheduling a cancellable delayed task; a stored
TaskHandlelets later code cancel a still-pending task. - Navigation side channel (crbug.com/1248444)
- a timing/observation technique by which a cross-origin parent infers information (e.g. the URL) of a cross-origin iframe from the pattern of observable
loadevents.
Root Cause Analysis
When a same-document navigation in an iframe is initiated cross-origin and is cross-origin to its parent, the enclosing function in document_loader.cc (not named in the @@ -1822/@@ -1832 hunk headers) fires the owner iframe’s load event via frame_->Owner()->DispatchLoad() to mask whether the navigation was same- or cross-document. The pre-patch code dispatched this load event synchronously and once per navigation, so a cross-origin parent observing repeated same-document navigations saw a different count/cadence of load events than it would for repeated cross-document navigations (where a later navigation cancels the previous pending one), reconstituting the very side channel the masking was meant to close.
The fix coalesces these events: it stores a TaskHandle (cross_origin_parent_load_event_task_), cancels any still-pending one, and re-schedules DispatchLoad() on a 100 ms delayed cancellable task so rapid repeated same-document navigations collapse into roughly one load event, emulating cross-document cancellation behavior.
The patch also drops the now-redundant trailing guard frame_->Tree().Parent()->GetSecurityContext()->GetSecurityOrigin() from the condition, leaving the cross-origin !initiator_origin->CanAccess(...) check intact. The delayed task uses WrapWeakPersistent(frame_->Owner()) so it is safe if the owner goes away before it fires.
Attack Path
- Embed a cross-origin iframe An attacker page embeds a cross-origin iframe and arranges to initiate same-document (fragment) navigations in it that are cross-origin to the parent.
- Drive repeated same-document navigations The parent triggers a rapid series of fragment navigations on the iframe.
- Observe load-event cadence
Pre-patch, each same-document navigation synchronously fired one owner
loadevent, producing a count/timing pattern distinguishable from repeated cross-document navigations (which cancel each other). - Infer cross-origin state
The parent uses this observable
load-event side channel to distinguish same- vs. cross-document navigation and thereby guess information such as the cross-origin iframe’s URL (crbug.com/1248444).
Impact Assessment
load events, in the renderer/web-content context; the vendor advisory rates this medium. It is reachable by ordinary web content in a stock build with no special flag, extension, or policy, requiring only that the parent embed a cross-origin iframe and drive repeated same-document navigations; the diff addresses an information-leak timing channel and demonstrates no memory-safety impact.Changed Functions
| Function | Change | Notes |
|---|---|---|
BindOncethird_party/blink/renderer/core/loader/document_loader.cc |
modified |
Files Changed
third_party/blink/renderer/core/loader/document_loader.ccthird_party/blink/renderer/core/loader/document_loader.hthird_party/blink/web_tests/http/tests/navigation/cross-origin-fragment-navigation-is-async-expected.txtthird_party/blink/web_tests/http/tests/navigation/cross-origin-fragment-navigation-is-async.html
Audit Directions
- Masking events that remain countableAudit places that fire a “cover” event to hide cross-origin state and check whether its count, timing, or ordering still differs between the cases it is meant to make indistinguishable.
- Sync vs. async cross-origin signalsReview cross-origin-observable callbacks (
load,error,message, resize) fired synchronously per operation that could be debounced/coalesced to match the baseline case. - Navigation-initiator origin checksLook at same-document navigation commit paths guarded by
initiator_origin->CanAccess(...)for other observable differences (event order, promise resolution timing) between same- and cross-document navigations.
Patch
From 9a4f5dc8779d4e11b96c043fb54a2977b89664a1 Mon Sep 17 00:00:00 2001 From: Nate Chapin <[email protected]> Date: Mon, 23 Feb 2026 17:08:34 -0800 Subject: [PATCH] Fire load event for same-document nav initiated by cross-origin parent on a delayed timer Fixed: 41485206 Change-Id: I905e09a080d5bd715becf00c99375f3bc144240e Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7560041 Reviewed-by: Charlie Reis <[email protected]> Commit-Queue: Nate Chapin <[email protected]> Cr-Commit-Position: refs/heads/main@{#1589055} --- diff --git a/third_party/blink/renderer/core/loader/document_loader.cc b/third_party/blink/renderer/core/loader/document_loader.cc index 17eeeb8..bcf06c7 100644 --- a/third_party/blink/renderer/core/loader/document_loader.cc +++ b/third_party/blink/renderer/core/loader/document_loader.cc @@ -454,6 +454,7 @@ initial_permission_statuses; bool force_new_document_sequence_number; base::TimeDelta total_taken_time_to_update_subresource_load_metrics; + TaskHandle cross_origin_parent_load_event_task; }; // Asserts size of DocumentLoader, so that whenever a new attribute is added to @@ -1822,8 +1823,7 @@ if (!frame_->GetDocument()->LoadEventStillNeeded() && frame_->Owner() && initiator_origin && - !initiator_origin->CanAccess(frame_->DomWindow()->GetSecurityOrigin()) && - frame_->Tree().Parent()->GetSecurityContext()->GetSecurityOrigin()) { + !initiator_origin->CanAccess(frame_->DomWindow()->GetSecurityOrigin())) { // If this same-document navigation was initiated by a cross-origin iframe // and is cross-origin to its parent, fire onload on the owner iframe. // Normally, the owner iframe's onload fires if and only if the window's @@ -1832,7 +1832,21 @@ // load event to detect whether the navigation was same- or cross-document, // and can therefore try to guess the url of a cross-origin iframe. Fire the // iframe's onload to prevent this technique. https://crbug.com/1248444 - frame_->Owner()->DispatchLoad(); + // Fire the event on a delayed timer so that we only fire one load event for + // repeated same-document navigations. This allows us to fire roughly the + // same number of load events as if the navigation were cross-document in + // the repeated-navigation case, because each successive cross-document + // navigation would cancel the previous pending navigation. + if (cross_origin_parent_load_event_task_.IsActive()) { + cross_origin_parent_load_event_task_.Cancel(); + } + constexpr static const base::TimeDelta cross_origin_load_event_delay = + base::Milliseconds(100); + cross_origin_parent_load_event_task_ = PostDelayedCancellableTask( + *frame_->GetTaskRunner(TaskType::kInternalLoading), FROM_HERE, + BindOnce([](FrameOwner* owner) { owner->DispatchLoad(); }, + WrapWeakPersistent(frame_->Owner())), + cross_origin_load_event_delay); } auto scroll_behavior = has_ua_visual_transition diff --git a/third_party/blink/renderer/core/loader/document_loader.h b/third_party/blink/renderer/core/loader/document_loader.h index 65e50de..113b7e93 100644 --- a/third_party/blink/renderer/core/loader/document_loader.h +++ b/third_party/blink/renderer/core/loader/document_loader.h @@ -881,6 +881,18 @@ // Stores the total time taken by `UpdateSubresourceLoadMetrics()` for the // measurement purpose. base::TimeDelta total_taken_time_to_update_subresource_load_metrics_; + + // Special case for same-document navigations initiated by a cross-origin + // frame: When a same-document navigation occurs in an iframe, we call + // FrameOwner::DispatchLoad() to fire a load event on the iframe that is + // embedding this frame. The parent frame containing that iframe might be + // cross-origin, and therefore shouldn't know whether the navigation was + // same-document or cross-document. We therefore schedule the DispatchLoad + // on a timer, which allows us to coalesce repeated same-document navigations + // into a single DispatchLoad, emulating the behavior of repeated + // cross-document navigations that will cancel each other if one doesn't have + // time to finish before the next one begins. + TaskHandle cross_origin_parent_load_event_task_; }; DECLARE_WEAK_IDENTIFIER_MAP(DocumentLoader); diff --git a/third_party/blink/web_tests/http/tests/navigation/cross-origin-fragment-navigation-is-async-expected.txt b/third_party/blink/web_tests/http/tests/navigation/cross-origin-fragment-navigation-is-async-expected.txt index 09122d9c..cef31d4 100644 --- a/third_party/blink/web_tests/http/tests/navigation/cross-origin-fragment-navigation-is-async-expected.txt +++ b/third_party/blink/web_tests/http/tests/navigation/cross-origin-fragment-navigation-is-async-expected.txt @@ -1,6 +1,6 @@ ALERT: iframe onload fired ALERT: PASS: url fragment is changing asynchronously ALERT: PASS: scheduled postMessage() before hashchange triggered. -ALERT: iframe onload fired ALERT: PASS: hashchange triggered after postMessage(). +ALERT: iframe onload fired This tests that cross-origin-initiated fragment navigations are asynchronous and always fire the load event at their embedding iframe element if it's cross-origin. It does so by scheduling a postMessage before scheduling the navigation. If the navigation is synchronous, the internals API will be able to report the presence of an url fragment immediately. diff --git a/third_party/blink/web_tests/http/tests/navigation/cross-origin-fragment-navigation-is-async.html b/third_party/blink/web_tests/http/tests/navigation/cross-origin-fragment-navigation-is-async.html index a1fc3bc..b2cfc918 100644 --- a/third_party/blink/web_tests/http/tests/navigation/cross-origin-fragment-navigation-is-async.html +++ b/third_party/blink/web_tests/http/tests/navigation/cross-origin-fragment-navigation-is-async.html @@ -7,6 +7,8 @@ testRunner.waitUntilDone(); } +window.iframe_onload_count = 0; + window.onload = function() { window.postMessage("postmessage", "*"); document.querySelector('iframe').src = "http://localhost:8000/navigation/resources/postmessage-on-hashchange.html#anchor1"; @@ -33,12 +35,15 @@ alert('PASS: hashchange triggered after postMessage().'); else alert('FAIL: hashchange triggered before postMessage().'); - testRunner.notifyDone(); } }); function onloadFired() { + window.iframe_onload_count++; alert("iframe onload fired"); + // 2 load events: 1 initial iframe load, 1 async fragment change. + if (window.iframe_onload_count == 2) + testRunner.notifyDone(); } </script> <p>This tests that cross-origin-initiated fragment navigations are asynchronous
Regression Test / PoC
diff --git a/third_party/blink/web_tests/http/tests/navigation/cross-origin-fragment-navigation-is-async-expected.txt b/third_party/blink/web_tests/http/tests/navigation/cross-origin-fragment-navigation-is-async-expected.txt
index 09122d9c..cef31d4 100644
--- a/third_party/blink/web_tests/http/tests/navigation/cross-origin-fragment-navigation-is-async-expected.txt
+++ b/third_party/blink/web_tests/http/tests/navigation/cross-origin-fragment-navigation-is-async-expected.txt
@@ -1,6 +1,6 @@
ALERT: iframe onload fired
ALERT: PASS: url fragment is changing asynchronously
ALERT: PASS: scheduled postMessage() before hashchange triggered.
-ALERT: iframe onload fired
ALERT: PASS: hashchange triggered after postMessage().
+ALERT: iframe onload fired
This tests that cross-origin-initiated fragment navigations are asynchronous and always fire the load event at their embedding iframe element if it's cross-origin. It does so by scheduling a postMessage before scheduling the navigation. If the navigation is synchronous, the internals API will be able to report the presence of an url fragment immediately.
diff --git a/third_party/blink/web_tests/http/tests/navigation/cross-origin-fragment-navigation-is-async.html b/third_party/blink/web_tests/http/tests/navigation/cross-origin-fragment-navigation-is-async.html
index a1fc3bc..b2cfc918 100644
--- a/third_party/blink/web_tests/http/tests/navigation/cross-origin-fragment-navigation-is-async.html
+++ b/third_party/blink/web_tests/http/tests/navigation/cross-origin-fragment-navigation-is-async.html
@@ -7,6 +7,8 @@
testRunner.waitUntilDone();
}
+window.iframe_onload_count = 0;
+
window.onload = function() {
window.postMessage("postmessage", "*");
document.querySelector('iframe').src = "http://localhost:8000/navigation/resources/postmessage-on-hashchange.html#anchor1";
@@ -33,12 +35,15 @@
alert('PASS: hashchange triggered after postMessage().');
else
alert('FAIL: hashchange triggered before postMessage().');
- testRunner.notifyDone();
}
});
function onloadFired() {
+ window.iframe_onload_count++;
alert("iframe onload fired");
+ // 2 load events: 1 initial iframe load, 1 async fragment change.
+ if (window.iframe_onload_count == 2)
+ testRunner.notifyDone();
}
</script>
<p>This tests that cross-origin-initiated fragment navigations are asynchronous
Original Bug Report
Security: Guess a cross-origin iframe URL by firing multiple navigations
VULNERABILITY DETAILS
This is a bypass for https://crbug.com/chromium/1248444.
It is possible to figure out whether a cross-origin iframe is on a specific URL by firing multiple hash-navigations at the same time, and then counting the onload events fired. If the URL you’re navigating to fires more than one load event, it was previously on the same URL.
Eg if the iframe is on example.org/foo and you navigate to example.org/foo#a, example.org/foo#b, and example.org/foo#c, you will get three onload events, but if the iframe starts off on example.org/bar you will only get one.
VERSION
Chrome Version: 122.0.6182.0 Dev + Stable
Operating System: Windows, Linux, macOS
REPRODUCTION CASE
In this example I am using www.chromium.org because it redirects to www.chromium.org/chromium-projects/, which we will detect.
- Download the
poc.htmlfile and open it. - Write “https://www.chromium.org/" in both inputs.
- Click “Set URL” and wait for the page to load.
- Click “Check URL”. The URL in the iframe should NOT match.
- Repeat 2-4, but write “https://www.chromium.org/chromium-projects/" in the bottom input. This time the iframe URL should match.
CREDIT INFORMATION
Externally reported security bugs may appear in Chrome release notes. If
this bug is included, how would you like to be credited?
Reporter credit: Lyra Rebane (rebane2001)