Medium patch-now ~ plausible CVSS 8.8 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Blink
DescriptionUse after free in Blink
ComponentBlink
Sub-componentBlink>Workers
Bug ClassUAF
Action tierpatch-now — corruption primitive, web-reachable in default config (plausible) — 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 AIPlausible
Reviewer notes: Commit author only 'supposes' the race root cause; diff shows ordering/once-only enforcement and CHECKs, not the freed object or reuse site. UAF is advisory-asserted, not diff-shown. Requires nested workers plus winning a cross-thread race.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker409059706
Fix commitf1e6422a355c (chromium/src) +105/-21
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedWalkman
Reported2025-04-07 · bounty $1000 (per vendor advisory)
Fix landed2025-05-22 (public git)
Disclosed2025-06-02
Patch gap11 days — fix public in source before the advisory

Background

`WorkerThread`
Blink’s per-worker backing-thread controller (used by SharedWorker et al.) whose termination runs PrepareForShutdownOnWorkerThread() then PerformShutdownOnWorkerThread() on the worker thread.
`Terminate()`
the parent-thread entry point that marks a worker for shutdown and posts the shutdown tasks; it must be idempotent against re-entry.
`PerformShutdownOnWorkerThread()`
the final, effectively non-reentrant shutdown step that tears down worker fields and must run exactly once, after PrepareForShutdownOnWorkerThread() and after all child worker threads have terminated.
`TerminationProgress`
the new enum (kNotRequested, kRequested, kPrepared, kPerforming) replacing the boolean requested_to_terminate_, encoding the shutdown stage so the two triggering paths can coordinate.

Root Cause Analysis

Verify before building a PoC

This analysis is consistent with the fixing commit but not fully provable from it: part of the vulnerable path lives in code the fix commit does not touch. Confirm the trigger and the sink against the source before relying on it for exploit development.
What the commit does not establish: Commit author only 'supposes' the race root cause; diff shows ordering/once-only enforcement and CHECKs, not the freed object or reuse site. UAF is advisory-asserted, not diff-shown. Requires nested workers plus winning a cross-thread race.

Two independent code paths could invoke PerformShutdownOnWorkerThread(): Terminate() posted it unconditionally right after posting PrepareForShutdownOnWorkerThread(), while ChildThreadTerminatedOnWorkerThread() invoked it directly once the last child thread terminated and CheckRequestedToTerminate() was true. Because the “terminate requested” state was a single boolean set early in Terminate() — before PrepareForShutdownOnWorkerThread() actually ran — these paths could race across threads and run PerformShutdownOnWorkerThread() out of order (before prepare) or more than once, while the worker’s thread_state_ was still kRunning, touching fields that were being torn down and yielding a use-after-free.

The fix replaces the boolean with the staged TerminationProgress enum, adds a lock-protected num_child_threads_ counter, and posts PerformShutdownOnWorkerThread() only when the stage is kPrepared and num_child_threads_ == 0, transitioning to kPerforming under the lock so exactly one path performs it exactly once. The child-termination path now also posts (rather than directly calls) the perform step to preserve ordering relative to prepare. A kill-switch feature kWorkerThreadSequentialShutdown (enabled by default) guards the new behavior.

Key insight
The core mistake was tracking termination with a single boolean that was set before preparation completed, letting Terminate() and the child-thread-termination path race to run the non-reentrant PerformShutdownOnWorkerThread() early or twice; the fix serializes shutdown through a lock-protected multi-stage TerminationProgress enum plus a num_child_threads_ count so perform runs once, only after prepare and only when no children remain.

Attack Path

  1. Spawn nested workers Web content creates a worker that itself owns child worker threads, so num_child_threads_ is nonzero and child-termination callbacks are in play.
  2. Trigger termination during teardown The document/worker is terminated (e.g. by navigation or explicit close), invoking Terminate(), which set requested_to_terminate_ = true and posted both the prepare and perform tasks before PrepareForShutdownOnWorkerThread() had run.
  3. Race the two shutdown paths Concurrently, a child thread finishes and ChildThreadTerminatedOnWorkerThread() sees the boolean set and directly calls PerformShutdownOnWorkerThread(), so perform runs out of order versus prepare and/or is executed twice while thread_state_ is still kRunning.
  4. Use-after-free The premature/double PerformShutdownOnWorkerThread() tears down worker fields that are still referenced by the other in-flight shutdown task, producing a use-after-free in the renderer. (inferred) further control of the freed memory.

Impact Assessment

An attacker who can script nested SharedWorker/worker creation and termination can race the worker-thread shutdown sequence to free and then reuse WorkerThread state, a use-after-free in the Blink renderer process (as the advisory classifies: “Use after free in Blink”). Exploitation requires winning a cross-thread timing race during worker termination; any escalation from the UAF to code execution or sandbox effects is (inferred) and not demonstrated by the patch. The advisory does not report in-the-wild exploitation.

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/workers/worker_thread.cc
modified

Files Changed

  • third_party/blink/common/features.cc
  • third_party/blink/public/common/features.h
  • third_party/blink/renderer/core/workers/worker_thread.cc

Audit Directions

  • Boolean flags set before the guarded work completes
    Look for shutdown/teardown state tracked by a single boolean set at entry, where a second concurrent path can observe it and act before the first path finishes; prefer staged enums with lock-protected transitions.
  • Multiple triggers for once-only cleanup
    Audit routines like PerformShutdownOnWorkerThread() that can be reached from more than one path/thread, verifying a single serialized owner performs the non-reentrant final step exactly once.
  • Cross-thread post vs. direct call ordering
    Review teardown code that mixes PostCrossThreadTask/PostTask with direct method calls; a direct call from one thread can overtake a posted task from another, so ensure ordered steps are consistently posted to the same runner.
From f1e6422a355c016e5f2c22619181f7f121d1f511 Mon Sep 17 00:00:00 2001
From: Yoshisato Yanagisawa <[email protected]>
Date: Wed, 21 May 2025 23:25:12 -0700
Subject: [PATCH] Enforce SharedWorker::Terminate() procedure order

During the investigation of crbug.com/409059706, we observed that
PerformShutdownOnWorkerThread() is called during the status is
running.

I suppose the root cause is race condition between `Terminate()`
procedure and a child process termination procedure in different
thread.  WorkerThread can be terminated if two conditions are met;
`Terminate()` is called and all child worker threads have been
terminated.  Both `Terminate()` and the child process termination
procedure may call `PerformShutdownOnWorkerThread()`, and former
is executed regardless of two conditions are met.  The latter
is called if `Terminate()` is called and no child processes.
To be clear, "`Terminate()` is called" does not mean
`PrepareForShutdownOnWorkerThread()` is executed.  `Terminate()`
queues it after the flag to tell `Terminate()` call.  And, when
the issue happen, I am quite sure the flag is set but,
`PrepareForShutdownOnWorkerThread()` won't be executed yet.

The fix is that:
1. The "Terminate() is called" flag to be multi staged.
   The flag is used for two purpose; a. avoid re-enter of
  `Terminate()`, and b. `PrepareForShutdownOnWorkerThread()` is
  in flight. The CL changed the flag to enum to represent
  the stage properly.
2. `PerformShutdownOnWorkerThread()` is queued even if it is
   called within the child process termination procedure.
   It avoid the execution order flip between
   `PrepareForShutdownOnWorkerThread()` and
   `PerformShutdownOnWorkerThread()`.

In addition, this change ensures `PerformShutdownOnWorkerThread()`
is called once.  While `PerformShutdownOnWorkerThread()` touches
fields inside, the fields must not be touched at some point within
the function, the function is actually not re-entrant when it reaches
to the end.  Upon mikt@ suggestion, I made
`PerformShutdownOnWorkerThread()` is called only when two conditions
are fulfilled. i.e. `Terminate()` is called and the number of child
threads is 0.  Also, the CL uses the enum to show
`PerformShutdownOnWorkerThread()` is in-flight to avoid re-entrance
in this level.

Bug: 409059706
Change-Id: I81a1c3b1a34e827fa75ec2d1a9b37023965dbe27
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6543412
Reviewed-by: Hiroki Nakagawa <[email protected]>
Commit-Queue: Yoshisato Yanagisawa <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1463892}
---

diff --git a/third_party/blink/common/features.cc b/third_party/blink/common/features.cc
index 2d13943f..0440e931 100644
--- a/third_party/blink/common/features.cc
+++ b/third_party/blink/common/features.cc
@@ -2816,6 +2816,13 @@
              "WebviewAccelerateSmallCanvases",
              base::FEATURE_DISABLED_BY_DEFAULT);
 
+// WorkerThread termination procedure (prepare and shutdown) runs sequentially
+// in the same task without calling another cross thread post task.
+// Kill switch for crbug.com/409059706.
+BASE_FEATURE(kWorkerThreadSequentialShutdown,
+             "WorkerThreadSequentialShutdown",
+             base::FEATURE_ENABLED_BY_DEFAULT);
+
 BASE_FEATURE(kNoReferrerForPreloadFromSubresource,
              "NoReferrerForPreloadFromSubresource",
              base::FEATURE_ENABLED_BY_DEFAULT);
diff --git a/third_party/blink/public/common/features.h b/third_party/blink/public/common/features.h
index 7e3c024e..97923fb 100644
--- a/third_party/blink/public/common/features.h
+++ b/third_party/blink/public/common/features.h
@@ -1827,6 +1827,8 @@
 
 BLINK_COMMON_EXPORT BASE_DECLARE_FEATURE(kWebviewAccelerateSmallCanvases);
 
+BLINK_COMMON_EXPORT BASE_DECLARE_FEATURE(kWorkerThreadSequentialShutdown);
+
 // Kill switch for https://crbug.com/415810136.
 BLINK_COMMON_EXPORT BASE_DECLARE_FEATURE(kNoReferrerForPreloadFromSubresource);
 
diff --git a/third_party/blink/renderer/core/workers/worker_thread.cc b/third_party/blink/renderer/core/workers/worker_thread.cc
index e4d70bc..ba5aa4c 100644
--- a/third_party/blink/renderer/core/workers/worker_thread.cc
+++ b/third_party/blink/renderer/core/workers/worker_thread.cc
@@ -263,9 +263,10 @@
   DCHECK_CALLED_ON_VALID_THREAD(parent_thread_checker_);
   {
     base::AutoLock locker(lock_);
-    if (requested_to_terminate_)
+    if (termination_progress_ != TerminationProgress::kNotRequested) {
       return;
-    requested_to_terminate_ = true;
+    }
+    termination_progress_ = TerminationProgress::kRequested;
   }
 
   // Schedule a task to forcibly terminate the script execution in case that the
@@ -281,10 +282,33 @@
       *task_runner, FROM_HERE,
       CrossThreadBindOnce(&WorkerThread::PrepareForShutdownOnWorkerThread,
                           CrossThreadUnretained(this)));
-  PostCrossThreadTask(
-      *task_runner, FROM_HERE,
-      CrossThreadBindOnce(&WorkerThread::PerformShutdownOnWorkerThread,
-                          CrossThreadUnretained(this)));
+
+  if (!base::FeatureList::IsEnabled(
+          blink::features::kWorkerThreadSequentialShutdown)) {
+    PostCrossThreadTask(
+        *task_runner, FROM_HERE,
+        CrossThreadBindOnce(&WorkerThread::PerformShutdownOnWorkerThread,
+                            CrossThreadUnretained(this)));
+    return;
+  }
+
+  bool perform_shutdown = false;
+  {
+    base::AutoLock locker(lock_);
+    CHECK_EQ(TerminationProgress::kRequested, termination_progress_);
+    termination_progress_ = TerminationProgress::kPrepared;
+    if (num_child_threads_ == 0) {
+      termination_progress_ = TerminationProgress::kPerforming;
+      perform_shutdown = true;
+    }
+  }
+
+  if (perform_shutdown) {
+    PostCrossThreadTask(
+        *task_runner, FROM_HERE,
+        CrossThreadBindOnce(&WorkerThread::PerformShutdownOnWorkerThread,
+                            CrossThreadUnretained(this)));
+  }
 }
 
 void WorkerThread::TerminateForTesting() {
@@ -421,20 +445,48 @@
 
 void WorkerThread::ChildThreadStartedOnWorkerThread(WorkerThread* child) {
   DCHECK(IsCurrentThread());
-#if DCHECK_IS_ON()
+  child_threads_.insert(child);
   {
     base::AutoLock locker(lock_);
     DCHECK_EQ(ThreadState::kRunning, thread_state_);
+    CHECK_EQ(TerminationProgress::kNotRequested, termination_progress_);
+    if (base::FeatureList::IsEnabled(
+            blink::features::kWorkerThreadSequentialShutdown)) {
+      ++num_child_threads_;
+      CHECK_EQ(child_threads_.size(), num_child_threads_);
+    }
   }
-#endif
-  child_threads_.insert(child);
 }
 
 void WorkerThread::ChildThreadTerminatedOnWorkerThread(WorkerThread* child) {
   DCHECK(IsCurrentThread());
   child_threads_.erase(child);
-  if (child_threads_.empty() && CheckRequestedToTerminate())
-    PerformShutdownOnWorkerThread();
+  if (!base::FeatureList::IsEnabled(
+          blink::features::kWorkerThreadSequentialShutdown)) {
+    if (child_threads_.empty() && CheckRequestedToTerminate()) {
+      PerformShutdownOnWorkerThread();
+    }
+    return;
+  }
+
+  bool perform_shutdown = false;
+  {
+    base::AutoLock locker(lock_);
+    --num_child_threads_;
+    CHECK_EQ(child_threads_.size(), num_child_threads_);
+    if (num_child_threads_ == 0 &&
+        termination_progress_ == TerminationProgress::kPrepared) {
+      termination_progress_ = TerminationProgress::kPerforming;
+      perform_shutdown = true;
+    }
+  }
+  if (perform_shutdown) {
+    scoped_refptr<base::SingleThreadTaskRunner> task_runner =
+        GetWorkerBackingThread().BackingThread().GetTaskRunner();
+    GetWorkerBackingThread().BackingThread().GetTaskRunner()->PostTask(
+        FROM_HERE, WTF::BindOnce(&WorkerThread::PerformShutdownOnWorkerThread,
+                                 WTF::Unretained(this)));
+  }
 }
 
 WorkerThread::WorkerThread(WorkerReportingProxy& worker_reporting_proxy)
@@ -772,18 +824,32 @@
   DCHECK(IsCurrentThread());
   {
     base::AutoLock locker(lock_);
-    DCHECK(requested_to_terminate_);
+    if (!base::FeatureList::IsEnabled(
Loading diff…

Original Bug Report

reported by [Deleted User]

Use After Free in CompressedPointer::Load inside WorkerThread::DidProcessTask

deleted
View on issue tracker