CVE-2026-9931
Overview
Background
- `ui::LayerAnimationSequence`
- a browser-process object (
ui/compositor/) representing an ordered list ofLayerAnimationElements applied to a compositorLayer. ItsProgress()/ProgressToEnd()/Abort()/Start()methods drive the elements and notify aLayerAnimationDelegate(typically aui::Layer). - Re-entrant deletion
- when an element calls into the delegate (e.g.
SetBoundsFromAnimation,SetBrightnessFromAnimation), theLayersynchronously notifies observers (views::View,aura::Window). An observer may callLayerAnimator::StopAnimating(), whoseFinishAnimationmoves the running sequence into a localstd::unique_ptrthat is destroyed on return — freeing the sequence mid-method. - Implicit `this`
- after such an out-call, the sequence’s own methods keep touching member variables (
elements_,last_element_,last_progressed_fraction_) throughthis, which now points at freed memory. - `base::WeakPtr` / `AsWeakPtr()`
- the standard Chromium liveness guard: take a weak pointer before an out-call, and bail if it is invalidated by re-entrant destruction. BackupRefPtr does not protect this path because the access is via the raw implicit
this, not araw_ptr.
Root Cause Analysis
The invariant is that after any call that can re-enter the animator and destroy the sequence, LayerAnimationSequence must not touch its own members. Progress() already took base::WeakPtr<LayerAnimationSequence> alive(AsWeakPtr()) around the per-frame elements_[current_index]->Progress(now, delegate) out-call — but it had no guard around the earlier elements_[current_index]->ProgressToEnd(delegate) out-call in the “let the element we’re passing finish” branch of the while loop. ProgressToEnd notifies the delegate → Layer → observers; an observer that stops/preempts the animation triggers LayerAnimator::FinishAnimation, which destroys the sequence. Control then returns into the loop, which executes last_start_ += element_duration; ++last_element_; last_progressed_fraction_ = ... on freed memory, and on the next iteration may make a virtual call on a freed element — a use-after-free in the browser process. The same unguarded-out-call pattern existed in the element-finish loops of ProgressToEnd() itself and in Abort(), and in the Start() call near the top of the sequence.
The patch installs base::WeakPtr liveness checks around every re-entrant out-call. In LayerAnimationSequence::Progress (ui/compositor/layer_animation_sequence.cc) the alive weak pointer is created once before the loop; a if (!alive) return; is added immediately after the ProgressToEnd(delegate) call, and the existing per-frame Progress call is restructured so the liveness is checked both before and after it. Start() gains alive(AsWeakPtr()) around elements_[0]->Start(...) with an early return. ProgressToEnd() and Abort() each take alive before their element loops and return if the weak pointer is cleared after an element out-call. The regression test LayerAnimatorTest.ProgressToEndInWhileLoopFreesSequenceUAF with a PreemptingDelegate re-enters via StopAnimatingProperty(BRIGHTNESS) during SetBrightnessFromAnimation, reproducing the exact free-and-continue path. The advisory’s “Use-after-free” (CWE-416) is confirmed by the diff.
LayerAnimationSequence::Progress guarded only its per-frame Progress out-call with a WeakPtr check and left the earlier ProgressToEnd out-call (and the analogous calls in ProgressToEnd, Abort, and Start) unguarded — so a re-entrant observer that stopped the animation could free the sequence while the method kept using this.Attack Path
- Trigger a browser-process UI animation
from a compromised renderer, drive DOM/UI actions that start a
Layeranimation (window resize, tabstrip/brightness-style property animations). - Arm a preempting observer
arrange UI state so that a
View/Windowobserver, on receiving the property-change notification during an element’sProgressToEnd, synchronously callsLayerAnimator::StopAnimating()/StopAnimatingProperty(). - Free the sequence
the stop path runs
FinishAnimation, moving the sequence into a localunique_ptrthat is destroyed when it returns, freeing theLayerAnimationSequenceand its elements mid-loop. - Reclaim (inferred)
concurrently spray the browser-process heap (e.g. via Mojo
BlobRegistry/data pipes) to place attacker-controlled bytes into the freed chunk. - Control-flow hijack (inferred)
when
Progress()resumes it reads the reclaimedelements_and invokes a virtual method on a forged element, dereferencing an attacker vtable — a browser-process RCE / sandbox escape.
Impact Assessment
this, not a raw_ptr member. CVSS 8.3 (S:C) reflects the browser-process/sandbox-escape potential.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifui/compositor/layer_animation_sequence.cc |
modified | |
PreemptingDelegateui/compositor/layer_animator_unittest.cc |
modified | |
TESTui/compositor/layer_animator_unittest.cc |
modified |
Files Changed
ui/compositor/layer_animation_sequence.ccui/compositor/layer_animator_unittest.cc
Audit Directions
- Unguarded delegate/element out-callsaudit all of
ui/compositorandui/viewsanimation code for methods that call into a delegate/observer and then touchthis/members without aWeakPtrrecheck; this class of re-entrancy UAF is easy to reintroduce. - `FinishAnimation`/`StopAnimating` re-entrancymap every call site that can re-enter
LayerAnimatorand destroy the running sequence, and confirm each caller is guarded. - Observer notification during property set
Layer::Set*FromAnimationsynchronously notifies observers; review other animated-property setters for callers that assume the sequence survives the notification. - Implicit-`this` UAFs vs MiraclePtrbecause BRP cannot protect implicit
this, flag long-lived objects that make synchronous observer out-calls mid-method as candidates forWeakPtr-guarded patterns.
Patch
From 6eb376c4e511ff1e8ca1b802cec5cbbb5df7ab85 Mon Sep 17 00:00:00 2001
From: Tzarial <zork@google.com>
Date: Thu, 14 May 2026 21:11:30 -0700
Subject: [PATCH] [ui/compositor] Fix heap-use-after-free in LayerAnimationSequence
This CL fixes a heap-use-after-free bug in LayerAnimationSequence where
the sequence could be destroyed re-entrantly during its own execution.
The vulnerability existed in several methods (Progress, ProgressToEnd,
Abort, and Start) that call out to the LayerAnimationDelegate or its
LayerAnimationElements. These out-calls can trigger observers (e.g.,
aura::WindowObserver or LayoutManager) to re-enter the LayerAnimator
and stop or preempt the running sequence, causing it to be deleted.
When control returned to LayerAnimationSequence, it would continue
accessing member variables or iterating through its elements, resulting
in a use-after-free.
Changes:
- Added base::WeakPtr<LayerAnimationSequence> guards around all
potentially re-entrant calls in LayerAnimationSequence::Progress,
ProgressToEnd, Abort, and Start.
- The sequence now early-returns or breaks out of loops if it detects
it has been destroyed during an out-call.
- Added a regression test ProgressToEndInWhileLoopFreesSequenceUAF in
layer_animator_unittest.cc which specifically reproduces the UAF
via a re-entrant StopAnimatingProperty call.
Fixed: 501524262
Change-Id: I6f7d68edbd0230770c4a2dc4472a4fde133a139b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7846156
Commit-Queue: Tzarial <zork@chromium.org>
Reviewed-by: Jonathan Ross <jonross@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1631050}
---
diff --git a/ui/compositor/layer_animation_sequence.cc b/ui/compositor/layer_animation_sequence.cc
index 6d793ff51..ffa98f8 100644
--- a/ui/compositor/layer_animation_sequence.cc
+++ b/ui/compositor/layer_animation_sequence.cc
@@ -57,7 +57,11 @@
"indefinite amount of time without any actual animated content";
elements_[0]->set_requested_start_time(start_time_);
+ base::WeakPtr<LayerAnimationSequence> alive(AsWeakPtr());
elements_[0]->Start(delegate, animation_group_id_);
+ if (!alive) {
+ return;
+ }
NotifyStarted();
@@ -75,6 +79,8 @@
if (last_element_ == 0)
last_start_ = start_time_;
+ base::WeakPtr<LayerAnimationSequence> alive(AsWeakPtr());
+
const base::TimeDelta total_duration = GetTotalDurationOfAllElements();
const auto animation_should_progress = [this, total_duration]() {
// A repeating animation with zero total duration results in an infinite
@@ -95,6 +101,11 @@
// Let the element we're passing finish.
if (elements_[current_index]->ProgressToEnd(delegate))
redraw_required = true;
+
+ if (!alive) {
+ return;
+ }
+
last_start_ += element_duration;
++last_element_;
last_progressed_fraction_ =
@@ -109,15 +120,22 @@
animation_group_id_ = cc::AnimationIdProvider::NextGroupId();
elements_[current_index]->Start(delegate, animation_group_id_);
}
- base::WeakPtr<LayerAnimationSequence> alive(AsWeakPtr());
- if (elements_[current_index]->Progress(now, delegate))
- redraw_required = true;
- if (!alive)
+
+ if (!alive) {
return;
+ }
+
+ if (elements_[current_index]->Progress(now, delegate)) {
+ redraw_required = true;
+ }
+
+ if (!alive) {
+ return;
+ }
+
last_progressed_fraction_ =
elements_[current_index]->last_progressed_fraction();
}
-
// Since the delegate may be deleted due to the notifications below, it is
// important that we schedule a draw before sending them.
if (redraw_required)
@@ -166,10 +184,17 @@
if (elements_.empty())
return;
+ base::WeakPtr<LayerAnimationSequence> alive(AsWeakPtr());
+
size_t current_index = last_element_ % elements_.size();
while (current_index < elements_.size()) {
if (elements_[current_index]->ProgressToEnd(delegate))
redraw_required = true;
+
+ if (!alive) {
+ return;
+ }
+
last_progressed_fraction_ =
elements_[current_index]->last_progressed_fraction();
++current_index;
@@ -199,9 +224,13 @@
}
void LayerAnimationSequence::Abort(LayerAnimationDelegate* delegate) {
+ base::WeakPtr<LayerAnimationSequence> alive(AsWeakPtr());
size_t current_index = last_element_ % elements_.size();
while (current_index < elements_.size()) {
elements_[current_index]->Abort(delegate);
+ if (!alive) {
+ return;
+ }
++current_index;
}
last_element_ = 0;
diff --git a/ui/compositor/layer_animator_unittest.cc b/ui/compositor/layer_animator_unittest.cc
index 53c9aa7..1b9d4fb648 100644
--- a/ui/compositor/layer_animator_unittest.cc
+++ b/ui/compositor/layer_animator_unittest.cc
@@ -3825,4 +3825,55 @@
}
}
+// Regression test for heap-use-after-free in LayerAnimationSequence::Progress.
+class PreemptingDelegate : public TestLayerAnimationDelegate {
+ public:
+ PreemptingDelegate() = default;
+ ~PreemptingDelegate() override = default;
+
+ void set_animator(LayerAnimator* animator) { animator_ = animator; }
+ void Arm() { armed_ = true; }
+
+ void SetBrightnessFromAnimation(float brightness,
+ PropertyChangeReason reason) override {
+ TestLayerAnimationDelegate::SetBrightnessFromAnimation(brightness, reason);
+ // Re-enter the animator to stop the sequence and trigger deletion.
+ if (!armed_ || reentered_ ||
+ reason != PropertyChangeReason::FROM_ANIMATION) {
+ return;
+ }
+ reentered_ = true;
+ animator_->StopAnimatingProperty(LayerAnimationElement::BRIGHTNESS);
+ }
+
+ private:
+ raw_ptr<LayerAnimator> animator_ = nullptr;
+ bool armed_ = false;
+ bool reentered_ = false;
+};
+
+TEST(LayerAnimatorTest, ProgressToEndInWhileLoopFreesSequenceUAF) {
+ scoped_refptr<LayerAnimator> animator = CreateDefaultTestAnimator(nullptr);
+ PreemptingDelegate delegate;
+ animator->SetDelegate(&delegate);
+ delegate.set_animator(animator.get());
+
+ // Create a two-element sequence where the first element is finished on
+ // Step().
+ LayerAnimationSequence* seq = new LayerAnimationSequence();
+ seq->AddElement(LayerAnimationElement::CreateBrightnessElement(
+ 0.5f, base::Milliseconds(10)));
+ seq->AddElement(LayerAnimationElement::CreateBrightnessElement(
+ 1.0f, base::Milliseconds(1000)));
+
+ animator->StartAnimation(seq);
+ ASSERT_TRUE(animator->is_animating());
+
+ base::TimeTicks start_time = animator->last_step_time();
+ delegate.Arm();
+
+ // Step far enough to finish the first element and trigger the UAF path.
+ animator->Step(start_time + base::Milliseconds(50));
+}
+
} // namespace ui
Regression Test / PoC
diff --git a/ui/compositor/layer_animator_unittest.cc b/ui/compositor/layer_animator_unittest.cc
index 53c9aa7..1b9d4fb648 100644
--- a/ui/compositor/layer_animator_unittest.cc
+++ b/ui/compositor/layer_animator_unittest.cc
@@ -3825,4 +3825,55 @@
}
}
+// Regression test for heap-use-after-free in LayerAnimationSequence::Progress.
+class PreemptingDelegate : public TestLayerAnimationDelegate {
+ public:
+ PreemptingDelegate() = default;
+ ~PreemptingDelegate() override = default;
+
+ void set_animator(LayerAnimator* animator) { animator_ = animator; }
+ void Arm() { armed_ = true; }
+
+ void SetBrightnessFromAnimation(float brightness,
+ PropertyChangeReason reason) override {
+ TestLayerAnimationDelegate::SetBrightnessFromAnimation(brightness, reason);
+ // Re-enter the animator to stop the sequence and trigger deletion.
+ if (!armed_ || reentered_ ||
+ reason != PropertyChangeReason::FROM_ANIMATION) {
+ return;
+ }
+ reentered_ = true;
+ animator_->StopAnimatingProperty(LayerAnimationElement::BRIGHTNESS);
+ }
+
+ private:
+ raw_ptr<LayerAnimator> animator_ = nullptr;
+ bool armed_ = false;
+ bool reentered_ = false;
+};
+
+TEST(LayerAnimatorTest, ProgressToEndInWhileLoopFreesSequenceUAF) {
+ scoped_refptr<LayerAnimator> animator = CreateDefaultTestAnimator(nullptr);
+ PreemptingDelegate delegate;
+ animator->SetDelegate(&delegate);
+ delegate.set_animator(animator.get());
+
+ // Create a two-element sequence where the first element is finished on
+ // Step().
+ LayerAnimationSequence* seq = new LayerAnimationSequence();
+ seq->AddElement(LayerAnimationElement::CreateBrightnessElement(
+ 0.5f, base::Milliseconds(10)));
+ seq->AddElement(LayerAnimationElement::CreateBrightnessElement(
+ 1.0f, base::Milliseconds(1000)));
+
+ animator->StartAnimation(seq);
+ ASSERT_TRUE(animator->is_animating());
+
+ base::TimeTicks start_time = animator->last_step_time();
+ delegate.Arm();
+
+ // Step far enough to finish the first element and trigger the UAF path.
+ animator->Step(start_time + base::Milliseconds(50));
+}
+
} // namespace ui
Original Bug Report
Potential Use-After-Free in LayerAnimationSequence::Progress via re-entrant deletion
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team.
Overview: A potential Use-After-Free exists in LayerAnimationSequence::Progress() due to an unguarded outcall to ProgressToEnd(). Synchronous observer callbacks during this outcall can re-entrantly destroy the animation sequence, leading to member accesses on freed memory and potential browser process code execution.
Affected files:
ui/compositor/layer_animation_sequence.cc
Estimated timestamp from git blame: 2021-02-25
Vulnerability Details
A potential Use-After-Free (UAF) vulnerability exists in ui/compositor/layer_animation_sequence.cc. The LayerAnimationSequence::Progress() method processes a sequence of animation elements in a while loop. At line 96, it makes an unguarded outcall:
// Let the element we're passing finish.
if (elements_[current_index]->ProgressToEnd(delegate))
redraw_required = true;
last_start_ += element_duration;
++last_element_;
// ...
When ProgressToEnd(delegate) is called on the element, it notifies the delegate (typically a ui::Layer) to update its properties (e.g., SetBoundsFromAnimation). The Layer then synchronously notifies its observers (e.g., views::View or aura::Window components) of the property change.
If an observer reacts to this notification by aborting the animation or starting a new, conflicting animation, it triggers a call to LayerAnimator::StopAnimating(). This results in LayerAnimator::FinishAnimation() being called for the active sequence.
FinishAnimation removes the sequence from the animator’s queue, transferring ownership into a local std::unique_ptr<LayerAnimationSequence> removed. When FinishAnimation returns, this local pointer goes out of scope, destroying the LayerAnimationSequence and its associated elements.
As the stack unwinds, execution returns to the while loop in LayerAnimationSequence::Progress(). Because there is no liveness check after the outcall, the function continues to execute lines 98-105 using the implicitly captured this pointer, which now points to freed memory.
Because the access is via the implicit this pointer rather than an explicit base::raw_ptr, BackupRefPtr (BRP) does not mitigate this UAF.
Potential Attacker Steps
Note: These are suggested steps; our tooling agent cannot dynamically run code to verify the full chain.
- Trigger UI Animation: An attacker in a compromised renderer process uses JavaScript/DOM manipulation to trigger a browser-process UI animation (e.g., rapidly resizing a window or triggering tab strip animations).
- Force Re-entrant Deletion: The attacker orchestrates the UI state so that a View observer synchronously stops the active animation in response to a bounds or transform update during an animation tick.
- Reclaim Memory: Concurrent with the animation tick, the attacker uses Mojo primitives (e.g.,
BlobRegistryor Data Pipes) to spray the browser process heap, attempting to allocate attacker-controlled data into the exact memory chunk freed by the sequence’s destruction. - Control Flow Hijack: When
LayerAnimationSequence::Progress()resumes, it reads from the attacker-controlledelements_vector. On the next loop iteration, it attempts to call a virtual method (e.g.,IsFinishedorStart) on an element. This dereferences an attacker-supplied vtable pointer, leading to arbitrary code execution (a Sandbox Escape).
Recommended Fix
The call to ProgressToEnd() at line 96 should be guarded with a base::WeakPtr check. The developers already recognized this re-entrancy risk for the Progress() call at line 113 of the exact same function:
base::WeakPtr<LayerAnimationSequence> alive(AsWeakPtr());
if (elements_[current_index]->Progress(now, delegate))
redraw_required = true;
if (!alive)
return;
A similar check must be applied around line 96. Additionally, the Start(...) call at line 110, as well as the element iteration loops in LayerAnimationSequence::ProgressToEnd() and LayerAnimationSequence::Abort(), lack WeakPtr checks and should be audited to prevent similar re-entrancy UAFs.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.