← Chrome — 2026-W38

e8c064e9c3badba85b53d718c3bb4279a49611c2  [M150-LTS] Fix Browser-process Heap-UAF in LockManager caused by stale SharedWorkerHost

severity high class UAF CVSS 8.8 confidence 0.9 signal score 7.3 content/browser/locks patch-now AI review CONFIRMED
Primitive: SharedWorkerHost outlives its renderer process, leaving a dangling observer in LockManager::client_observer_map_ that a later lock-contention notification dereferences.
Triage note: Unconditional AddLockObserver vs conditional RemoveLockObserver (inside if(HasProcess())); browser-process heap-UAF reachable from a SharedWorker using the Web Locks API. Verified CONFIRMED.

The bug at a glance

A dangling Observer* left in LockManager::client_observer_map_ is dereferenced in the privileged browser process (out of the renderer sandbox) during Web Locks contention, giving an attacker-influenced use-after-free of a freed SharedWorkerHost. The trigger is fully web-reachable from an ordinary page via the standard SharedWorker + navigator.locks APIs with no special permission, so attack vector is Network, complexity is Low-to-High (a process-teardown-vs-contention race must be won), privileges/user-interaction are None, and the impact is high across confidentiality, integrity and availability because a browser-process heap-UAF is a classic renderer-to-browser sandbox-escape primitive. CVSS 8.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) captures this; the residual race is the only reason it is not maximal. Severity high per Chrome’s severity guidelines for a browser-process memory-corruption bug reachable from web content.

The lifetime of the Web Locks observer registration was not symmetric with the lifetime of the SharedWorkerHost that owns it. Registration in CreateLockManager() is unconditional, but pre-fix deregistration in ~SharedWorkerHost() sat inside an if (site_instance_->HasProcess()) block. The observer object (the SharedWorkerHost, which implements Observer) can outlive its renderer process, and when it does, its destructor runs but never calls RemoveLockObserver, so LockManager::client_observer_map_ keeps a pointer to memory that is about to be freed. The angle is the asymmetry between an always-taken registration path and a conditionally-taken cleanup path gated on renderer-process liveness.

Root cause

SharedWorkerHost derives from LockManager’s Observer interface. In SharedWorkerHost::CreateLockManager() the host calls GetStoragePartitionImpl()->GetLockManager()->AddLockObserver(token().value(), this) unconditionally, storing this (a raw Observer*) into LockManager::client_observer_map_ keyed by the worker token string. Pre-fix, the reverse call lived inside if (site_instance_->HasProcess()) in ~SharedWorkerHost(): only when the SiteInstance still had a live RenderProcessHost did the destructor reach if (auto* lock_manager = GetStoragePartitionImpl()->GetLockManager()) lock_manager->RemoveLockObserver(token().value()). Two things went wrong in the no-process case: (1) the guard was false, so the RemoveLockObserver branch was skipped entirely, and (2) even had it run, the pre-fix GetStoragePartitionImpl() resolved the partition via GetProcessHost()->GetStoragePartition(), which is not reliably valid once the process is gone. The result is that ~SharedWorkerHost() frees the host while client_observer_map_ still holds a raw pointer to it. A subsequent lock-contention event on the same lock group (a second client requesting a held lock) causes LockManager to look up and invoke the stale Observer* (via the OnLockContention/notification path that SharedWorkerHost::OnLockContention services), dereferencing freed heap memory in the browser process. The fix has three coordinated parts: (a) the RemoveLockObserver block is hoisted above the if (site_instance_->HasProcess()) guard so it runs unconditionally on every destruction; (b) GetStoragePartitionImpl() is changed to return service_->storage_partition() instead of GetProcessHost()->GetStoragePartition() — this is the ENABLER for (a), because the SharedWorkerServiceImpl-owned storage partition is valid for the entire lifetime of the host, so the unconditional RemoveLockObserver can always resolve a live LockManager even after the process exited (it is not a separate live bug); (c) client_observer_map_ is hardened from base::flat_map<std::string, Observer*> to base::flat_map<std::string, raw_ptr> so MiraclePtr provides defense-in-depth against any remaining dangling pointer.

Key code

Before/after: the ~SharedWorkerHost() reorder (RemoveLockObserver hoisted above the HasProcess() guard), the GetStoragePartitionImpl() lifetime fix that enables it, and the raw_ptr hardening of the observer map.

// BEFORE — content/browser/worker_host/shared_worker_host.cc
SharedWorkerHost::~SharedWorkerHost() {
  // ...
  if (site_instance_->HasProcess()) {
    // Send any final reports and allow the reporting configuration to be removed.
    // ...
    GetProcessHost()->RemoveObserver(this);

    // Deregistration was GATED on the process still being alive:
    if (auto* lock_manager = GetStoragePartitionImpl()->GetLockManager()) {
      lock_manager->RemoveLockObserver(token().value());   // <-- SKIPPED if no process
    }

    GetStoragePartitionImpl()->ClearNetworkRestrictionsAfterDelay({
        network_restrictions_id_,
    });
  }
}

StoragePartitionImpl* SharedWorkerHost::GetStoragePartitionImpl() {
  // Resolved through the process host — unreliable once the process is gone:
  return static_cast<StoragePartitionImpl*>(
      GetProcessHost()->GetStoragePartition());
}

// AFTER — content/browser/worker_host/shared_worker_host.cc
SharedWorkerHost::~SharedWorkerHost() {
  // ...
  // UNCONDITIONAL deregistration, hoisted ABOVE the HasProcess() guard:
  if (auto* lock_manager = GetStoragePartitionImpl()->GetLockManager()) {
    lock_manager->RemoveLockObserver(token().value());
  }

  if (site_instance_->HasProcess()) {
    // ...
    GetProcessHost()->RemoveObserver(this);
    GetStoragePartitionImpl()->ClearNetworkRestrictionsAfterDelay({
        network_restrictions_id_,
    });
  }
}

StoragePartitionImpl* SharedWorkerHost::GetStoragePartitionImpl() {
  // Service-owned partition: valid for the entire lifetime of the host,
  // so the unconditional RemoveLockObserver above can always resolve it.
  return service_->storage_partition();
}

// AFTER — content/browser/locks/lock_manager.h  (defense-in-depth)
// base::flat_map<std::string, Observer*> client_observer_map_;
base::flat_map<std::string, raw_ptr<Observer>> client_observer_map_;

// Registration side is unchanged and unconditional — the asymmetry that caused the bug:
// SharedWorkerHost::CreateLockManager()
GetStoragePartitionImpl()->GetLockManager()->AddLockObserver(token().value(), this);

Patch walkthrough

  • content/browser/worker_host/shared_worker_host.cc — The core fix. The RemoveLockObserver block is moved out of the if (site_instance_->HasProcess()) branch and placed above it, so every ~SharedWorkerHost() deregisters the observer regardless of whether the renderer process still exists. GetStoragePartitionImpl() is rewritten to return service_->storage_partition() (owned by SharedWorkerServiceImpl, lifetime-stable) instead of GetProcessHost()->GetStoragePartition(); this is what makes the unconditional removal actually resolve a valid LockManager after process exit. A whitespace-only realignment of the AddLockObserver(token().value(), this) call in CreateLockManager() also appears but is inert.
  • content/browser/locks/lock_manager.h — Hardens client_observer_map_ from base::flat_map<std::string, Observer*> to base::flat_map<std::string, raw_ptr>. This is defense-in-depth: MiraclePtr (BackupRefPtr) can quarantine the allocation and turn a residual dangling-pointer dereference into a safe crash rather than exploitable corruption. It does not by itself fix the missing deregistration; the .cc change does.
  • content/browser/worker_host/shared_worker_host_unittest.cc — The regression/coverage change. SharedWorkerHostTest’s fixture now constructs its SharedWorkerServiceImpl with a real StoragePartition (browser_context_.GetDefaultStoragePartition() cast to StoragePartitionImpl*) instead of nullptr. Because GetStoragePartitionImpl() now dereferences service_->storage_partition(), the test needs a non-null partition; this also lets the test exercise the observer add/remove path through a live LockManager. Adds the storage_partition_impl.h include.
  • content/browser/devtools/shared_worker_devtools_agent_host_unittest.cc — Parallel test update for the same constructor-signature dependency: service1 and service2 are now built with regular_context / incognito_context GetDefaultStoragePartition() cast to StoragePartitionImpl* instead of (nullptr, nullptr), so these tests keep compiling and running against the new lifetime-stable partition resolution. Adds the storage_partition_impl.h include. Behaviorally a build/coverage fix, not part of the vulnerability.

Background

SharedWorkerHost — Browser-process object that manages one shared worker’s lifetime and its Mojo interface bindings. It is destroyed when the worker terminates; crucially it can be destroyed AFTER its renderer process has already exited, which is exactly the window the bug lives in (site_instance_->HasProcess() is false at destruction).

Web Locks API / navigator.locks — Web-exposed API letting same-origin contexts (including workers) acquire named locks. Browser-side it is served by LockManager; contention (a request for a lock already held) drives notifications to registered observers, which is the code path that dereferences the stale pointer.

LockManager::Observer and client_observer_map_ — SharedWorkerHost implements LockManager’s Observer interface (e.g. OnLockContention). LockManager stores each registered observer as a raw pointer in client_observer_map_, keyed by the worker token string. The map holds no ownership, so a stale entry is a dangling pointer until explicitly removed via RemoveLockObserver(token).

AddLockObserver / RemoveLockObserver symmetry — CreateLockManager() calls AddLockObserver unconditionally, so correctness requires RemoveLockObserver to run on every destruction. The pre-fix gating of removal on renderer-process liveness broke that invariant.

raw_ptr (MiraclePtr / BackupRefPtr) — Chrome’s smart raw pointer that adds use-after-free mitigation: dereferencing a dangling raw_ptr can be turned into a controlled crash rather than exploitable memory reuse. Applied here to client_observer_map_ values as belt-and-suspenders alongside the real lifetime fix.

StoragePartitionImpl resolution — GetStoragePartitionImpl() previously hopped through GetProcessHost()->GetStoragePartition(), coupling partition validity to process existence. service_->storage_partition() (owned by SharedWorkerServiceImpl) is valid for the host’s whole lifetime, decoupling LockManager access from process state (inferred: SharedWorkerServiceImpl outlives its hosts).

Vulnerability window

  1. Registration — A page creates a SharedWorker; the worker uses navigator.locks. SharedWorkerHost::CreateLockManager() calls GetStoragePartitionImpl()->GetLockManager()->AddLockObserver(token().value(), this), inserting a raw Observer* into client_observer_map_.
  2. Process exit — The shared worker’s renderer process terminates (navigation away, crash, or resource pressure) while the SharedWorkerHost object still exists in the browser process. site_instance_->HasProcess() becomes false.
  3. Host destruction (the flaw) — ~SharedWorkerHost() runs. Pre-fix, RemoveLockObserver was inside the if (site_instance_->HasProcess()) block, which is now false, so deregistration is skipped. The host is freed but client_observer_map_ still points at it: a dangling pointer.
  4. Contention trigger — Another client contends the same Web Lock. LockManager consults client_observer_map_ and invokes the stale Observer* (the freed SharedWorkerHost’s OnLockContention path), dereferencing freed browser-process heap: the use-after-free fires.
  5. Fix — M150-LTS CL moves RemoveLockObserver above the guard (unconditional), switches GetStoragePartitionImpl() to service_->storage_partition() so removal always resolves a live LockManager, and hardens the map to raw_ptr. Cherry-picked from main@{#1694548} to branch 7871.

Proof of concept

No exploit is shipped. The diff contains regression test changes (shared_worker_host_unittest.cc and shared_worker_devtools_agent_host_unittest.cc now construct SharedWorkerServiceImpl with a real StoragePartition instead of nullptr), which exist because GetStoragePartitionImpl() now dereferences service_->storage_partition() and the destructor’s unconditional RemoveLockObserver must resolve a live LockManager — i.e. they exercise the fixed teardown path. The JS below is the web-reachable trigger reconstructed from the mechanism: create a SharedWorker that holds a Web Lock, cause its renderer process to exit while the host lingers, then contend the same lock from another client so LockManager notifies the now-freed observer. This demonstrates reaching the UAF condition; it is NOT a full exploit (no heap grooming, no control-flow hijack) and reliable triggering requires winning the process-teardown-vs-contention race, so it is best confirmed under ASAN. No exploit primitive is fabricated here.

// ---- worker.js (the SharedWorker script) ----
// Acquire a named Web Lock and hold it indefinitely so a later
// same-origin client will CONTEND it.
self.onconnect = (e) => {
  const port = e.ports[0];
  navigator.locks.request('patchgap-lock', { mode: 'exclusive' }, () =>
    new Promise(() => { /* never resolve: hold the lock forever */ })
  );
  port.start();
};

// ---- page.html / driver (served same-origin) ----
// 1) Start the shared worker; it registers a LockManager observer
//    in the browser process (AddLockObserver on CreateLockManager).
const w = new SharedWorker('worker.js');
w.port.start();

// 2) Force the worker's renderer process to exit while its
//    SharedWorkerHost still lives in the browser process. From web
//    content this is done indirectly: drop all references and
//    navigate/reload / evict under memory pressure so the process is
//    torn down. (inferred: exact eviction timing is not script-controlled;
//    an OOM/discard or --site-per-process teardown widens the window.)
w.port.close();
// e.g. window.location.reload();  or navigate the tab away and back

// 3) From another same-origin client (a new tab / frame), contend the
//    SAME lock name. If the stale SharedWorkerHost observer was not
//    deregistered, LockManager notifies the freed object -> browser-process UAF.
navigator.locks.request('patchgap-lock', { mode: 'exclusive' }, async () => {
  // reaching this callback means we won the lock after contention;
  // the UAF (if present) fires inside the browser during the
  // contention notification before we ever get here.
});

// Confirm under ASAN: heap-use-after-free in the browser process,
// stack through LockManager contention -> SharedWorkerHost::OnLockContention
// on freed memory. Race-dependent; not a weaponized exploit.

Exploitation

  1. Reach — Fully web-reachable with no permission prompt: any origin can instantiate a SharedWorker and call navigator.locks. The vulnerable AddLockObserver registration happens automatically when the worker first uses Web Locks (CreateLockManager).
  2. Set up the dangling pointer — Get the SharedWorkerHost destroyed while site_instance_->HasProcess() is false. (inferred) The attacker cannot call the destructor directly; they induce renderer-process teardown (tab discard, navigation, crash, or memory pressure) while the browser-side host lingers, so ~SharedWorkerHost() skips RemoveLockObserver and leaves the stale Observer* in client_observer_map_.
  3. Win the race — (inferred) Reliable triggering requires the process-teardown to complete (host freed, map entry stale) before the next contention notification, i.e. winning a teardown-vs-contention race. Attacker sequences: hold the lock, evict the worker process, then contend from a second client — repeated across many attempts to hit the window.
  4. Fire the UAF — A contending Web Locks request on the same lock group causes LockManager to dereference the freed SharedWorkerHost through client_observer_map_, executing on freed browser-process heap.
  5. Weaponize — (inferred) To go from UAF to control, an attacker would reclaim the freed SharedWorkerHost allocation with attacker-controlled data (heap grooming) so the observer dispatch reads a controlled vtable/field, yielding info-leak or control-flow hijack in the unsandboxed browser process — a renderer-to-browser sandbox escape. No such primitive is proven by the diff; the raw_ptr hardening is specifically intended to blunt this step.

Detection & hunting

For defenders and detection engineers:

  • ASAN heap-use-after-free in the browser process — Look for an ASAN report whose free stack is SharedWorkerHost destruction and whose use stack runs through content::LockManager contention/notification into a SharedWorkerHost Observer method (OnLockContention). The freed object type is SharedWorkerHost.
  • Observer add/remove imbalance — Instrument or audit that every AddLockObserver(token) in LockManager is matched by a RemoveLockObserver(token). A non-empty client_observer_map_ entry whose keyed token corresponds to an already-destroyed worker host indicates the stale-registration condition.
  • Renderer process exit followed by shared-worker lock contention — Telemetry/logging correlating a SharedWorker renderer process exit with a subsequent Web Locks contention event on that worker’s lock group is a behavioral fingerprint of the trigger sequence.
  • Browser crashes with MiraclePtr/BackupRefPtr signatures — After the raw_ptr hardening, a residual dangling access surfaces as a controlled BackupRefPtr crash rather than silent corruption; treat such crashes referencing lock_manager client_observer_map_ as a signal.

Audit directions

  • Observer-registration lifetime symmetry across content/browser — Grep for AddObserver/AddLockObserver-style registrations whose matching removal sits inside a conditional (especially process-liveness guards like site_instance_->HasProcess() or GetProcessHost() null checks). Any registration that is unconditional but whose removal is conditional is the same bug pattern.
  • Other users of LockManager::AddLockObserver — Audit every caller of AddLockObserver (e.g. DedicatedWorkerHost, RenderFrameHostImpl, service workers) to confirm each deregisters unconditionally in its destructor and does not gate RemoveLockObserver on renderer-process existence.
  • GetStoragePartition()-via-process-host resolution — Find other code that resolves a StoragePartition through GetProcessHost()->GetStoragePartition() (or similar process-coupled paths) in destructors or post-process-exit cleanup, where the partition/LockManager may already be invalid. Prefer service- or context-owned partition handles with host-wide lifetime.
  • Raw-pointer observer maps in the browser process — Search for base::flat_map / std::map values typed as raw T* observers (not raw_ptr / not CheckedObserver). These are candidates for both the missing-deregistration UAF class and raw_ptr hardening.
  • SharedWorkerHost teardown ordering — Review ~SharedWorkerHost() and adjacent shutdown paths for other cleanup actions that were (or still are) nested under HasProcess() but must run unconditionally (report flushing, network-restriction clearing, observer removals) to catch sibling lifetime bugs.

Before / after

Loading diff…