CVE-2025-5068
Overview
Background
- `WorkerThread`
- Blink’s per-worker backing-thread controller (used by SharedWorker et al.) whose termination runs
PrepareForShutdownOnWorkerThread()thenPerformShutdownOnWorkerThread()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 booleanrequested_to_terminate_, encoding the shutdown stage so the two triggering paths can coordinate.
Root Cause Analysis
Verify before building a PoC
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.
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
- 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. - Trigger termination during teardown
The document/worker is terminated (e.g. by navigation or explicit close), invoking
Terminate(), which setrequested_to_terminate_ = trueand posted both the prepare and perform tasks beforePrepareForShutdownOnWorkerThread()had run. - Race the two shutdown paths
Concurrently, a child thread finishes and
ChildThreadTerminatedOnWorkerThread()sees the boolean set and directly callsPerformShutdownOnWorkerThread(), so perform runs out of order versus prepare and/or is executed twice whilethread_state_is stillkRunning. - 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
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
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/workers/worker_thread.cc |
modified |
Files Changed
third_party/blink/common/features.ccthird_party/blink/public/common/features.hthird_party/blink/renderer/core/workers/worker_thread.cc
Audit Directions
- Boolean flags set before the guarded work completesLook 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 cleanupAudit 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 orderingReview teardown code that mixes
PostCrossThreadTask/PostTaskwith 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.
Patch
From f1e6422a355c016e5f2c22619181f7f121d1f511 Mon Sep 17 00:00:00 2001
From: Yoshisato Yanagisawa <yyanagisawa@chromium.org>
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 <nhiroki@chromium.org>
Commit-Queue: Yoshisato Yanagisawa <yyanagisawa@chromium.org>
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(
Original Bug Report
Use After Free in CompressedPointer::Load inside WorkerThread::DidProcessTask