Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in ServiceWorker
DescriptionUse after free in ServiceWorker
ComponentServiceWorker
Bug ClassUAF
Tracker513424000
Fix commitcc9b3ce9c019 (chromium/src) +88/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-08

Changed Functions

FunctionChangeNotes
for
content/browser/service_worker/service_worker_context_core.cc
modified
SyncObserverForTest
content/browser/service_worker/service_worker_version_unittest.cc
modified
if
content/browser/service_worker/service_worker_version_unittest.cc
modified
TEST_P
content/browser/service_worker/service_worker_version_unittest.cc
modified

Files Changed

  • content/browser/service_worker/service_worker_context_core.cc
  • content/browser/service_worker/service_worker_version_unittest.cc
From cc9b3ce9c019c845ada1d095d1653f6f748408ae Mon Sep 17 00:00:00 2001
From: Andrea Orru <andreaorru@chromium.org>
Date: Fri, 22 May 2026 10:35:16 -0700
Subject: [PATCH] [Service Workers] Fix re-entrancy UAF/double-free in RemoveLiveVersion

Erase the version from live_versions_ before notifying synchronous
observers in ServiceWorkerContextCore::RemoveLiveVersion.

Previously, synchronous observers could re-acquire a reference to the
version undergoing destruction by calling GetLiveVersion(), which
wrapped the raw pointer in a scoped_refptr and triggered a second
destruction when it went out of scope.

Moving the live_versions_.erase() call to the beginning of the method
strictly protects synchronous observers. It does not affect standard
asynchronous observers, as observer_list_ is a
base::ObserverListThreadSafe and dispatches notifications asynchronously
via PostTask. Because RemoveLiveVersion() runs synchronously on the UI
thread, the erase completes before any queued asynchronous observer
tasks can be executed by the event loop. Therefore, the version is
already removed from live_versions_ by the time asynchronous callbacks
run in both the previous and updated implementations.

Fixed: 513424000
Change-Id: Iacf4225cfed9a7c21c7e785a1bee42145e44c5f2
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7853122
Commit-Queue: Andrea Orru <andreaorru@chromium.org>
Reviewed-by: Yoshisato Yanagisawa <yyanagisawa@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1635063}
---

diff --git a/content/browser/service_worker/service_worker_context_core.cc b/content/browser/service_worker/service_worker_context_core.cc
index 0f171f67..b17a7ad 100644
--- a/content/browser/service_worker/service_worker_context_core.cc
+++ b/content/browser/service_worker/service_worker_context_core.cc
@@ -960,10 +960,13 @@
   auto it = live_versions_.find(id);
   CHECK(it != live_versions_.end());
   ServiceWorkerVersion* version = it->second;
+  // Erase from the map before notifying observers to prevent re-entrancy:
+  // synchronous observers could otherwise look up this version via
+  // `GetLiveVersion()` and resurrect it with a new `scoped_refptr`.
+  live_versions_.erase(it);
 
   if (version->running_status() != blink::EmbeddedWorkerStatus::kStopped) {
-    // Notify all observers that this live version is stopped, as it will
-    // be removed from |live_versions_|.
+    // Notify all observers that this live version is stopped.
     observer_list_->Notify(FROM_HERE,
                            &ServiceWorkerContextCoreObserver::OnStopped, id);
     for (auto& observer : sync_observer_list_->observers) {
@@ -982,8 +985,6 @@
 
   observer_list_->Notify(
       FROM_HERE, &ServiceWorkerContextCoreObserver::OnLiveVersionDestroyed, id);
-
-  live_versions_.erase(it);
 }
 
 std::vector<ServiceWorkerRegistrationInfo>
diff --git a/content/browser/service_worker/service_worker_version_unittest.cc b/content/browser/service_worker/service_worker_version_unittest.cc
index 878b2a06..7b1cbbf3 100644
--- a/content/browser/service_worker/service_worker_version_unittest.cc
+++ b/content/browser/service_worker/service_worker_version_unittest.cc
@@ -158,6 +158,14 @@
     return version->ping_controller_.IsActivated();
   }
 
+  void StopTimeoutTimer(ServiceWorkerVersion* version) const {
+    version->StopTimeoutTimer();
+  }
+
+  void ResetWorkerHost(ServiceWorkerVersion* version) const {
+    version->worker_host_.reset();
+  }
+
   void NotifyScriptEvaluationStart(ServiceWorkerVersion* version) {
     version->OnScriptEvaluationStart();
   }
@@ -2455,5 +2463,80 @@
   EXPECT_FALSE(is_update_scheduled());
 }
 
+// A synchronous observer that checks whether the `ServiceWorkerVersion` is
+// still discoverable in `ServiceWorkerContextCore` while `OnStoppedSync()` is
+// executing. Part of the regression test for https://crbug.com/513424000.
+class SyncObserverForTest : public ServiceWorkerContextObserverSynchronous {
+ public:
+  SyncObserverForTest(ServiceWorkerContextWrapper* wrapper,
+                      int64_t expected_version_id)
+      : wrapper_(wrapper), expected_version_id_(expected_version_id) {}
+
+  void OnStoppedSync(
+      int64_t version_id,
+      const GURL& scope,
+      const blink::ServiceWorkerToken& service_worker_token) override {
+    if (version_id != expected_version_id_) {
+      return;
+    }
+
+    called_ = true;
+    // Verify that the version is erased from `live_versions_` before observers
+    // are notified. If it were still in the map, looking it up would wrap a raw
+    // pointer undergoing destruction into a new `scoped_refptr`, causing a
+    // double free.
+    EXPECT_FALSE(wrapper_->GetLiveVersion(version_id));
+  }
+
+  bool called() const { return called_; }
+
+ private:
+  raw_ptr<ServiceWorkerContextWrapper> wrapper_;
+  int64_t expected_version_id_;
+  bool called_ = false;
+};
+
+// Verifies that during the destruction of a `ServiceWorkerVersion`, synchronous
+// observers notified via `OnStoppedSync()` cannot re-acquire a reference to the
+// version being destroyed (e.g. via `GetLiveVersion()`). This ensures the
+// version is erased from the live map prior to observer notifications,
+// preventing double-free and use-after-free vulnerabilities.
+// Regression test for https://crbug.com/513424000.
+TEST_P(ServiceWorkerVersionTest, RemoveLiveVersion_NoResurrection) {
+  // Ensure the version starts with exactly 1 reference, representing the sole
+  // reference held by `version_`.
+  EXPECT_TRUE(version_->HasOneRef());
+
+  // Initiate starting the worker. This transitions `running_status` to
+  // `kStarting`, which ensures that when the version is destroyed,
+  // `RemoveLiveVersion()` will notify `OnStoppedSync()` observers.
+  version_->StartWorker(ServiceWorkerMetrics::EventType::UNKNOWN,
+                        base::DoNothing());
+  EXPECT_EQ(blink::EmbeddedWorkerStatus::kStarting, version_->running_status());
+
+  // Attach a synchronous observer to intercept `OnStoppedSync()`.
+  SyncObserverForTest sync_observer(helper_->context_wrapper(),
+                                    version_->version_id());
+  helper_->context_wrapper()->AddSyncObserver(&sync_observer);
+
+  // Drop all internal references (e.g., pending timer callbacks and worker
+  // hosts) that were created by `StartWorker()`, so that `version_` remains the
+  // only reference.
+  StopTimeoutTimer(version_.get());
+  ResetWorkerHost(version_.get());
+  registration_.reset();
+  EXPECT_TRUE(version_->HasOneRef());
+
+  // Trigger destruction of the version. As part of destruction,
+  // `RemoveLiveVersion()` is invoked. The observer verifies that the version is
+  // erased from the live map prior to the observer notification.
+  version_.reset();
+  EXPECT_TRUE(sync_observer.called());
+
+  // Unregister the observer before it goes out of scope to prevent dangling
+  // pointer crashes during fixture teardown.
+  helper_->context_wrapper()->RemoveSyncObserver(&sync_observer);
+}
+
 }  // namespace service_worker_version_unittest
 }  // namespace content
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/service_worker/service_worker_version_unittest.cc b/content/browser/service_worker/service_worker_version_unittest.cc
index 878b2a06..7b1cbbf3 100644
--- a/content/browser/service_worker/service_worker_version_unittest.cc
+++ b/content/browser/service_worker/service_worker_version_unittest.cc
@@ -158,6 +158,14 @@
     return version->ping_controller_.IsActivated();
   }
 
+  void StopTimeoutTimer(ServiceWorkerVersion* version) const {
+    version->StopTimeoutTimer();
+  }
+
+  void ResetWorkerHost(ServiceWorkerVersion* version) const {
+    version->worker_host_.reset();
+  }
+
   void NotifyScriptEvaluationStart(ServiceWorkerVersion* version) {
     version->OnScriptEvaluationStart();
   }
@@ -2455,5 +2463,80 @@
   EXPECT_FALSE(is_update_scheduled());
 }
 
+// A synchronous observer that checks whether the `ServiceWorkerVersion` is
+// still discoverable in `ServiceWorkerContextCore` while `OnStoppedSync()` is
+// executing. Part of the regression test for https://crbug.com/513424000.
+class SyncObserverForTest : public ServiceWorkerContextObserverSynchronous {
+ public:
+  SyncObserverForTest(ServiceWorkerContextWrapper* wrapper,
+                      int64_t expected_version_id)
+      : wrapper_(wrapper), expected_version_id_(expected_version_id) {}
+
+  void OnStoppedSync(
+      int64_t version_id,
+      const GURL& scope,
+      const blink::ServiceWorkerToken& service_worker_token) override {
+    if (version_id != expected_version_id_) {
+      return;
+    }
+
+    called_ = true;
+    // Verify that the version is erased from `live_versions_` before observers
+    // are notified. If it were still in the map, looking it up would wrap a raw
+    // pointer undergoing destruction into a new `scoped_refptr`, causing a
+    // double free.
+    EXPECT_FALSE(wrapper_->GetLiveVersion(version_id));
+  }
+
+  bool called() const { return called_; }
+
+ private:
+  raw_ptr<ServiceWorkerContextWrapper> wrapper_;
+  int64_t expected_version_id_;
+  bool called_ = false;
+};
+
+// Verifies that during the destruction of a `ServiceWorkerVersion`, synchronous
+// observers notified via `OnStoppedSync()` cannot re-acquire a reference to the
+// version being destroyed (e.g. via `GetLiveVersion()`). This ensures the
+// version is erased from the live map prior to observer notifications,
+// preventing double-free and use-after-free vulnerabilities.
+// Regression test for https://crbug.com/513424000.
+TEST_P(ServiceWorkerVersionTest, RemoveLiveVersion_NoResurrection) {
+  // Ensure the version starts with exactly 1 reference, representing the sole
+  // reference held by `version_`.
+  EXPECT_TRUE(version_->HasOneRef());
+
+  // Initiate starting the worker. This transitions `running_status` to
+  // `kStarting`, which ensures that when the version is destroyed,
+  // `RemoveLiveVersion()` will notify `OnStoppedSync()` observers.
+  version_->StartWorker(ServiceWorkerMetrics::EventType::UNKNOWN,
+                        base::DoNothing());
+  EXPECT_EQ(blink::EmbeddedWorkerStatus::kStarting, version_->running_status());
+
+  // Attach a synchronous observer to intercept `OnStoppedSync()`.
+  SyncObserverForTest sync_observer(helper_->context_wrapper(),
+                                    version_->version_id());
+  helper_->context_wrapper()->AddSyncObserver(&sync_observer);
+
+  // Drop all internal references (e.g., pending timer callbacks and worker
+  // hosts) that were created by `StartWorker()`, so that `version_` remains the
+  // only reference.
+  StopTimeoutTimer(version_.get());
+  ResetWorkerHost(version_.get());
+  registration_.reset();
+  EXPECT_TRUE(version_->HasOneRef());
+
+  // Trigger destruction of the version. As part of destruction,
+  // `RemoveLiveVersion()` is invoked. The observer verifies that the version is
+  // erased from the live map prior to the observer notification.
+  version_.reset();
+  EXPECT_TRUE(sync_observer.called());
+
+  // Unregister the observer before it goes out of scope to prevent dangling
+  // pointer crashes during fixture teardown.
+  helper_->context_wrapper()->RemoveSyncObserver(&sync_observer);
+}
+
 }  // namespace service_worker_version_unittest
 }  // namespace content
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential Double-free and UAF in ServiceWorkerVersion via re-entrant RemoveLiveVersion

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 re-entrancy vulnerability in ServiceWorkerContextCore::RemoveLiveVersion allows observers to re-acquire a reference to a ServiceWorkerVersion object currently undergoing destruction. This can lead to a double-free and use-after-free (UAF) in the browser process, potentially resulting in memory corruption.

Affected files:

  • content/browser/service_worker/service_worker_context_core.cc
  • content/browser/service_worker/service_worker_version.cc
  • content/browser/service_worker/service_worker_context_wrapper.cc
  • extensions/browser/service_worker/service_worker_state.cc
  • extensions/browser/service_worker/service_worker_task_queue.cc
  • extensions/browser/process_manager.cc
  • extensions/browser/event_router.cc
  • extensions/browser/events/event_ack_data.cc

Estimated timestamp from git blame: 2025-09-25

Description

A potential memory safety vulnerability exists in the Chromium browser process due to the way ServiceWorkerVersion objects are removed from ServiceWorkerContextCore during their destruction. The issue stems from notifying synchronous observers while the object is still present in the context’s internal tracking map.

Root Cause Analysis

When a ServiceWorkerVersion’s reference count reaches zero, its destructor ~ServiceWorkerVersion() is invoked. This destructor calls ServiceWorkerContextCore::RemoveLiveVersion(version_id_) to remove the version from the live_versions_ map.

Inside RemoveLiveVersion(), if the worker is not in the kStopped state, the code notifies synchronous observers via OnStoppedSync. Crucially, the version is not yet removed from the live_versions_ map during this notification.

// content/browser/service_worker/service_worker_context_core.cc
void ServiceWorkerContextCore::RemoveLiveVersion(int64_t id) {
  auto it = live_versions_.find(id);
  CHECK(it != live_versions_.end());
  ServiceWorkerVersion* version = it->second;

  if (version->running_status() != blink::EmbeddedWorkerStatus::kStopped) {
    for (auto& observer : sync_observer_list_->observers) {
      observer.OnStoppedSync(id, version->scope(),
                             *version->start_worker_token());
    }
  }
  // ...
  live_versions_.erase(it);
}

In the extensions system, extensions::ServiceWorkerState::OnStoppedSync initiates a synchronous call chain: ServiceWorkerState::HandleStop -> ServiceWorkerTaskQueue::OnWorkerStop -> ProcessManager::StopTrackingServiceWorkerRunningInstance -> EventRouter::OnStoppedTrackingServiceWorkerInstance -> EventAckData::ClearUnackedEventsForWorker -> ServiceWorkerContextWrapper::FinishedExternalRequest.

FinishedExternalRequest calls context()->GetLiveVersion(service_worker_version_id). Since the entry has not yet been erased from the map, it retrieves a raw pointer to the object being destroyed. It then wraps this pointer in a scoped_refptr<ServiceWorkerVersion>.

In release builds, base::RefCounted::AddRef() does not check if the object is being destroyed (the in_dtor_ check is a DCHECK). The reference count increments from 0 to 1. When the scoped_refptr goes out of scope at the end of the function, the count returns to zero, and a second delete this is executed on the same memory.

Potential Consequences

  1. Double Destruction: The ServiceWorkerVersion destructor runs twice, leading to a double-free of its member objects (e.g., EmbeddedWorkerInstance).
  2. Re-entrant Map Erasure: The inner (re-entrant) RemoveLiveVersion call erases the entry from live_versions_. When the outer call resumes, its iterator it is invalidated, and the subsequent live_versions_.erase(it) causes undefined behavior/heap corruption.
  3. Use-After-Free: After the notification loop, the outer RemoveLiveVersion accesses the now-deleted object via version->reporting_source(), leading to a UAF read.

Potential Steps to Reproduce

An attacker controlling an extension renderer might trigger this via the following potential steps:

  1. Ensure the extension has a background service worker and an active “unacked event” (e.g., by sending a message that hasn’t been acknowledged).
  2. Trigger the destruction of the ServiceWorkerVersion (for example, by causing the worker to fail during startup or by reloading the extension).
  3. The synchronous notification chain triggered during destruction will reach FinishedExternalRequest, causing the resurrection and subsequent double-free of the ServiceWorkerVersion object.

Suggested Fix

Remove the ServiceWorkerVersion from the live_versions_ map before notifying synchronous observers in ServiceWorkerContextCore::RemoveLiveVersion. This ensures that any re-entrant calls to GetLiveVersion will fail to find the object being destroyed.

Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e


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