Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Animation
DescriptionUse after free in Animation
ComponentAnimation
Bug ClassUAF
Tracker496285281
Fix commit23356b88c87f (chromium/src) +98/-11
CISA KEVNot listed
CreditedGoogle
Disclosed2026-04-28

Changed Functions

FunctionChangeNotes
PromiseHandler
third_party/blink/renderer/core/animation/animation_test.cc
modified
if
third_party/blink/renderer/core/animation/animation_test.cc
modified
TEST_F
third_party/blink/renderer/core/animation/animation_test.cc
modified
get
third_party/blink/renderer/core/animation/animation_test.cc
modified
ASSERT_TRUE
third_party/blink/renderer/core/animation/animation_test.cc
modified
AnimationTypeMetricsTest
third_party/blink/renderer/core/animation/animation_test.cc
modified
switch
third_party/blink/renderer/core/animation/animation_trigger.cc
modified
if
third_party/blink/renderer/core/animation/animation_trigger.cc
modified

Files Changed

  • third_party/blink/renderer/core/animation/animation.h
  • third_party/blink/renderer/core/animation/animation_test.cc
  • third_party/blink/renderer/core/animation/animation_trigger.cc
From 23356b88c87fec67905ebea007db6f4c86018fc1 Mon Sep 17 00:00:00 2001
From: David Awogbemila <[email protected]>
Date: Thu, 16 Apr 2026 11:53:26 -0700
Subject: [PATCH] [animation-trigger] Fix use-after-free in PerformActivate

This patch fixes a use-after-free bug in PerformActivate which happened
in the linked issue because PerformActivate could run script. This patch
fixes this by establishing a ScriptForbiddenScope to prevent script from
being run during activation or deactivation.

Bug: 496285281
Change-Id: If99df7d585f3581efbec0c245b7940f99b022461
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7765909
Commit-Queue: David A <[email protected]>
Reviewed-by: Vladimir Levin <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1616035}
---

diff --git a/third_party/blink/renderer/core/animation/animation.h b/third_party/blink/renderer/core/animation/animation.h
index d10b1e4..ae732c8 100644
--- a/third_party/blink/renderer/core/animation/animation.h
+++ b/third_party/blink/renderer/core/animation/animation.h
@@ -806,6 +806,8 @@
   FRIEND_TEST_ALL_PREFIXES(CSSAnimationsTriggerTest, ChangeTriggerAttachments);
   FRIEND_TEST_ALL_PREFIXES(CSSAnimationsTriggerTest,
                            SameTriggerNameDifferentSource);
+  FRIEND_TEST_ALL_PREFIXES(ScriptedTimelineTriggerTest,
+                           ForbidScriptDuringActivation);
 };
 
 }  // namespace blink
diff --git a/third_party/blink/renderer/core/animation/animation_test.cc b/third_party/blink/renderer/core/animation/animation_test.cc
index 38980ddf..be848d63 100644
--- a/third_party/blink/renderer/core/animation/animation_test.cc
+++ b/third_party/blink/renderer/core/animation/animation_test.cc
@@ -35,6 +35,7 @@
 #include <tuple>
 
 #include "base/test/metrics/histogram_tester.h"
+#include "base/test/run_until.h"
 #include "build/build_config.h"
 #include "cc/trees/target_property.h"
 #include "testing/gmock/include/gmock/gmock.h"
@@ -79,6 +80,7 @@
 #include "third_party/blink/renderer/core/page/page_animator.h"
 #include "third_party/blink/renderer/core/paint/paint_layer.h"
 #include "third_party/blink/renderer/core/paint/paint_layer_scrollable_area.h"
+#include "third_party/blink/renderer/core/script/classic_script.h"
 #include "third_party/blink/renderer/core/testing/core_unit_test_helper.h"
 #include "third_party/blink/renderer/core/testing/page_test_base.h"
 #include "third_party/blink/renderer/platform/animation/compositor_animation.h"
@@ -2772,7 +2774,9 @@
     test::RunPendingTasks();
   }
 
-  void Initialize() {
+  void Initialize(std::string activate = "play-forwards",
+                  std::string deactivate = "play-backwards",
+                  std::string post_setup_code = "") {
     const char html[] = R"HTML(
       <style>
       div {
@@ -2787,7 +2791,8 @@
 
     UpdateAllLifecyclePhasesForTest();
 
-    const char make_animation_js[] = (R"JS(
+    String make_animation_js = String::Format(
+        R"JS(
       function setupTriggeredAnimation() {
         const animation = new Animation(
           new KeyframeEffect(
@@ -2803,14 +2808,17 @@
           timeline: new ViewTimeline({
             subject: document.getElementById('subject'), axis: "y"
           }),
-          activationRangeStart: "contain 0%",
-          activationRangeEnd: "contain 100%"}]);
+          activationRangeStart: "contain",
+          activationRangeEnd: "contain"}]);
+                                       /* activate */ /* deactivate */
+        trigger.addAnimation(animation,    "%s",           "%s"       );
 
-        trigger.addAnimation(animation, "play-forwards", "play-backwards");
+        // Run post-setup JS.
+        %s
       }
-
       setupTriggeredAnimation();
-    )JS");
+    )JS",
+        activate.c_str(), deactivate.c_str(), post_setup_code.c_str());
 
     ExecuteScript(make_animation_js);
 
@@ -2833,6 +2841,20 @@
     EXPECT_NE(animation_, nullptr);
   }
 
+  class PromiseHandler final : public ThenCallable<Animation, PromiseHandler> {
+   public:
+    explicit PromiseHandler(base::OnceClosure callback)
+        : callback_(std::move(callback)) {}
+    void React(ScriptState* script_state, Animation* animation) {
+      if (callback_) {
+        std::move(callback_).Run();
+      }
+    }
+
+   private:
+    base::OnceClosure callback_;
+  };
+
  protected:
   frame_test_helpers::WebViewHelper helper_;
   // The element that is the target of |animation_|.
@@ -3011,6 +3033,51 @@
   EXPECT_EQ(animation_, nullptr);
 }
 
+TEST_F(ScriptedTimelineTriggerTest, ForbidScriptDuringActivation) {
+  // Define 'then' getter. This runs synchronously.
+  std::string remove_animation_code =
+      R"JS(Object.defineProperty(Animation.prototype, 'then', {
+        get() {
+          trigger.removeAnimation(animation);
+          return undefined;
+        }
+      });
+      )JS";
+
+  Initialize(/* activate= */ "reset", /* deactivate= */ "none",
+             /* post_sectup_code*/ remove_animation_code);
+
+  // Ensure we are pending_pause_.
+  animation_->play();
+  animation_->pause();
+  EXPECT_TRUE(animation_->pending_pause_);
+
+  // Establish context necessary to arm ready promise.
+  ScriptState* script_state =
+      ToScriptStateForMainWorld(GetDocument().GetFrame());
+  v8::HandleScope handle_scope(script_state->GetIsolate());
+  ScriptState::Scope script_scope(script_state);
+
+  // Arm the ready promise.
+  bool ready_promise_resolved = false;
+  auto ready_callback = [](bool* did_resolve) { *did_resolve = true; };
+  animation_->ready(script_state)
+      .Then(script_state,
+            MakeGarbageCollected<PromiseHandler>(base::BindOnce(
+                std::move(ready_callback), &ready_promise_resolved)));
+
+  // Perform activate. This should not resolved the ready promise and should not
+  // run script.
+  trigger_->PerformActivate();
+  EXPECT_EQ(trigger_->BehaviorMap().size(), 1);
+  EXPECT_FALSE(ready_promise_resolved);
+
+  // Ensure the ready promise does get resolved in due time.
+  ASSERT_TRUE(base::test::RunUntil([&]() { return ready_promise_resolved; }));
+
+  EXPECT_EQ(trigger_->BehaviorMap().size(), 0);
+}
+
 class AnimationTypeMetricsTest : public AnimationAnimationTestCompositing {
  public:
   AnimationTypeMetricsTest() = default;
diff --git a/third_party/blink/renderer/core/animation/animation_trigger.cc b/third_party/blink/renderer/core/animation/animation_trigger.cc
index e8b9727d..771fb01 100644
--- a/third_party/blink/renderer/core/animation/animation_trigger.cc
+++ b/third_party/blink/renderer/core/animation/animation_trigger.cc
@@ -82,6 +82,7 @@
 void AnimationTrigger::PerformBehavior(Animation& animation,
                                        Behavior behavior,
                                        ExceptionState& exception_state) {
+  ScriptForbiddenScope forbid_script;
   V8AnimationPlayState::Enum play_state =
       animation.CalculateAnimationPlayState();
   switch (behavior) {
@@ -175,6 +176,8 @@
     V8AnimationTriggerBehavior activate_behavior,
     V8AnimationTriggerBehavior deactivate_behavior,
     ExceptionState& exception_state) {
+  CHECK(!is_activating_or_deactivating_);
+
   if (!animation) {
     return;
   }
@@ -204,6 +207,8 @@
 }
 
 void AnimationTrigger::removeAnimation(Animation* animation) {
+  CHECK(!is_activating_or_deactivating_);
+
   if (!animation) {
     return;
   }
@@ -249,8 +254,9 @@
 }
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/animation/animation_test.cc b/third_party/blink/renderer/core/animation/animation_test.cc
index 38980ddf..be848d63 100644
--- a/third_party/blink/renderer/core/animation/animation_test.cc
+++ b/third_party/blink/renderer/core/animation/animation_test.cc
@@ -35,6 +35,7 @@
 #include <tuple>
 
 #include "base/test/metrics/histogram_tester.h"
+#include "base/test/run_until.h"
 #include "build/build_config.h"
 #include "cc/trees/target_property.h"
 #include "testing/gmock/include/gmock/gmock.h"
@@ -79,6 +80,7 @@
 #include "third_party/blink/renderer/core/page/page_animator.h"
 #include "third_party/blink/renderer/core/paint/paint_layer.h"
 #include "third_party/blink/renderer/core/paint/paint_layer_scrollable_area.h"
+#include "third_party/blink/renderer/core/script/classic_script.h"
 #include "third_party/blink/renderer/core/testing/core_unit_test_helper.h"
 #include "third_party/blink/renderer/core/testing/page_test_base.h"
 #include "third_party/blink/renderer/platform/animation/compositor_animation.h"
@@ -2772,7 +2774,9 @@
     test::RunPendingTasks();
   }
 
-  void Initialize() {
+  void Initialize(std::string activate = "play-forwards",
+                  std::string deactivate = "play-backwards",
+                  std::string post_setup_code = "") {
     const char html[] = R"HTML(
       <style>
       div {
@@ -2787,7 +2791,8 @@
 
     UpdateAllLifecyclePhasesForTest();
 
-    const char make_animation_js[] = (R"JS(
+    String make_animation_js = String::Format(
+        R"JS(
       function setupTriggeredAnimation() {
         const animation = new Animation(
           new KeyframeEffect(
@@ -2803,14 +2808,17 @@
           timeline: new ViewTimeline({
             subject: document.getElementById('subject'), axis: "y"
           }),
-          activationRangeStart: "contain 0%",
-          activationRangeEnd: "contain 100%"}]);
+          activationRangeStart: "contain",
+          activationRangeEnd: "contain"}]);
+                                       /* activate */ /* deactivate */
+        trigger.addAnimation(animation,    "%s",           "%s"       );
 
-        trigger.addAnimation(animation, "play-forwards", "play-backwards");
+        // Run post-setup JS.
+        %s
       }
-
       setupTriggeredAnimation();
-    )JS");
+    )JS",
+        activate.c_str(), deactivate.c_str(), post_setup_code.c_str());
 
     ExecuteScript(make_animation_js);
 
@@ -2833,6 +2841,20 @@
     EXPECT_NE(animation_, nullptr);
   }
 
+  class PromiseHandler final : public ThenCallable<Animation, PromiseHandler> {
+   public:
+    explicit PromiseHandler(base::OnceClosure callback)
+        : callback_(std::move(callback)) {}
+    void React(ScriptState* script_state, Animation* animation) {
+      if (callback_) {
+        std::move(callback_).Run();
+      }
+    }
+
+   private:
+    base::OnceClosure callback_;
+  };
+
  protected:
   frame_test_helpers::WebViewHelper helper_;
   // The element that is the target of |animation_|.
@@ -3011,6 +3033,51 @@
   EXPECT_EQ(animation_, nullptr);
 }
 
+TEST_F(ScriptedTimelineTriggerTest, ForbidScriptDuringActivation) {
+  // Define 'then' getter. This runs synchronously.
+  std::string remove_animation_code =
+      R"JS(Object.defineProperty(Animation.prototype, 'then', {
+        get() {
+          trigger.removeAnimation(animation);
+          return undefined;
+        }
+      });
+      )JS";
+
+  Initialize(/* activate= */ "reset", /* deactivate= */ "none",
+             /* post_sectup_code*/ remove_animation_code);
+
+  // Ensure we are pending_pause_.
+  animation_->play();
+  animation_->pause();
+  EXPECT_TRUE(animation_->pending_pause_);
+
+  // Establish context necessary to arm ready promise.
+  ScriptState* script_state =
+      ToScriptStateForMainWorld(GetDocument().GetFrame());
+  v8::HandleScope handle_scope(script_state->GetIsolate());
+  ScriptState::Scope script_scope(script_state);
+
+  // Arm the ready promise.
+  bool ready_promise_resolved = false;
+  auto ready_callback = [](bool* did_resolve) { *did_resolve = true; };
+  animation_->ready(script_state)
+      .Then(script_state,
+            MakeGarbageCollected<PromiseHandler>(base::BindOnce(
+                std::move(ready_callback), &ready_promise_resolved)));
+
+  // Perform activate. This should not resolved the ready promise and should not
+  // run script.
+  trigger_->PerformActivate();
+  EXPECT_EQ(trigger_->BehaviorMap().size(), 1);
+  EXPECT_FALSE(ready_promise_resolved);
+
+  // Ensure the ready promise does get resolved in due time.
+  ASSERT_TRUE(base::test::RunUntil([&]() { return ready_promise_resolved; }));
+
+  EXPECT_EQ(trigger_->BehaviorMap().size(), 0);
+}
+
 class AnimationTypeMetricsTest : public AnimationAnimationTestCompositing {
  public:
   AnimationTypeMetricsTest() = default;
Loading diff…

Original Bug Report

reported by [email protected]

UAF in AnimationTrigger::PerformActivate due to synchronous promise resolution

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: A potential Use-After-Free (UAF) exists when iterating over an AnimationTrigger’s backing map. Synchronous resolution of a paused animation’s promise allows JavaScript execution during iteration. Modifying the map from JS can shrink and free the backing store, invalidating the iterator and leading to a potential renderer RCE.

Affected files:

  • third_party/blink/renderer/core/animation/animation_trigger.cc
  • third_party/blink/renderer/core/animation/scroll_snapshot_timeline.cc
  • third_party/blink/renderer/core/animation/animation.cc

Estimated timestamp from git blame: 2026-01-26

Summary

A potential Use-After-Free (UAF) exists in AnimationTrigger::PerformActivate and AnimationTrigger::PerformDeactivate. These functions iterate over animation_behavior_map_ (a HeapHashMap) using a C++ range-based for loop. During iteration, PerformBehavior can trigger synchronous JavaScript execution via a promise resolution. If an attacker installs a getter on Animation.prototype.then, they can modify the map (e.g., by calling removeAnimation) during the iteration. This modification can trigger a map shrink and rehash, which immediately frees the backing store that the C++ iterator is currently traversing.

Technical Details

The vulnerability is reachable because Animation::ResolvePromiseMaybeAsync resolves promises synchronously when ScriptForbiddenScope::IsScriptForbidden() is false. The call path through LocalFrameView::ServiceScrollAnimations (via PageAnimator::ServiceScriptedAnimations during BeginMainFrame) occurs before the lifecycle update establishes such a scope.

Specifically:

  1. AnimationTrigger::PerformActivate iterates animation_behavior_map_.
  2. The loop calls PerformBehavior -> PerformReset -> Animation::ResetPlayback -> Animation::setCurrentTime.
  3. If pending_pause_ is true (set via a prior animation.pause() call), Animation::ResolvePromiseMaybeAsync is called.
  4. Since script is not forbidden, promise->Resolve(this) runs synchronously.
  5. V8’s promise resolution synchronously accesses the .then property of the resolved Animation object.
  6. An attacker-defined JS getter for Animation.prototype.then executes mid-loop.
  7. The getter repeatedly calls trigger.removeAnimation(), triggering animation_behavior_map_.erase().
  8. If enough elements are removed, the map calls Shrink() -> Rehash(), which immediately frees the old backing store via BlinkAllocator::FreeHashTableBacking and Oilpan’s FreeUnreferencedObject.
  9. The JS getter returns, and the C++ loop attempts to increment the iterator (HashTableConstIterator::operator++).
  10. In release builds (where CheckModifications() is compiled out), the iterator blindly reads from the freed memory.
  11. The loop reads a fake WeakMember<Animation> from the reallocated memory and calls HasPausedCSSPlayState(animation), which invokes the virtual method animation->IsCSSAnimation(), leading to a potential RCE.

Suggested Attacker Steps

(Note: These are potential steps as a working Proof-of-Concept has not yet been executed by the automated agent.)

  1. Create a ScrollTimeline and several Animation objects linked to it.
  2. Associate an AnimationTrigger with these animations, setting the activation behavior to reset.
  3. Call animation.pause() on the first animation to set pending_pause_ = true.
  4. Define a custom getter on Animation.prototype.then.
  5. Trigger a scroll event to activate the trigger.
  6. Inside the custom getter, call trigger.removeAnimation() on the remaining animations to force a rehash and free the backing store.
  7. Still inside the getter, perform heap spraying (e.g., allocating arrays) to reclaim the freed backing store and inject a fake WeakMember<Animation> pointer pointing to a fake object with a forged vtable.

Other Affected Loops

Similar vulnerable patterns exist in:

  • AnimationTrigger::PerformDeactivate
  • ScrollSnapshotTimeline::UpdateSnapshotInternal (where it directly iterates over trigger->BehaviorMap())

Suggested Fix

To fix this, avoid iterating directly over the HeapHashMap when synchronous script execution is possible. A common and safe pattern in Blink is to copy the elements (e.g., the Animation pointers) into a separate, temporary HeapVector before iterating and performing actions that might run script.

Alternatively, consider whether a ScriptForbiddenScope should be active during this phase of the rendering pipeline (e.g., within ServiceScrollAnimations or UpdateSnapshotInternal) to enforce asynchronous promise resolution, though this might have wider architectural implications.

Evaluated with Chrome root at commit: a3f5fcb392f2902650ca2b71820e7e418787e18b


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. Please feel free to reach out to me if you have concerns or feedback.

View on issue tracker