35740ce26095e839a6f08ad844d1b46f37ac6fe3 [Blink] fix: worklet lifecycle crash
Triage note: Adds task abort in ContextDestroyed() (release-relevant) and Dispose(). Framed as a lifecycle/DCHECK fix; potential dangling reference on navigation but exploitability not establishable from the diff.
The bug at a glance
This is a genuine lifecycle defect: when a worklet module load is in flight and the execution context is destroyed (navigation, frame detach), the WorkletPendingTasks bound to that load were never aborted. The immediately observable symptom is a fatal DCHECK (!HasPendingTasks()) in ~Worklet, which is a debug/DCHECK-build crash rather than a release-build memory-corruption primitive. In release builds the DCHECK is compiled out, so the failure mode degrades to WorkletPendingTasks (a garbage-collected object) surviving with references into an execution context that is being torn down, plus ScriptPromiseResolver state that is never settled. Because everything here lives on the main thread inside Oilpan’s garbage-collected heap, there is no attacker-controlled allocator interleaving and no classic freed-then-reused raw pointer: Oilpan keeps the WorkletPendingTasks object alive as long as it is reachable, so the practical worst case is a stuck/leaked promise, use-counter accounting drift, or a null/torn-context deref during a narrow teardown window. That keeps it firmly watch-tier: real correctness bug, plausibly a crash, not a demonstrated exploitable UAF. Score low, CVSS ~4.0 (renderer-local DoS at most).
Navigation or frame detachment that races an in-flight addModule() worklet fetch. The attacker’s leverage is timing: get a module load pending, then destroy the context, and observe whether the pending-task set is drained before the Worklet’s pre-finalizer/destructor runs. The security-relevant question is not the DCHECK itself but whether any pending-task callback can execute against a half-destroyed context.
Root cause
Worklet holds pending_tasks_set_, a HeapHashSet<Member> tracking each outstanding addModule() fetch. Before this change, neither Dispose() nor ContextDestroyed() drained that set. ContextDestroyed() is the ExecutionContextLifecycleObserver hook fired on navigation/detach; it disposed module_responses_map_ and terminated the global-scope proxies but left pending_tasks_set_ populated. When a module fetch was still outstanding at that moment, HasPendingTasks() remained true through pre-finalization, tripping the DCHECK in ~Worklet (worklet.cc:37). The fix adds abort loops in two places: ContextDestroyed() (the primary path, run at navigation time) and Dispose() (a defensive pre-finalizer safeguard). Both snapshot pending_tasks_set_ into a HeapVector before iterating — because WorkletPendingTasks::Abort() mutates the set (it removes itself), iterating the live set directly would invalidate the iterator. Abort(nullptr) settles/rejects each pending task and unlinks it. The new include of serialized_script_value.h and heap_vector.h supports the snapshot vector; worklet.h now pulls in worklet_pending_tasks.h for the full type.
Key code
ContextDestroyed() now drains the pending-task set (snapshotted to avoid iterator invalidation from Abort())
void Worklet::ContextDestroyed() {
DCHECK(IsMainThread());
module_responses_map_->Dispose();
+
+ // Abort any pending tasks when the context is destroyed. This is the primary
+ // cleanup path. This prevents the DCHECK in ~Worklet from firing if a module
+ // load is in flight during navigation.
+ if (HasPendingTasks()) {
+ HeapVector<Member<WorkletPendingTasks>> tasks_to_abort(pending_tasks_set_);
+ for (const auto& task : tasks_to_abort) {
+ task->Abort(nullptr);
+ }
+ }
+
for (const auto& proxy : proxies_)
proxy->TerminateWorkletGlobalScope();
}
Patch walkthrough
third_party/blink/renderer/core/workers/worklet.cc— Two abort loops added. ContextDestroyed() is the real fix: it drains pending_tasks_set_ at navigation/detach time, before the proxies terminate their global scopes, so no WorkletPendingTasks survives into destruction still referencing the doomed context. Dispose() gets an identical loop as a belt-and-suspenders safeguard for any path where the pre-finalizer runs without ContextDestroyed() having fired. Both copy the HeapHashSet into a HeapVector<Member> first because Abort() removes the task from the set, which would otherwise invalidate the range-for iterator.third_party/blink/renderer/core/workers/worklet.cc (includes)— Adds serialized_script_value.h and platform/heap/collection_support/heap_vector.h — the latter is needed for the snapshot vector; the former is a transitive dependency pulled in by fully using WorkletPendingTasks / Abort machinery.third_party/blink/renderer/core/workers/worklet.h— Includes worklet_pending_tasks.h so the full WorkletPendingTasks definition (not just a forward declaration) is visible in the header, needed now that HeapVector<Member> is constructed inline in the .cc against a complete type.AUTHORS— Non-functional: adds the external contributor (Canrong Lan). No security relevance; typical for a first upstream contribution.
Background
Worklet / WorkletPendingTasks — A Worklet (paint, audio, animation, layout, shared-storage) loads module scripts via addModule(). Each addModule() call spawns a WorkletPendingTasks object that tracks fetch completion across the (possibly multiple) global scopes and settles the returned promise. Worklet keeps them in pending_tasks_set_ until they complete or abort.
ExecutionContextLifecycleObserver::ContextDestroyed() — Blink hook fired when the owning execution context (document/window) is being destroyed — on navigation, frame detach, or discard. It is the canonical place to release resources bound to that context. Missing cleanup here is the root cause.
Oilpan pre-finalizer & DCHECK — Blink’s GC runs a pre-finalizer before object destruction. ~Worklet asserts !HasPendingTasks() to catch leaked in-flight tasks. DCHECKs are active in dev/DCHECK builds and canary; they compile out in stable release, so the assert is a correctness tripwire, not the vulnerability itself.
Iterator invalidation on Abort() — WorkletPendingTasks::Abort() removes itself from pending_tasks_set_; iterating the live set while aborting would mutate it mid-iteration. The snapshot-to-HeapVector pattern is the standard Blink workaround.
Vulnerability window
- introduction — Long-standing: Worklet’s ContextDestroyed()/Dispose() never drained pending_tasks_set_. The DCHECK guarding ~Worklet made the gap visible whenever a module load raced teardown.
- discovery — Reported as crbug 472245768 — a reproducible FATAL DCHECK (!HasPendingTasks()) at worklet.cc:37 when a worklet module load is in flight during navigation.
- fix — Commit 35740ce260 (main@{#1564367}, 2026-01-05) adds abort loops in ContextDestroyed() (primary) and Dispose() (safeguard). Reviewed by Blink workers owner nhiroki and ByteDance’s Peng Zhou.
- status — Silently fixed under a benign ‘[Blink] fix: worklet lifecycle crash’ subject with no security tag; bug tracker entry likely restricted. Watch-tier.
Proof of concept
No test shipped in this diff, so no PoC is reconstructed and no exploit is demonstrated. A crash-repro recipe (not an exploit): create a page that calls a worklet addModule() against a URL that stalls the module fetch (a slow/never-completing HTTP response), then immediately navigate the frame away (location change, or detach an that owns the worklet) while the fetch is still pending. On a DCHECK/canary build this trips the ~Worklet assert; on release it exercises the teardown window where a WorkletPendingTasks referencing the dying context is left un-aborted. This is a reliability/crash recipe only — no memory-safety primitive is claimed or shown, and no exploit code is provided.
Exploitation
- primitive assessment — Best-case attacker outcome is a renderer crash during navigation (DoS) on DCHECK-enabled builds, or a leaked/never-settled promise and stale context references on stable. No controlled write, no freed-and-reallocated raw pointer is evident from the diff.
- why not exploitable-grade — WorkletPendingTasks is Oilpan-managed; while it is reachable it is not freed, so ‘reference into torn-down context’ does not equal a dangling raw pointer to reallocated memory. Any dereference would hit a still-live GC object or a cleared Member, tending toward null/DCHECK rather than type confusion.
- ceiling — Renderer-local, main-thread, single-origin. Even weaponized as a crash, it is a local DoS with no cross-origin or sandbox-escape leverage.
Detection & hunting
For defenders and detection engineers:
- Crash telemetry —
- Hang/leak signals —
Audit directions
- Sibling lifecycle-observers with pending-task sets —
- DCHECK-only invariants that mask release UAF —
- Iterator-safety of new abort loops —