f4b178b0d0058bae64f538a8ec381aa0bdb0f1f2 VT: Ensure that waitUntil is resilient when View disappears.
Triage note: Null-deref crash from arbitrary promise resolution racing document/view teardown, reachable via the View Transitions API. DoS-class, mitigated by added null checks.
The bug at a glance
Low because the failure mode is a renderer null-pointer dereference (crash / DoS) that occurs during document or view teardown, not a controllable use-after-free with an attacker-owned object. When the View Transition’s waitUntil pending-promise counter reaches zero, the code called document_->View()->ScheduleAnimation() unconditionally. That resolution runs from arbitrary JS promise microtasks, which can execute after the document has been detached or its LocalFrameView torn down, so document_ or document_->View() can be null, yielding a null deref. It is watch-tier and not patch-now because: the trigger is a teardown race the page can influence but not precisely aim, the dereferenced pointer is an internal Blink object (not attacker-shaped memory), and the outcome is a crash rather than corruption or disclosure. There is no evidence of a reclaimable-object UAF here, only a legitimately-null owning pointer.
This is a resilience hardening of the View Transitions waitUntil machinery: the promise-resolution callback that schedules the next animation frame now null-checks the document and its view before use, because that callback can fire during teardown when the view is already gone. Defenders should treat it as a low-severity renderer-crash fix in a JS-reachable API (document.startViewTransition + waitUntil) with a teardown-timing trigger. It reinforces the standard Blink rule that anything reached from an arbitrary promise resolution must re-validate document/frame/view liveness; it does not represent an exploitable memory-safety primitive on its own.
Root cause
View Transitions let script pass promises to waitUntil; the transition tracks how many are still pending in wait_until_pending_promise_count_. Each time one settles, the count is decremented, and when it hits zero the transition wants the compositor/animation pipeline to advance, so it called document_->View()->ScheduleAnimation() to request the next frame. The decrement-and-schedule runs inside the promise’s resolution callback.
The root cause is a lifetime assumption: the resolution callback assumed document_ and its LocalFrameView (document_->View()) are still alive at the moment the last promise settles. But promise resolution is driven by arbitrary JS microtasks whose timing the transition does not control. If the associated document is detached (e.g. the frame is navigated away, the iframe removed, or the page enters teardown) while promises are still outstanding, the final settlement can arrive after document_->View() has been cleared or the document itself is being destroyed. document_->View() then returns null (or document_ is null), and the unconditional ->ScheduleAnimation() dereferences null, crashing the renderer. The fix guards the call: if (document_ && document_->View()) { document_->View()->ScheduleAnimation(); }. Semantically this is correct because if there is no view there is no frame to schedule an animation on; skipping the schedule during teardown loses nothing. The CHECK_GT(wait_until_pending_promise_count_, 0) and the decrement are unchanged; only the post-zero scheduling is now conditional on liveness.
Key code
Re-validate document and view before scheduling from a promise callback (view_transition.cc)
if (--wait_until_pending_promise_count_ == 0) {
- document_->View()->ScheduleAnimation();
+ // Since this call happens via some promise resolution, it could be the case
+ // that we're in a tear-down or some other state where we don't have a View
+ // anymore.
+ if (document_ && document_->View()) {
+ document_->View()->ScheduleAnimation();
+ }
}
Patch walkthrough
third_party/blink/renderer/core/view_transition/view_transition.cc— In the waitUntil pending-promise decrement handler, after the count reaches zero, the unconditional document_->View()->ScheduleAnimation() is wrapped in if (document_ && document_->View()). This is a pure liveness guard: it changes no state, and when document/view are present behavior is identical. When they are absent (teardown), the schedule is skipped instead of dereferencing null. A comment documents that the callback fires from arbitrary promise resolution and therefore cannot assume a live view.
Background
View Transitions API — document.startViewTransition() animates between DOM states. Its implementation coordinates with the compositor and requests animation frames as the transition progresses.
waitUntil pending-promise count — Author or internal code can extend a transition phase by supplying promises; wait_until_pending_promise_count_ tracks how many are still unsettled. Reaching zero means the phase can proceed, prompting a ScheduleAnimation() to drive the next step.
LocalFrameView / document_->View() — The view is the frame’s rendering surface. On detach/teardown the document’s view pointer is cleared, so document_->View() can legitimately be null even while a ViewTransition object still exists and holds callbacks.
Promise-resolution timing — Microtask callbacks run at points determined by the JS engine and page, not by Blink internals. Any Blink object reached from such a callback must re-check the liveness of everything it dereferences, because navigation/detach can have occurred in between.
Vulnerability window
- Introduction — Present since the waitUntil scheduling path assumed a live view at count-zero; the teardown race was latent until a page settled its last waitUntil promise during detach.
- Discovery — Tracked as crbug 476179565; framed by the author as making waitUntil ‘resilient when View disappears’, consistent with a crash observed or anticipated during teardown rather than an exploit report.
- Fix — 2026-01-21, main@{#1572580}, by Vladimir Levin, reviewed by Steve Kobes. Landed as a robustness/null-check change, not a tagged security release.
Proof of concept
No test shipped in this diff. Recipe to reproduce (crash/DoS only, no exploit): start a view transition that supplies one or more author promises via waitUntil, then during the transition detach the associated document/frame (e.g. remove the iframe running the transition, or navigate it) so its view is torn down while a waitUntil promise is still pending, and resolve that last promise so the count hits zero after detach. Pre-fix, the count-zero handler dereferences document_->View() when it is null and crashes the renderer. Timing the last resolution to land after view teardown is racy and not precisely controllable; no working exploit is demonstrated.
Exploitation
- Precondition — Attacker script must run a view transition with pending waitUntil promises and arrange for the document/frame to detach (view destroyed) before the final promise settles.
- Primitive — Null-pointer dereference on an internal Blink pointer (document_ or its View) -> renderer crash. No control over the dereferenced address, no write, no disclosure.
- Blockers — The teardown-vs-resolution ordering is a race the page nudges but cannot deterministically win, and the crash is on a genuinely-null owning pointer rather than a freed-and-reclaimed object, so it does not yield a UAF-style primitive. Hence low/DoS and watch-tier.
Detection & hunting
For defenders and detection engineers:
- Crash telemetry — Look for renderer null-deref crashes with ViewTransition waitUntil / ScheduleAnimation frames on the stack, correlated with frame detach/navigation occurring during an active view transition.
- Content heuristic — Pages using startViewTransition with waitUntil promises inside frames that are removed or navigated mid-transition are the trigger surface; use for regression/fuzz corpora.
Audit directions
- view_transition.cc — Audit all other document_->View(), document_->GetFrame(), and compositor-scheduling calls reachable from promise/callback paths in this file for the same missing liveness check, especially any that run after author-supplied promises settle.
- Promise-callback lifetime pattern — Across Blink, review callbacks invoked from arbitrary JS promise resolution (WebGPU, Payment, Background Fetch, Web Animations finished/ready) that dereference document/frame/view without re-checking liveness; teardown-time null derefs of this shape recur.