High patch-now ✔︎ verified CVSS 8.3 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in ServiceWorker
DescriptionUse after free in ServiceWorker
ComponentServiceWorker
Sub-componentService Workers
Bug ClassUAF
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — upheld by independent review by AI
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.
Independent review by AIConfirmed
Reviewer notes: Mechanism confirmed by diff+regression test. But web-only reachability unproven: resurrection needs a synchronous observer calling GetLiveVersion during teardown; test uses a test-only observer, no real web-triggerable caller shown.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker513424000
Fix commitcc9b3ce9c019 (chromium/src) +88/-4
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-05-15 (per vendor advisory)
Fix landed2026-05-22 (public git)
Disclosed2026-06-08
Patch gap17 days — fix public in source before the advisory

Background

`ServiceWorkerContextCore::RemoveLiveVersion`
The browser-process method that removes a ServiceWorkerVersion from the live_versions_ map when it is being destroyed.
`live_versions_`
A map from version id to a raw ServiceWorkerVersion* of currently-live versions, queried by GetLiveVersion(id).
Synchronous observers
Observers in sync_observer_list_ whose OnStoppedSync callbacks run inline during RemoveLiveVersion, unlike observer_list_ which is a base::ObserverListThreadSafe dispatching via PostTask.
`scoped_refptr` resurrection
Wrapping a raw pointer whose object is mid-destruction into a new scoped_refptr, which later drops to zero and triggers a second destruction (double free).

Root Cause Analysis

RemoveLiveVersion looked up the version in live_versions_, notified observers, and only erased the map entry at the very end of the method. Because OnStoppedSync runs synchronously while the ServiceWorkerVersion is already undergoing destruction, a synchronous observer could call GetLiveVersion(id), find the still-present raw pointer, and wrap it in a fresh scoped_refptr; when that temporary scoped_refptr later went out of scope it decremented the refcount to zero again, driving a second destruction and thus a double-free / use-after-free. The invariant violated is that an object being destroyed must no longer be discoverable through the live registry.

The fix moves live_versions_.erase(it) to the very beginning of the method, immediately after fetching the raw pointer, so that by the time any observer runs GetLiveVersion(id) returns null and cannot resurrect the version. Per the commit, this only affects synchronous observers; asynchronous observer_list_ notifications already ran after the erase because they are dispatched via PostTask on the same UI thread.

Key insight
The single mistake was leaving the dying version discoverable in live_versions_ while notifying synchronous observers, allowing resurrection into a new scoped_refptr; the fix erases the map entry before any notification so lookups return null.

Attack Path

  1. Start a worker to arm sync notification A service worker is started so running_status() is kStarting/running, which guarantees RemoveLiveVersion will notify OnStoppedSync observers during teardown (per the regression test).
  2. Drop all references All other references to the ServiceWorkerVersion are released so the final scoped_refptr drop begins destruction and invokes RemoveLiveVersion (regression test drops timer, worker host, and registration).
  3. Re-acquire via synchronous observer During the inline OnStoppedSync callback, code calls GetLiveVersion(version_id), which pre-patch returned the raw pointer still present in live_versions_, and wraps it in a new scoped_refptr.
  4. Second destruction When that scoped_refptr goes out of scope the refcount reaches zero a second time, triggering a double-free / use-after-free of the ServiceWorkerVersion.
  5. Exploitation The double-freed version memory could be reallocated and manipulated to corrupt browser-process state. (inferred)

Impact Assessment

An attacker gains a re-entrancy-driven double-free / use-after-free of a ServiceWorkerVersion in the browser process, requiring the ability to drive a worker’s start/stop lifecycle and to run within (or influence) a synchronous observer that calls GetLiveVersion during teardown. The advisory classifies this as a use-after-free in ServiceWorker with no exploited-in-the-wild status stated; escalation beyond the memory-safety violation is (inferred).

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

Audit Directions

  • Erase-after-notify ordering
    Audit destruction/removal methods that notify observers or run callbacks before removing the object from its owning/registry container; the removal must precede any synchronous notification.
  • Raw-pointer registries resurrectable into `scoped_refptr`
    Search for maps of raw pointers to refcounted objects (e.g., GetLiveXxx accessors) where a caller can wrap a mid-destruction pointer in a fresh scoped_refptr.
  • Synchronous vs. asynchronous observer paths
    Review code that mixes ObserverListThreadSafe (async PostTask) with inline synchronous observer lists, since only the synchronous path exposes re-entrancy during teardown.
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