Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in Workers
DescriptionRace condition in Workers
ComponentWorkers
Bug ClassRace
Tracker517467117
Fix commit8f32771f758c (chromium/src) +106/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

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
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/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
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
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