High patch-now ✔︎ verified CVSS 8.8 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Animation
DescriptionUse after free in Animation
ComponentAnimation
Sub-componentAnimation-trigger
Bug ClassUAF
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — upheld by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIConfirmed
Reviewer notes: Re-entry via 'then' getter calling removeAnimation during PerformActivate is confirmed by commit message and test. Exact freed object (Animation vs map storage) is not shown. Test shows ready-promise resolution is deferred, not aborted.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker496285281
Fix commit23356b88c87f (chromium/src) +98/-11
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-03-25 (per vendor advisory)
Fix landed2026-04-16 (public git)
Disclosed2026-04-28
Patch gap12 days — fix public in source before the advisory

Background

`AnimationTrigger`
a Blink object that attaches animations to a scroll/view timeline and activates or deactivates them as the timeline enters or leaves a range.
`PerformBehavior` / `PerformActivate`
AnimationTrigger methods that apply a Behavior (play, reset, etc.) to each tracked Animation during (de)activation, iterating over the trigger’s internal behavior map.
`ScriptForbiddenScope`
a Blink RAII guard that makes any attempt to execute JavaScript on the main thread during its lifetime a hard failure (CHECK), used to protect code that must not be re-entered by script.
Promise/`then` re-entrancy
resolving an Animation’s ready promise, or touching a scripted property like then, can synchronously run author JavaScript that mutates the trigger’s state mid-iteration.

Root Cause Analysis

During trigger activation, AnimationTrigger iterates its set of tracked animations and calls PerformBehavior, which computes play state and drives each Animation; this path could synchronously run author script (for example by resolving a ready promise or accessing a scripted then getter). That script could call back into the trigger — notably AnimationTrigger::removeAnimation (or addAnimation) — mutating the behavior map while the activation loop still held references into it, producing a use-after-free of the removed Animation/map entry. The invariant violated is that (de)activation must iterate its own collection without that collection being mutated by re-entrant script.

The fix adds ScriptForbiddenScope forbid_script; at the top of PerformBehavior, so any script execution attempted during behavior application aborts instead of re-entering, and adds CHECK(!is_activating_or_deactivating_) to addAnimation and removeAnimation to assert that mutation cannot occur while a (de)activation is in progress. The regression test ForbidScriptDuringActivation installs a malicious then getter that calls removeAnimation and confirms the behavior map is left intact (size 1) during PerformActivate.

Key insight
The core mistake was allowing author script to run synchronously inside trigger (de)activation, letting a scripted callback mutate the collection being iterated; the fix forbids script during behavior application (ScriptForbiddenScope) and asserts no re-entrant add/remove with CHECK(!is_activating_or_deactivating_).

Attack Path

  1. Set up a timeline-triggered animation Script creates an Animation driven by a ViewTimeline and registers it via trigger.addAnimation(...) with an activation behavior (e.g. reset).
  2. Plant a synchronous re-entry hook Author code overrides a property that the activation path reads during script-visible work (e.g. a then getter on Animation.prototype) whose getter calls trigger.removeAnimation(animation).
  3. Arm the trigger The page puts the animation into a pending state and arms the ready promise so that activation will reach the point that invokes the scripted hook.
  4. Trigger activation The timeline enters its activation range and PerformActivate/PerformBehavior runs; pre-patch, the scripted hook fires synchronously and removes the animation, freeing the entry while the activation loop still references it.
  5. Use-after-free The loop continues to use the freed Animation/behavior-map entry in the Blink renderer process; exploitation into a stronger primitive is inferred, as the patch only demonstrates the re-entrant free.

Impact Assessment

An attacker running JavaScript in a renderer can re-enter AnimationTrigger during (de)activation and free an object still in use, giving a use-after-free in the Blink renderer process with at least a renderer crash and potentially a corruption primitive (inferred). The vendor advisory rates this high severity (use after free in Animation) and does not report exploited-in-the-wild status. Preconditions are only that the page can create a triggered animation and install a synchronous script hook on the activation path.

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

Audit Directions

  • Script-reachable iteration
    Flag any Blink collection iterated during animation/timeline (de)activation that can trigger promise resolution or scripted getters; guard such regions with ScriptForbiddenScope.
  • Re-entrant mutators lacking state guards
    Audit addAnimation/removeAnimation-style mutators for a CHECK against an in-progress-operation flag like is_activating_or_deactivating_.
  • Promise resolution inside loops
    Search for ready/finished promise resolution performed while holding references into a container being traversed, a classic source of re-entrant UAF.
From 23356b88c87fec67905ebea007db6f4c86018fc1 Mon Sep 17 00:00:00 2001
From: David Awogbemila <awogbemila@chromium.org>
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 <awogbemila@chromium.org>
Reviewed-by: Vladimir Levin <vmpstr@chromium.org>
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 rj...@google.com

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