Chrome · Scheduling
CVE-2026-14107
UAF in Scheduling
Overview
Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/platform/scheduler/common/auto_advancing_virtual_time_domain.cc |
modified | |
ifthird_party/blink/renderer/platform/scheduler/common/thread_scheduler_base.cc |
modified | |
forthird_party/blink/renderer/platform/scheduler/worker/non_main_thread_impl_unittest.cc |
modified |
Files Changed
third_party/blink/renderer/platform/scheduler/common/auto_advancing_virtual_time_domain.ccthird_party/blink/renderer/platform/scheduler/common/auto_advancing_virtual_time_domain.hthird_party/blink/renderer/platform/scheduler/common/thread_scheduler_base.ccthird_party/blink/renderer/platform/scheduler/worker/non_main_thread_impl_unittest.cc
Patch
From a2adbb269c9e542c672a9449e6a6179a38dc64b8 Mon Sep 17 00:00:00 2001
From: Etienne Pierre-doray <etiennep@chromium.org>
Date: Tue, 19 May 2026 14:23:26 -0700
Subject: [PATCH] [task] Fix AutoAdvancingVirtualTimeDomain lifetime
To honor TimeDomain contract, AutoAdvancingVirtualTimeDomain
is detached from its observer instead of destroyed in
ThreadSchedulerBase::Shutdown.
We then rely on ~ThreadSchedulerBase to clear virtual_time_domain_,
at which point it's safe to do so because it happens after
all queues are shutdown, synchronized by OperationsController.
Bug: 513544566
Change-Id: Ib3747306708e0f94f623d74938f4cb9d05e82b45
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7857479
Commit-Queue: Etienne Pierre-Doray <etiennep@chromium.org>
Reviewed-by: Scott Haseley <shaseley@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1633105}
---
diff --git a/third_party/blink/renderer/platform/scheduler/common/auto_advancing_virtual_time_domain.cc b/third_party/blink/renderer/platform/scheduler/common/auto_advancing_virtual_time_domain.cc
index f2408dd..80b40c34 100644
--- a/third_party/blink/renderer/platform/scheduler/common/auto_advancing_virtual_time_domain.cc
+++ b/third_party/blink/renderer/platform/scheduler/common/auto_advancing_virtual_time_domain.cc
@@ -32,7 +32,16 @@
}
AutoAdvancingVirtualTimeDomain::~AutoAdvancingVirtualTimeDomain() {
- helper_->RemoveTaskObserver(this);
+ if (helper_) {
+ helper_->RemoveTaskObserver(this);
+ }
+}
+
+void AutoAdvancingVirtualTimeDomain::ShutDown() {
+ if (helper_) {
+ helper_->RemoveTaskObserver(this);
+ helper_ = nullptr;
+ }
}
base::TimeTicks AutoAdvancingVirtualTimeDomain::NowTicks() const {
diff --git a/third_party/blink/renderer/platform/scheduler/common/auto_advancing_virtual_time_domain.h b/third_party/blink/renderer/platform/scheduler/common/auto_advancing_virtual_time_domain.h
index ebd2672..208a5d9 100644
--- a/third_party/blink/renderer/platform/scheduler/common/auto_advancing_virtual_time_domain.h
+++ b/third_party/blink/renderer/platform/scheduler/common/auto_advancing_virtual_time_domain.h
@@ -45,6 +45,9 @@
const AutoAdvancingVirtualTimeDomain&) = delete;
~AutoAdvancingVirtualTimeDomain() override;
+ // Detach from the helper safely while it is still alive.
+ void ShutDown();
+
// Controls whether or not virtual time is allowed to advance, when the
// SequenceManager runs out of immediate work to do.
void SetCanAdvanceVirtualTime(bool can_advance_virtual_time);
diff --git a/third_party/blink/renderer/platform/scheduler/common/thread_scheduler_base.cc b/third_party/blink/renderer/platform/scheduler/common/thread_scheduler_base.cc
index c986839..3183997 100644
--- a/third_party/blink/renderer/platform/scheduler/common/thread_scheduler_base.cc
+++ b/third_party/blink/renderer/platform/scheduler/common/thread_scheduler_base.cc
@@ -18,7 +18,9 @@
void ThreadSchedulerBase::Shutdown() {
GetHelper().ResetTimeDomain();
- virtual_time_domain_.reset();
+ if (virtual_time_domain_) {
+ virtual_time_domain_->ShutDown();
+ }
}
base::TimeTicks ThreadSchedulerBase::EnableVirtualTime(
diff --git a/third_party/blink/renderer/platform/scheduler/worker/non_main_thread_impl_unittest.cc b/third_party/blink/renderer/platform/scheduler/worker/non_main_thread_impl_unittest.cc
index 4c7220e..7e60ba0d 100644
--- a/third_party/blink/renderer/platform/scheduler/worker/non_main_thread_impl_unittest.cc
+++ b/third_party/blink/renderer/platform/scheduler/worker/non_main_thread_impl_unittest.cc
@@ -4,10 +4,16 @@
#include "third_party/blink/renderer/platform/scheduler/worker/non_main_thread_impl.h"
+#include <atomic>
+#include <vector>
+
#include "base/functional/bind.h"
+#include "base/functional/callback_helpers.h"
#include "base/location.h"
#include "base/memory/raw_ptr.h"
#include "base/synchronization/waitable_event.h"
+#include "base/threading/platform_thread.h"
+#include "base/threading/thread.h"
#include "build/build_config.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
@@ -165,6 +171,107 @@
thread_.reset();
}
+// Regression test for crbug.com/513544566.
+// SequenceManagerImpl::any_thread_clock() requires that the installed
+// TimeDomain outlives SequenceManagerImpl (even if it is reset).
+//
+// Previously, ThreadSchedulerBase::Shutdown() destroyed the
+// AutoAdvancingVirtualTimeDomain before the default task queue's
+// GuardedTaskPoster was fully shut down. A concurrent cross-thread
+// PostDelayedTask() from another thread could load a stale clock pointer
+// and dereference it, causing a Use-After-Free (UAF) under ASAN inside
+// LazyNow::Now().
+//
+// This test ensures that we safely sequence the shutdown of the virtual
+// time domain. It models a DevTools client enabling virtual time on a
+// worker target followed by worker termination, while another renderer
+// thread is concurrently posting delayed tasks to the worker's task runner.
+TEST(NonMainThreadImplVirtualTimeShutdownRace,
+ CrossThreadPostDelayedTaskDuringShutdown) {
+ // Oversubscribe the CPU so hammer threads are preempted between the
+ // any_thread_clock() load and the LazyNow::Now() dereference.
+ const int kHammerThreads = 16;
+
+ std::unique_ptr<NonMainThread> thread = NonMainThread::CreateThread(
+ ThreadCreationParams(ThreadType::kTestThread));
+ scoped_refptr<base::SingleThreadTaskRunner> task_runner =
+ thread->GetTaskRunner();
+
+ // Enable virtual time on the worker thread, mimicking what
+ // InspectorEmulationAgent::setVirtualTimePolicy does on a worker target.
+ // The policy is paused so that the worker doesn't fast-forward through
+ // the incoming delayed tasks.
+ {
+ base::WaitableEvent done;
+ task_runner->PostTask(
+ FROM_HERE, base::BindOnce(
+ [](Thread* thread, base::WaitableEvent* done) {
+ auto* sched = static_cast<WorkerThreadScheduler*>(
+ thread->Scheduler());
+ sched->EnableVirtualTime(base::Time());
+ sched->SetVirtualTimePolicy(
+ VirtualTimeController::VirtualTimePolicy::kPause);
+ done->Signal();
+ },
+ thread.get(), &done));
+ done.Wait();
+ }
+
+ // Spawns threads to hammer the worker's default task queue with cross-thread
+ // PostDelayedTask() calls. This exercises the code path that accesses the
+ // time domain's clock concurrently.
+ std::atomic<bool> stop{false};
+ base::WaitableEvent go_event(base::WaitableEvent::ResetPolicy::MANUAL,
+ base::WaitableEvent::InitialState::NOT_SIGNALED);
+ std::vector<std::unique_ptr<base::Thread>> hammers;
+ hammers.reserve(kHammerThreads);
+ for (int i = 0; i < kHammerThreads; ++i) {
+ auto hammer = std::make_unique<base::Thread>("HammerThread");
+ hammer->Start();
+
+ hammer->task_runner()->PostTask(
+ FROM_HERE,
+ base::BindOnce(
+ [](base::WaitableEvent* go_event, std::atomic<bool>* stop,
+ scoped_refptr<base::SingleThreadTaskRunner> target_runner) {
+ go_event->Wait();
+ while (!stop->load(std::memory_order_relaxed)) {
+ target_runner->PostDelayedTask(FROM_HERE, base::DoNothing(),
+ base::Hours(1));
+ }
+ },
+ &go_event, &stop, task_runner));
+
+ hammers.push_back(std::move(hammer));
+ }
+ go_event.Signal();
+ // Let the hammers warm up so they're mid-PostDelayedTask when the worker
+ // tears the time domain down.
+ base::PlatformThread::Sleep(base::Microseconds(200));
+
+ // Shut down the scheduler on the worker thread, mimicking what
+ // WorkerThread::PerformShutdownOnWorkerThread does. This triggers
+ // ThreadSchedulerBase::Shutdown(), which safely detaches the virtual
+ // time domain observer while the helper is still alive.
+ {
+ base::WaitableEvent done;
+ task_runner->PostTask(FROM_HERE,
+ base::BindOnce(
+ [](Thread* thread, base::WaitableEvent* done) {
+ thread->Scheduler()->Shutdown();
+ done->Signal();
+ },
+ thread.get(), &done));
+ done.Wait();
+ }
+
+ stop.store(true, std::memory_order_relaxed);
+ // base::Thread destructor automatically stops and Joins the thread,
+ // so simply clearing the vector joins all hammer threads safely.
+ hammers.clear();
+ thread.reset();
+}
+
} // namespace worker_thread_unittest
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/third_party/blink/renderer/platform/scheduler/worker/non_main_thread_impl_unittest.cc b/third_party/blink/renderer/platform/scheduler/worker/non_main_thread_impl_unittest.cc
index 4c7220e..7e60ba0d 100644
--- a/third_party/blink/renderer/platform/scheduler/worker/non_main_thread_impl_unittest.cc
+++ b/third_party/blink/renderer/platform/scheduler/worker/non_main_thread_impl_unittest.cc
@@ -4,10 +4,16 @@
#include "third_party/blink/renderer/platform/scheduler/worker/non_main_thread_impl.h"
+#include <atomic>
+#include <vector>
+
#include "base/functional/bind.h"
+#include "base/functional/callback_helpers.h"
#include "base/location.h"
#include "base/memory/raw_ptr.h"
#include "base/synchronization/waitable_event.h"
+#include "base/threading/platform_thread.h"
+#include "base/threading/thread.h"
#include "build/build_config.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
@@ -165,6 +171,107 @@
thread_.reset();
}
+// Regression test for crbug.com/513544566.
+// SequenceManagerImpl::any_thread_clock() requires that the installed
+// TimeDomain outlives SequenceManagerImpl (even if it is reset).
+//
+// Previously, ThreadSchedulerBase::Shutdown() destroyed the
+// AutoAdvancingVirtualTimeDomain before the default task queue's
+// GuardedTaskPoster was fully shut down. A concurrent cross-thread
+// PostDelayedTask() from another thread could load a stale clock pointer
+// and dereference it, causing a Use-After-Free (UAF) under ASAN inside
+// LazyNow::Now().
+//
+// This test ensures that we safely sequence the shutdown of the virtual
+// time domain. It models a DevTools client enabling virtual time on a
+// worker target followed by worker termination, while another renderer
+// thread is concurrently posting delayed tasks to the worker's task runner.
+TEST(NonMainThreadImplVirtualTimeShutdownRace,
+ CrossThreadPostDelayedTaskDuringShutdown) {
+ // Oversubscribe the CPU so hammer threads are preempted between the
+ // any_thread_clock() load and the LazyNow::Now() dereference.
+ const int kHammerThreads = 16;
+
+ std::unique_ptr<NonMainThread> thread = NonMainThread::CreateThread(
+ ThreadCreationParams(ThreadType::kTestThread));
+ scoped_refptr<base::SingleThreadTaskRunner> task_runner =
+ thread->GetTaskRunner();
+
+ // Enable virtual time on the worker thread, mimicking what
+ // InspectorEmulationAgent::setVirtualTimePolicy does on a worker target.
+ // The policy is paused so that the worker doesn't fast-forward through
+ // the incoming delayed tasks.
+ {
+ base::WaitableEvent done;
+ task_runner->PostTask(
+ FROM_HERE, base::BindOnce(
+ [](Thread* thread, base::WaitableEvent* done) {
+ auto* sched = static_cast<WorkerThreadScheduler*>(
+ thread->Scheduler());
+ sched->EnableVirtualTime(base::Time());
+ sched->SetVirtualTimePolicy(
+ VirtualTimeController::VirtualTimePolicy::kPause);
+ done->Signal();
+ },
+ thread.get(), &done));
+ done.Wait();
+ }
+
+ // Spawns threads to hammer the worker's default task queue with cross-thread
+ // PostDelayedTask() calls. This exercises the code path that accesses the
+ // time domain's clock concurrently.
+ std::atomic<bool> stop{false};
+ base::WaitableEvent go_event(base::WaitableEvent::ResetPolicy::MANUAL,
+ base::WaitableEvent::InitialState::NOT_SIGNALED);
+ std::vector<std::unique_ptr<base::Thread>> hammers;
+ hammers.reserve(kHammerThreads);
+ for (int i = 0; i < kHammerThreads; ++i) {
+ auto hammer = std::make_unique<base::Thread>("HammerThread");
+ hammer->Start();
+
+ hammer->task_runner()->PostTask(
+ FROM_HERE,
+ base::BindOnce(
+ [](base::WaitableEvent* go_event, std::atomic<bool>* stop,
+ scoped_refptr<base::SingleThreadTaskRunner> target_runner) {
+ go_event->Wait();
+ while (!stop->load(std::memory_order_relaxed)) {
+ target_runner->PostDelayedTask(FROM_HERE, base::DoNothing(),
+ base::Hours(1));
+ }
+ },
+ &go_event, &stop, task_runner));
+
+ hammers.push_back(std::move(hammer));
+ }
+ go_event.Signal();
+ // Let the hammers warm up so they're mid-PostDelayedTask when the worker
+ // tears the time domain down.
+ base::PlatformThread::Sleep(base::Microseconds(200));
+
+ // Shut down the scheduler on the worker thread, mimicking what
+ // WorkerThread::PerformShutdownOnWorkerThread does. This triggers
+ // ThreadSchedulerBase::Shutdown(), which safely detaches the virtual
+ // time domain observer while the helper is still alive.
+ {
+ base::WaitableEvent done;
+ task_runner->PostTask(FROM_HERE,
+ base::BindOnce(
+ [](Thread* thread, base::WaitableEvent* done) {
+ thread->Scheduler()->Shutdown();
+ done->Signal();
+ },
+ thread.get(), &done));
+ done.Wait();
+ }
+
+ stop.store(true, std::memory_order_relaxed);
+ // base::Thread destructor automatically stops and Joins the thread,
+ // so simply clearing the vector joins all hammer threads safely.
+ hammers.clear();
+ thread.reset();
+}
+
} // namespace worker_thread_unittest
// Needs to be in scheduler namespace for FRIEND_TEST_ALL_PREFIXES to work
Loading diff…
Original Bug Report
The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.
References
On This Page