0f12a22f12637fe370ae7eba0a4f653b4c386356 Unregister AnimationFrameTimingMonitor from CoreProbeSink on Shutdown
Triage note: Shutdown() never called RemoveAnimationFrameTimingMonitor(), so after WebFrameWidgetImpl::Close() tore down the monitor a FrameRelatedTask probe ran ReportLongTaskTiming() on the closed widget. Proven defect is a use-after-shutdown (Oilpan objects, not shown freed); UAF/RCE escalation is inferred. postMessage is the analogous FrameRelatedTask scope; the regression test reproduces via a synthetic probe scope, not postMessage.
The bug at a glance
A use-after-shutdown in the Blink renderer’s Long Animation Frame (LoAF) timing instrumentation, reachable from ordinary web content. AnimationFrameTimingMonitor registers itself with its CoreProbeSink at construction, but the original Shutdown() never unregistered, so a probe callback still fires after the monitor has been shut down and its widget ‘closed’. That stale-callback-after-shutdown is the proven defect. It is NOT demonstrably a use-after-free: both the monitor and its client (WebFrameWidgetImpl) are Oilpan-managed (Member<>/Trace) and are not synchronously freed by Shutdown(), so at the callback the objects are still allocated but in a torn-down state (enabled_ left true pre-fix, local_root_ reset) — the likely outcome is a null/bad-state dereference crash (renderer DoS), not memory corruption. Escalation to a real UAF would require showing the GC reclaims the widget/monitor between Shutdown() and the stale callback, which the diff does not establish. Scored as a security-relevant lifetime fix at watch level (medium), not a confirmed exploitable UAF.
A performance-observability object (AnimationFrameTimingMonitor for the LoAF API) registers itself into CoreProbeSink at construction but its teardown path forgot the matching unregistration. The interesting part is the reentrancy window: the frame is detached while a probe scope is still on the stack, so the probe’s destructor fires a callback into an object that Shutdown() has already logically killed — a use-after-shutdown, with any reach into freed memory left unproven by the diff.
Root cause
AnimationFrameTimingMonitor::AnimationFrameTimingMonitor() calls sink->AddAnimationFrameTimingMonitor(this), registering the monitor with its CoreProbeSink, but the original Shutdown() only called Thread::Current()->RemoveTaskTimeObserver(this) and never removed itself from the CoreProbeSink. When a local root frame is detached inside a probe::FrameRelatedTask scope (e.g. during LocalFrame::PostMessageEvent), WebFrameWidgetImpl::Close() runs Shutdown() on the monitor and resets local_root_, but the stale registration remains in the sink. As that FrameRelatedTask scope unwinds, ~FrameRelatedTask() invokes Did() -> OnMainThreadTaskCompleted() -> client_->ReportLongTaskTiming() on the now-torn-down monitor and its closed widget client. The fix stores a Member probe_sink_, calls probe_sink_->RemoveAnimationFrameTimingMonitor(this) and nulls it in Shutdown(), and early-returns from OnMainThreadTaskCompleted() when !enabled_, so a residual in-flight probe callback cannot touch the dead client; Trace() was updated to visit probe_sink_.
Key code
Shutdown() now unregisters from CoreProbeSink, and OnMainThreadTaskCompleted() bails when disabled (animation_frame_timing_monitor.cc).
void AnimationFrameTimingMonitor::Shutdown() {
enabled_ = false;
frame_handling_input_ = nullptr;
if (probe_sink_) {
probe_sink_->RemoveAnimationFrameTimingMonitor(this);
probe_sink_ = nullptr;
}
Thread::Current()->RemoveTaskTimeObserver(this);
}
// OnMainThreadTaskCompleted(...)
if (!enabled_) {
return;
}
Patch walkthrough
third_party/blink/renderer/core/frame/animation_frame_timing_monitor.cc— Constructor stores the sink as probe_sink_. Shutdown() now calls probe_sink_->RemoveAnimationFrameTimingMonitor(this) and sets probe_sink_ = nullptr, closing the register/unregister asymmetry. OnMainThreadTaskCompleted() gains an ‘if (!enabled_) return;’ guard so a probe callback that still fires after Shutdown() does nothing. Trace() visits probe_sink_.third_party/blink/renderer/core/frame/animation_frame_timing_monitor.h— Adds the new member ‘Member probe_sink_;’ so the monitor retains a traced handle to the sink it must unregister from, rather than only using the raw sink pointer at construction time.third_party/blink/renderer/core/frame/animation_frame_timing_monitor_test.cc— Adds regression test DetachChildLocalRootDuringFrameRelatedTask: opens a probe::FrameRelatedTask scope on the frame, runs a long script, then calls LocalFrameRoot().Detach() while still inside the scope and asserts probe_sink->HasAnimationFrameTimingMonitors() is false afterward.
Background
AnimationFrameTimingMonitor — Blink object that measures long animation frames / long tasks for the Long Animation Frame (LoAF) performance API. It is both a TaskTimeObserver and a CoreProbeSink client, so it must be torn down symmetrically on both.
CoreProbeSink — Per-frame hub that fans out instrumentation probes (begin/end task hooks) to registered observers such as DevTools agents and the timing monitor. Registration is via AddAnimationFrameTimingMonitor / RemoveAnimationFrameTimingMonitor.
probe::FrameRelatedTask — A scoped probe object; its destructor (~FrameRelatedTask) calls Did() on the sink’s observers. LocalFrame::PostMessageEvent is one such FrameRelatedTask scope, which is why web-triggered postMessage delivery can hold this scope open across a frame detach.
Shutdown asymmetry — The root bug class: an object self-registers in a container in its constructor but its teardown removes only some of its registrations, leaving a dangling entry that outlives the object.
Vulnerability window
- Setup — A page with a local-root subframe causes an AnimationFrameTimingMonitor to be constructed and registered into that frame’s CoreProbeSink.
- Trigger — Work runs inside a probe::FrameRelatedTask scope (e.g. LocalFrame::PostMessageEvent delivering a postMessage), and during that scope the local root frame is detached.
- Teardown — WebFrameWidgetImpl::Close() runs the monitor’s Shutdown() and resets local_root_, but the stale CoreProbeSink registration survives.
- Use-after-shutdown — As the FrameRelatedTask scope unwinds, ~FrameRelatedTask() -> Did() -> OnMainThreadTaskCompleted() -> client_->ReportLongTaskTiming() runs on the shut-down monitor and the closed widget (local_root_ null) — a bad-state dereference; freed-memory access is not established by the diff.
Proof of concept
The diff ships a concrete SIM-based unit test. It opens a FrameRelatedTask probe scope on the local root, runs a ~60ms busy-loop script (so the monitor treats it as a long task), then detaches the frame while the scope is still live; the ~FrameRelatedTask destructor at the end of the block is the moment that, pre-fix, called back into the shut-down monitor. From the web the analogous trigger is delivering work inside a FrameRelatedTask scope on a local-root subframe and detaching that frame mid-scope. The regression test reproduces via a synthetic probe::FrameRelatedTask scope plus LocalFrameRoot().Detach() — NOT via postMessage; LocalFrame::PostMessageEvent is only the analogous real-world FrameRelatedTask scope (inferred, not the reproduction). Whether the callback reaches freed memory, and any escalation, are inferred.
TEST_F(AnimationFrameTimingMonitorTest,
DetachChildLocalRootDuringFrameRelatedTask) {
InitializeRemote();
LocalFrame* frame = LocalFrameRoot().GetFrame();
CoreProbeSink* probe_sink = frame->GetProbeSink();
ASSERT_TRUE(probe_sink->HasAnimationFrameTimingMonitors());
{
probe::FrameRelatedTask probe(frame->DomWindow());
LocalFrameRoot().ExecuteScript(WebScriptSource("while(performance.now()<60){}"));
LocalFrameRoot().Detach();
EXPECT_FALSE(probe_sink->HasAnimationFrameTimingMonitors());
}
}
Exploitation
- Reach — Drive a local-root subframe (out-of-process iframe) and deliver work that runs inside a probe::FrameRelatedTask scope (postMessage dispatch per the commit message), arranging for the frame to detach during that scope.
- Teardown — The detach runs WebFrameWidgetImpl::Close() -> AnimationFrameTimingMonitor::Shutdown(), which logically tears the monitor down and resets the widget’s local_root_, while the stale CoreProbeSink registration persists. Neither object is shown to be freed here — both are Oilpan-managed.
- Use-after-shutdown — The unwinding ~FrameRelatedTask() calls OnMainThreadTaskCompleted() -> client_->ReportLongTaskTiming() through the stale registration, on a monitor that was never disabled and a widget whose local_root_ is now null — a bad-state dereference, most plausibly a crash (renderer DoS). Reaching freed memory, and any escalation to controlled corruption, are inferred and not established by the diff.
Detection & hunting
For defenders and detection engineers:
- Renderer crash on frame detach during a probe scope — Look for null/bad-state dereference crashes with a use-stack through ~FrameRelatedTask -> CoreProbeSink::Did -> AnimationFrameTimingMonitor::OnMainThreadTaskCompleted -> Client::ReportLongTaskTiming, following WebFrameWidgetImpl::Close / AnimationFrameTimingMonitor::Shutdown. An ASAN heap-use-after-free on that same use-stack would confirm the (inferred) UAF escalation.
- Lingering sink registration — Instrument CoreProbeSink::HasAnimationFrameTimingMonitors() to be false after a frame’s widget Close()/Shutdown(); a true value post-teardown is the exact invariant the regression test checks.
Audit directions
- CoreProbeSink clients — Enumerate every object that calls a CoreProbeSink Add*/register method in its constructor and confirm a matching Remove* runs on all teardown paths (Shutdown, Dispose, destructor), not just the TaskTimeObserver removal.
- Probe-scope reentrancy — Audit other probe::*Task scopes for frame/widget detach occurring inside the scope, where the scope destructor fires callbacks into objects that detach already destroyed.