CVE-2026-11656
Overview
Background
- `ServiceWorkerContextCore::RemoveLiveVersion`
- The browser-process method that removes a
ServiceWorkerVersionfrom thelive_versions_map when it is being destroyed. - `live_versions_`
- A map from version id to a raw
ServiceWorkerVersion*of currently-live versions, queried byGetLiveVersion(id). - Synchronous observers
- Observers in
sync_observer_list_whoseOnStoppedSynccallbacks run inline duringRemoveLiveVersion, unlikeobserver_list_which is abase::ObserverListThreadSafedispatching viaPostTask. - `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.
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
- Start a worker to arm sync notification
A service worker is started so
running_status()iskStarting/running, which guaranteesRemoveLiveVersionwill notifyOnStoppedSyncobservers during teardown (per the regression test). - Drop all references
All other references to the
ServiceWorkerVersionare released so the finalscoped_refptrdrop begins destruction and invokesRemoveLiveVersion(regression test drops timer, worker host, and registration). - Re-acquire via synchronous observer
During the inline
OnStoppedSynccallback, code callsGetLiveVersion(version_id), which pre-patch returned the raw pointer still present inlive_versions_, and wraps it in a newscoped_refptr. - Second destruction
When that
scoped_refptrgoes out of scope the refcount reaches zero a second time, triggering a double-free / use-after-free of theServiceWorkerVersion. - Exploitation The double-freed version memory could be reallocated and manipulated to corrupt browser-process state. (inferred)
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
forcontent/browser/service_worker/service_worker_context_core.cc |
modified | |
SyncObserverForTestcontent/browser/service_worker/service_worker_version_unittest.cc |
modified | |
ifcontent/browser/service_worker/service_worker_version_unittest.cc |
modified | |
TEST_Pcontent/browser/service_worker/service_worker_version_unittest.cc |
modified |
Files Changed
content/browser/service_worker/service_worker_context_core.cccontent/browser/service_worker/service_worker_version_unittest.cc
Audit Directions
- Erase-after-notify orderingAudit 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.,
GetLiveXxxaccessors) where a caller can wrap a mid-destruction pointer in a freshscoped_refptr. - Synchronous vs. asynchronous observer pathsReview code that mixes
ObserverListThreadSafe(asyncPostTask) with inline synchronous observer lists, since only the synchronous path exposes re-entrancy during teardown.
Patch
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
Regression Test / PoC
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
Original Bug Report
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.cccontent/browser/service_worker/service_worker_version.cccontent/browser/service_worker/service_worker_context_wrapper.ccextensions/browser/service_worker/service_worker_state.ccextensions/browser/service_worker/service_worker_task_queue.ccextensions/browser/process_manager.ccextensions/browser/event_router.ccextensions/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
- Double Destruction: The
ServiceWorkerVersiondestructor runs twice, leading to a double-free of its member objects (e.g.,EmbeddedWorkerInstance). - Re-entrant Map Erasure: The inner (re-entrant)
RemoveLiveVersioncall erases the entry fromlive_versions_. When the outer call resumes, its iteratoritis invalidated, and the subsequentlive_versions_.erase(it)causes undefined behavior/heap corruption. - Use-After-Free: After the notification loop, the outer
RemoveLiveVersionaccesses the now-deleted object viaversion->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:
- Ensure the extension has a background service worker and an active “unacked event” (e.g., by sending a message that hasn’t been acknowledged).
- Trigger the destruction of the
ServiceWorkerVersion(for example, by causing the worker to fail during startup or by reloading the extension). - The synchronous notification chain triggered during destruction will reach
FinishedExternalRequest, causing the resurrection and subsequent double-free of theServiceWorkerVersionobject.
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.