CVE-2026-11656
Overview
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
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.