Medium watch CVSS 6.5 chrome Race 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in Workers
DescriptionRace condition in Workers
ComponentWorkers
Bug ClassRace
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker517467117
Fix commit8f32771f758c (chromium/src) +106/-2
CWECWE-362 (Race condition)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-28 (per vendor advisory)
Fix landed2026-07-02 (public git)
Disclosed2026-08-25
Patch gap54 days — fix public in source before the advisory

Background

Back/forward cache (BFCache)
keeps a navigated-away-from page alive in memory so Back/Forward can restore it instantly. A cached page and all its workers must be frozen so no script runs while it is not shown.
`DedicatedWorkerMessagingProxy`
the parent-context-side object in Blink that owns a dedicated worker’s WorkerThread and forwards lifecycle requests such as Freeze() and Resume() to it.
Nested dedicated worker
a Worker created from inside another worker. Its messaging proxy lives on the parent worker’s thread, and its own WorkerThread is only created once the worker global scope is started, after the script fetch.
`GetWorkerThread()`
returns the proxy’s worker thread, or nullptr if the thread has not yet been initialized (e.g. while the worker script is still being fetched).

Root Cause Analysis

DedicatedWorkerMessagingProxy::Freeze() and Resume() returned early when AskedToTerminate() || !worker_thread. As the commit message states, a freeze request that arrived before the worker thread existed was silently dropped. If a page was put into BFCache while a (nested) worker’s script was still being fetched, that worker missed the freeze. Once the fetch finished and StartWorkerGlobalScope() created the thread, the worker ran normally even though its owning page was cached. This breaks the BFCache rule that every execution context of a cached page is frozen, and the outcome depends on the timing of the script fetch versus the navigation.

The fix splits the guard. A freeze with no thread now records pending_freeze_is_in_back_forward_cache_, and a resume with no thread clears it. StartWorkerGlobalScope() applies any pending freeze with GetWorkerThread()->Freeze(...) before the script-loading steps run, so the worker starts frozen.

Key insight
A lifecycle request (freeze) was treated as a no-op when its target thread did not exist yet, instead of being deferred. The fix remembers the requested freeze state and applies it as soon as the worker thread is started.

Attack Path

  1. Spawn worker with slow script A page creates a dedicated worker that in turn creates a nested Worker whose script fetch is held open (the regression test uses a ControllableHttpResponse for /w2.js).
  2. Enter BFCache during fetch The page is navigated away and enters BFCache. Freeze() reaches the nested worker’s DedicatedWorkerMessagingProxy while GetWorkerThread() is still nullptr, so before the fix the request was ignored.
  3. Script arrives, worker runs The fetch completes, StartWorkerGlobalScope() starts the thread unfrozen, and the worker’s script runs while the page is cached. The test shows it posting to a BroadcastChannel that a same-origin active page receives.
  4. Persistent background execution The cached page thus keeps a live JavaScript context that can keep communicating, making network requests or using resources while the user believes they have left (the consequences beyond the BroadcastChannel message shown in the test are inferred).

Impact Assessment

Ordinary web content can cause script to keep running in the renderer for a page the user has navigated away from, defeating the BFCache freeze guarantee. This is a lifecycle and privacy-isolation bypass, not a demonstrated memory-safety bug. The only preconditions are that BFCache is eligible for the page and the attacker controls the timing of a worker script fetch. The advisory describes it only as a race condition in Workers and gives no in-the-wild status. Any effect beyond unfrozen script execution and messaging is (inferred).

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.cc
modified

Files Changed

  • content/browser/back_forward_cache_browsertest.cc
  • third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.cc
  • third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.h

Audit Directions

  • Lifecycle requests dropped on null target
    Review other worker proxies (SharedWorker, worklets, ThreadedMessagingProxyBase, service worker clients) and frame-scheduler hooks for if (!thread) return; in Freeze/Pause/Resume/Terminate paths that should defer the request.
  • Creation during freeze
    Check whether contexts created after a page is already frozen (new workers, iframes, worklets, MessagePort endpoints) inherit the frozen state at creation, not only when a later freeze broadcast reaches them.
  • Pending-state ordering
    Where deferred state is applied at start-up, verify it is applied before any script evaluation or task posting and that terminate/resume interleavings cannot leave stale pending state.
From 8f32771f758cd25294e982568b81b2522cd8659d Mon Sep 17 00:00:00 2001
From: Test User <chikamune@google.com>
Date: Wed, 01 Jul 2026 18:25:16 -0700
Subject: [PATCH] Handle freeze/resume requests for dedicated workers before their thread initialization.

Currently, when DedicatedWorkerMessagingProxy::Freeze() or Resume() is
called before the worker thread is initialized, the request is ignored
because GetWorkerThread() returns nullptr.

To address this, this CL:
- Introduces pending_freeze_is_in_back_forward_cache_ to store the
  requested freeze state when the worker thread is not yet available.
- Applies the stored freeze state to the worker thread when it is
  started in StartWorkerGlobalScope().
- Resets the stored state if Resume() is called before thread
  initialization.
- Adds a browser test to verify the freeze behavior for nested workers.

Fixed: 517467117
Change-Id: I613ced93bd3c7a029570b49fa2194563f92fdd40
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7895623
Reviewed-by: Yoshisato Yanagisawa <yyanagisawa@chromium.org>
Commit-Queue: Minoru Chikamune <chikamune@chromium.org>
Reviewed-by: Anna Sato <annasato@chromium.org>
Reviewed-by: Rakina Zata Amni <rakina@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1655853}
---
diff --git a/third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.cc b/third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.cc
index 4255301..4b30b69 100644
--- a/third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.cc
+++ b/third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.cc
@@ -116,6 +116,11 @@
     return;
   }
 
+  if (pending_freeze_is_in_back_forward_cache_.has_value()) {
+    GetWorkerThread()->Freeze(*pending_freeze_is_in_back_forward_cache_);
+    pending_freeze_is_in_back_forward_cache_.reset();
+  }
+
   // Step 13: "Obtain script by switching on the value of options's type
   // member:"
   if (options->type() == V8WorkerType::Enum::kClassic) {
@@ -216,16 +221,26 @@
 void DedicatedWorkerMessagingProxy::Freeze(bool is_in_back_forward_cache) {
   DCHECK(IsParentContextThread());
   auto* worker_thread = GetWorkerThread();
-  if (AskedToTerminate() || !worker_thread)
+  if (AskedToTerminate()) {
     return;
+  }
+  if (!worker_thread) {
+    pending_freeze_is_in_back_forward_cache_ = is_in_back_forward_cache;
+    return;
+  }
   worker_thread->Freeze(is_in_back_forward_cache);
 }
 
 void DedicatedWorkerMessagingProxy::Resume() {
   DCHECK(IsParentContextThread());
   auto* worker_thread = GetWorkerThread();
-  if (AskedToTerminate() || !worker_thread)
+  if (AskedToTerminate()) {
     return;
+  }
+  if (!worker_thread) {
+    pending_freeze_is_in_back_forward_cache_.reset();
+    return;
+  }
   worker_thread->Resume();
 }
 
diff --git a/third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.h b/third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.h
index 1a76b79..5fdef33 100644
--- a/third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.h
+++ b/third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.h
@@ -154,6 +154,9 @@
 
   // Pauses virtual time in parent context until the worker is initialized.
   WebScopedVirtualTimePauser virtual_time_pauser_;
+
+  // Tracks the freeze state when the worker thread is not yet created.
+  std::optional<bool> pending_freeze_is_in_back_forward_cache_;
 };
 
 }  // namespace blink
diff --git a/content/browser/back_forward_cache_browsertest.cc b/content/browser/back_forward_cache_browsertest.cc
index 8b84b1f2..4e728d3 100644
--- a/content/browser/back_forward_cache_browsertest.cc
+++ b/content/browser/back_forward_cache_browsertest.cc
@@ -3731,6 +3731,92 @@
   EXPECT_TRUE(rfh_c->IsHistoryUserActivationActive());
 }
 
+// Test that a nested Dedicated Worker created while the parent is waiting for
+// its script fetch is correctly frozen when the page enters the Back-Forward
+// Cache.
+IN_PROC_BROWSER_TEST_F(BackForwardCacheBrowserTest,
+                       BFCacheBypassViaUnfrozenNestedWorker) {
+  net::test_server::ControllableHttpResponse w2_response(embedded_test_server(),
+                                                         "/w2.js");
+
+  ASSERT_TRUE(embedded_test_server()->Start());
+
+  GURL url_a(embedded_test_server()->GetURL("a.com",
+                                            "/back_forward_cache/empty.html"));
+  GURL url_b(embedded_test_server()->GetURL("a.com", "/title1.html"));
+
+  // Navigate to A.
+  EXPECT_TRUE(NavigateToURL(shell(), url_a));
+  RenderFrameHostImpl* rfh_a = current_frame_host();
+  RenderFrameDeletedObserver deleted(rfh_a);
+
+  std::string w2_url = embedded_test_server()->GetURL("a.com", "/w2.js").spec();
+
+  // Create w1, which creates w2. Also set up a promise on A to listen for
+  // the message from w2 after we restore the page.
+  std::string js_script = R"(
+    window.receivedMsg = new Promise(resolve => {
+      const bc = new BroadcastChannel('bfcache_channel');
+      bc.onmessage = (e) => {
+        resolve(e.data);
+      };
+    });
+    const w1_code = `
+      const w2 = new Worker(')" +
+                          w2_url + R"(');
+    `;
+    const blob = new Blob([w1_code], {type: 'application/javascript'});
+    const w1 = new Worker(URL.createObjectURL(blob));
+    "done";
+  )";
+  EXPECT_TRUE(ExecJs(rfh_a, js_script));
+
+  // Wait for the request for w2.js to hit the server.
+  w2_response.WaitForRequest();
+
+  // Now w2 is pending fetch. Navigate to B to put A into BFCache.
+  EXPECT_TRUE(NavigateToURL(shell(), url_b));
+
+  // Ensure A is in BFCache.
+  EXPECT_FALSE(deleted.deleted());
+  EXPECT_TRUE(rfh_a->IsInBackForwardCache());
+
+  // In active page B, set up a BroadcastChannel to verify the message is not
+  // received.
+  RenderFrameHostImpl* rfh_b = current_frame_host();
+  EXPECT_TRUE(ExecJs(rfh_b, R"(
+    window.hasReceived = false;
+    const bc = new BroadcastChannel('bfcache_channel');
+    bc.onmessage = (e) => {
+      window.hasReceived = true;
+    };
+  )"));
+
+  // Now respond to the w2.js request.
+  w2_response.Send(
+      "HTTP/1.1 200 OK\r\n"
+      "Content-Type: application/javascript\r\n"
+      "\r\n"
+      "const bc = new BroadcastChannel('bfcache_channel');\n"
+      "bc.postMessage('Hello from nested worker!');\n");
+  w2_response.Done();
+
+  // Allow any outstanding tasks to execute on the renderer.
+  EXPECT_TRUE(ExecJs(rfh_b, "true"));
+
+  // Since A is in BFCache, w2 should be frozen and NOT run or send messages.
+  EXPECT_FALSE(EvalJs(rfh_b, "window.hasReceived").ExtractBool());
+
+  // Go back to page A.
+  ASSERT_TRUE(HistoryGoBack(shell()->web_contents()));
+  ExpectRestored(FROM_HERE);
+
+  // Once page A is restored (and unfrozen), w2 should resume and send the
+  // message.
+  std::string result = EvalJs(rfh_a, "window.receivedMsg").ExtractString();
+  EXPECT_EQ("Hello from nested worker!", result);
+}
+
 // BEFORE ADDING A NEW TEST HERE
 // Read the note at the top about the other files you could add it to.
 }  // namespace content
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/back_forward_cache_browsertest.cc b/content/browser/back_forward_cache_browsertest.cc
index 8b84b1f2..4e728d3 100644
--- a/content/browser/back_forward_cache_browsertest.cc
+++ b/content/browser/back_forward_cache_browsertest.cc
@@ -3731,6 +3731,92 @@
   EXPECT_TRUE(rfh_c->IsHistoryUserActivationActive());
 }
 
+// Test that a nested Dedicated Worker created while the parent is waiting for
+// its script fetch is correctly frozen when the page enters the Back-Forward
+// Cache.
+IN_PROC_BROWSER_TEST_F(BackForwardCacheBrowserTest,
+                       BFCacheBypassViaUnfrozenNestedWorker) {
+  net::test_server::ControllableHttpResponse w2_response(embedded_test_server(),
+                                                         "/w2.js");
+
+  ASSERT_TRUE(embedded_test_server()->Start());
+
+  GURL url_a(embedded_test_server()->GetURL("a.com",
+                                            "/back_forward_cache/empty.html"));
+  GURL url_b(embedded_test_server()->GetURL("a.com", "/title1.html"));
+
+  // Navigate to A.
+  EXPECT_TRUE(NavigateToURL(shell(), url_a));
+  RenderFrameHostImpl* rfh_a = current_frame_host();
+  RenderFrameDeletedObserver deleted(rfh_a);
+
+  std::string w2_url = embedded_test_server()->GetURL("a.com", "/w2.js").spec();
+
+  // Create w1, which creates w2. Also set up a promise on A to listen for
+  // the message from w2 after we restore the page.
+  std::string js_script = R"(
+    window.receivedMsg = new Promise(resolve => {
+      const bc = new BroadcastChannel('bfcache_channel');
+      bc.onmessage = (e) => {
+        resolve(e.data);
+      };
+    });
+    const w1_code = `
+      const w2 = new Worker(')" +
+                          w2_url + R"(');
+    `;
+    const blob = new Blob([w1_code], {type: 'application/javascript'});
+    const w1 = new Worker(URL.createObjectURL(blob));
+    "done";
+  )";
+  EXPECT_TRUE(ExecJs(rfh_a, js_script));
+
+  // Wait for the request for w2.js to hit the server.
+  w2_response.WaitForRequest();
+
+  // Now w2 is pending fetch. Navigate to B to put A into BFCache.
+  EXPECT_TRUE(NavigateToURL(shell(), url_b));
+
+  // Ensure A is in BFCache.
+  EXPECT_FALSE(deleted.deleted());
+  EXPECT_TRUE(rfh_a->IsInBackForwardCache());
+
+  // In active page B, set up a BroadcastChannel to verify the message is not
+  // received.
+  RenderFrameHostImpl* rfh_b = current_frame_host();
+  EXPECT_TRUE(ExecJs(rfh_b, R"(
+    window.hasReceived = false;
+    const bc = new BroadcastChannel('bfcache_channel');
+    bc.onmessage = (e) => {
+      window.hasReceived = true;
+    };
+  )"));
+
+  // Now respond to the w2.js request.
+  w2_response.Send(
+      "HTTP/1.1 200 OK\r\n"
+      "Content-Type: application/javascript\r\n"
+      "\r\n"
+      "const bc = new BroadcastChannel('bfcache_channel');\n"
+      "bc.postMessage('Hello from nested worker!');\n");
+  w2_response.Done();
+
+  // Allow any outstanding tasks to execute on the renderer.
+  EXPECT_TRUE(ExecJs(rfh_b, "true"));
+
+  // Since A is in BFCache, w2 should be frozen and NOT run or send messages.
+  EXPECT_FALSE(EvalJs(rfh_b, "window.hasReceived").ExtractBool());
+
+  // Go back to page A.
+  ASSERT_TRUE(HistoryGoBack(shell()->web_contents()));
+  ExpectRestored(FROM_HERE);
+
+  // Once page A is restored (and unfrozen), w2 should resume and send the
+  // message.
+  std::string result = EvalJs(rfh_a, "window.receivedMsg").ExtractString();
+  EXPECT_EQ("Hello from nested worker!", result);
+}
+
 // BEFORE ADDING A NEW TEST HERE
 // Read the note at the top about the other files you could add it to.
 }  // namespace content
Loading diff…

Original Bug Report

reported by vm...@google.com

BFCache freeze bypass for in-flight DedicatedWorkers allowing background JS execution

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

Overview: A potential logic bug in DedicatedWorkerMessagingProxy::Freeze causes freeze requests to be silently dropped if the child WorkerThread has not yet been initialized. Since this state is not re-evaluated after the worker thread subsequently starts, and Mojo callbacks are dispatched on an unpausable default task runner during the freeze nested loop, a child dedicated worker can successfully spawn and execute JavaScript. This allows potential background script execution while the page is supposed to be frozen in the Back-Forward Cache (BFCache).

Affected files:

  • third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.cc
  • third_party/blink/renderer/core/workers/dedicated_worker.cc
  • content/renderer/worker/dedicated_worker_host_factory_client.cc

Estimated timestamp from git blame: 2019-05-14

Potential Root Cause Analysis

When a page is navigated away and placed into the Back-Forward Cache (BFCache), the browser freezes active execution contexts and their associated workers. However, a potential logic issue exists where DedicatedWorkerMessagingProxy::Freeze() silently drops freeze requests if the child WorkerThread has not been initialized yet, with no deferred-apply mechanism:

// third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.cc
void DedicatedWorkerMessagingProxy::Freeze(bool is_in_back_forward_cache) {
  DCHECK(IsParentContextThread());
  auto* worker_thread = GetWorkerThread();
  if (AskedToTerminate() || !worker_thread)   // <-- Potential issue: freeze is dropped if the thread is not yet created
    return;
  worker_thread->Freeze(is_in_back_forward_cache);
}

Although the parent-side DedicatedWorker records requested_frozen_ = true, this flag is only evaluated during ContextLifecycleStateChanged(). It is never checked or applied by ContinueStartInternal() or StartWorkerGlobalScope() after InitializeWorkerThread() eventually creates and starts the child thread.

Enabling Primitive: Mojo Callback Dispatch on Unpausable Queue

DedicatedWorker::Start() sends a CreateWorkerHostAndStartScriptLoad IPC request to the browser. The Mojo receiver for the DedicatedWorkerHostFactoryClient is bound without specifying an explicit task runner:

// content/renderer/worker/dedicated_worker_host_factory_client.cc
factory_->CreateWorkerHostAndStartScriptLoad(
    dedicated_worker_token, script_url, credentials_mode,
    FetchClientSettingsObjectFromWebToMojom(fetch_client_settings_object),
    std::move(blob_url_token), receiver_.BindNewPipeAndPassRemote(),  // <-- Bound to default task runner
    storage_access_api_status);

When bound this way, incoming Mojo messages dispatch on the default sequenced task runner. On a worker thread, this default runner corresponds to SUBTHREAD_DEFAULT_TQ.

Crucially, PauseImpl() in WorkerSchedulerImpl only voter-disables throttleable, pausable, and web-scheduling task queues; it never disables SUBTHREAD_DEFAULT_TQ. During a freeze, the thread enters a nested loop (base::RunLoop(kNestableTasksAllowed).Run()) to process outstanding events. Because SUBTHREAD_DEFAULT_TQ remains active, the Mojo reply from the browser can still be received and processed during this nested loop, allowing the nested worker to finish initializing and running completely unfrozen.


Potential Trigger Path (Nested-Worker Case)

  1. A page spawns a parent worker: const parent = new Worker('parent.js').
  2. Inside parent.js, a child worker is instantiated: const child = new Worker('child.js'). This posts the CreateWorkerHostAndStartScriptLoad IPC while the child’s worker_thread_ on the proxy is still nullptr.
  3. The page navigates away, entering BFCache. The main thread signals the parent worker thread to freeze.
  4. The parent worker thread transitions to PauseOrFreezeOnWorkerThread(kFrozen, true):
    • It iterates through observers, calling ContextLifecycleStateChanged(kFrozen) on the child DedicatedWorker instance. This sets requested_frozen_ = true and invokes context_proxy_->Freeze(true), which is silently dropped because the child worker thread is still nullptr.
    • It enters a nested run loop via base::RunLoop(kNestableTasksAllowed).Run() with certain task queues disabled, but SUBTHREAD_DEFAULT_TQ remains enabled.
  5. The browser replies with OnScriptLoadStarted on the parent worker’s SUBTHREAD_DEFAULT_TQ queue.
  6. The handler is processed, executing DedicatedWorker::OnScriptLoadStarted -> ContinueStartInternal() -> DedicatedWorkerMessagingProxy::StartWorkerGlobalScope().
  7. The child DedicatedWorkerThread is created and started. Because there is no check for the pre-existing requested_frozen_ state during startup, the child worker thread starts running its JS completely unfrozen.

Note: Our tooling agent currently does not have the ability to run code, so these are potential steps derived from static code flow analysis.


Potential Impact

An attacker page stored in the BFCache can maintain an active dedicated-worker thread executing arbitrary JavaScript for the entire duration of its cache residency. This thread can perform network fetches, issue beacons, interact with IndexedDB, and queue postMessage calls to be delivered back to the parent document upon restoration. This completely bypasses the BFCache freeze constraints designed to preserve system resources and protect user privacy.


Suggested Fix

To prevent this freeze bypass, DedicatedWorkerMessagingProxy should track the requested freeze state even before the thread starts, and apply it upon initialization.

  1. Add a pending_freeze_ state to DedicatedWorkerMessagingProxy (and ThreadedMessagingProxyBase if appropriate):
    std::optional<bool> pending_freeze_;
    
  2. In DedicatedWorkerMessagingProxy::Freeze(bool is_in_back_forward_cache):
    void DedicatedWorkerMessagingProxy::Freeze(bool is_in_back_forward_cache) {
      DCHECK(IsParentContextThread());
      auto* worker_thread = GetWorkerThread();
      if (!worker_thread) {
        pending_freeze_ = is_in_back_forward_cache;
        return;
      }
      ...
    }
    
  3. During worker thread initialization inside InitializeWorkerThread, check if a freeze is pending and apply it immediately:
    if (pending_freeze_.has_value()) {
      worker_thread_->Freeze(pending_freeze_.value());
      pending_freeze_.reset();
    }
    

Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8


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.

View on issue tracker