Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactType confusion in Compositing
DescriptionType confusion in Compositing
ComponentCompositing
Sub-componentInternals>Compositing
Bug ClassType Confusion
Action tierwatch — ub-class only — no demonstrated corruption path
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.
Tracker553449113
Fix commitdeb41ddc5770 (chromium/src) +67/-12
CWECWE-843 (Type confusion)
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-08-27 (per vendor advisory)
Fix landed2026-08-28 (public git)
Disclosed2026-09-03
Patch gap6 days — fix public in source before the advisory

Background

`DCHECK` vs `CHECK`
DCHECK (and DCHECK_EQ) are assertions compiled out of release builds, so their conditions are only enforced in debug builds, whereas CHECK/CHECK_EQ are always evaluated and abort the process on failure.
Downcast helpers (`ToScrollTimeline`, `ToWorkletAnimation`, `ToTimelineTrigger`, `ToFilterAnimationCurve`, `ToScrollOffsetAnimationCurve`)
inline helpers in cc/animation/ that take a base-class pointer (e.g. AnimationTimeline*, Animation*, AnimationTrigger*, gfx::AnimationCurve*) and static_cast it to a concrete derived type after a type-tag check.
Type tag methods (`IsScrollTimeline`, `IsWorkletAnimation`, `IsTimelineTrigger`, `Type()`)
virtual/runtime predicates that report the real dynamic type of an animation object so a downcast can be validated before it is trusted.
`AnimationTrigger` / `TimelineTrigger` / `ScrollTimeline`
cc compositor animation objects; a TimelineTrigger fires based on an associated timeline, and its timeline_ member is expected to be a ScrollTimeline when the trigger is updated.

Root Cause Analysis

Several cc/animation/ downcasts guarded the transition from a base pointer to a concrete derived type with DCHECK/DCHECK_EQ, which are stripped from release builds; the compiled shipping binary therefore performed the static_cast (and, in TimelineTrigger::Update, a reinterpret_cast<ScrollTimeline*>) with no runtime verification of the object’s real type. The most direct sink is TimelineTrigger::Update, where timeline_.Read(*this).get() was reinterpret_cast to ScrollTimeline* and only checked for non-null via DCHECK, never for actually being a ScrollTimeline; if the stored AnimationTimeline were some other subtype, subsequent calls such as IsActive(...) operate on a mis-typed object — classic type confusion. Similarly AnimationHost::UpdateTriggers asserted trigger->IsTimelineTrigger() with a DCHECK and then static_cast<TimelineTrigger*>, so a non-TimelineTrigger would be treated as one in release. The invariant violated is that a downcast must match the object’s dynamic type before the derived pointer is dereferenced.

The fix promotes every one of these assertions to CHECK/CHECK_EQ (and routes TimelineTrigger::Update through the type-checking ToScrollTimeline helper, plus introduces ToTimelineTrigger), so a type mismatch now aborts safely instead of silently proceeding with a confused pointer. This tightens the checks — turning debug-only assumptions into always-enforced guarantees rather than relaxing anything.

Key insight
The core mistake was relying on DCHECK-family assertions — absent from release builds — to validate downcasts, leaving the shipping compositor to static_cast/reinterpret_cast base pointers to derived types without any runtime type check. The fix promotes those assertions to always-on CHECKs so a mismatched type deterministically crashes instead of producing a type-confused object.

Attack Path

  1. Set up an animation object graph Through web-exposed animation/scroll-timeline APIs, an attacker arranges for a compositor animation structure (a trigger, timeline, animation, or curve) whose stored base pointer does not have the concrete type the downcast helper assumes. (inferred)
  2. Reach the unchecked downcast Compositor animation updates invoke a path such as AnimationHost::UpdateTriggers → TimelineTrigger::Update, where the base pointer is cast to TimelineTrigger*/ScrollTimeline* with only a release-stripped DCHECK guarding the type.
  3. Trigger type confusion In a release build the cast proceeds regardless of the real type, and subsequent member access (e.g. ScrollTimeline::IsActive) reads/writes through a pointer to an object of the wrong type, yielding a type-confused access. (inferred)
  4. Escalate the confused access A controlled type mismatch can turn into an out-of-bounds or otherwise corrupt memory access exploitable for further compromise in the process hosting compositing. (inferred)

Impact Assessment

The advisory classifies this as type confusion in the Compositing component; a successful mismatch lets an attacker operate on a compositor animation object as if it were a different type, which can corrupt memory in the process that runs cc animation code. Exploitation requires driving the animation/timeline machinery into a state where a downcast target’s dynamic type differs from the expected one. The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 8.8. A specific severity rating are not stated in the material provided.

Changed Functions

FunctionChangeNotes
ToScrollOffsetAnimationCurve
cc/animation/scroll_offset_animation_curve.cc
modified
TEST_F
cc/animation/scroll_timeline_unittest.cc
modified
TEST_F
cc/animation/worklet_animation_unittest.cc
modified

Files Changed

  • cc/animation/worklet_animation_unittest.cc
  • cc/animation/timeline_trigger.cc
  • cc/animation/timeline_trigger.h
  • cc/animation/scroll_timeline_unittest.cc
  • cc/animation/worklet_animation.h
  • cc/animation/scroll_offset_animation_curve.cc
  • cc/animation/filter_animation_curve.cc
  • cc/animation/scroll_timeline.h
  • cc/animation/animation_host.cc

Audit Directions

  • `DCHECK`-guarded downcasts
    Search cc/ and other renderer/compositor code for DCHECK/DCHECK_EQ immediately preceding a static_cast/reinterpret_cast to a derived type; these are release-build type confusions waiting to happen and should use always-on CHECK helpers.
  • `reinterpret_cast` between polymorphic types
    Flag any reinterpret_cast from a base animation/timeline/curve pointer to a derived type (it does no type or offset adjustment), especially when only a non-null assertion follows; prefer type-checked static_cast helpers.
  • Type-tag predicates not enforced at the cast site
    Audit call sites of Is*()/Type() predicates to confirm the predicate is actually checked (with CHECK) before the corresponding downcast is dereferenced, rather than assumed by an upstream invariant.
From deb41ddc5770bd74cc7d0e4d6a7d7b12e2c5ecec Mon Sep 17 00:00:00 2001
From: Robert Flack <flackr@chromium.org>
Date: Fri, 28 Aug 2026 13:20:18 -0700
Subject: [PATCH] Promote DCHECK to CHECK in cc animation downcasts

Promotes `DCHECK` downcast assertions to `CHECK` across `cc/animation/`
downcasting helpers (`ToScrollTimeline`, `ToWorkletAnimation`,
`ToScrollOffsetAnimationCurve`, and `ToFilterAnimationCurve`).
Introduces `ToTimelineTrigger` helper functions with `CHECK` verification
and uses them in `AnimationHost::UpdateTriggers`.

This ensures downcasts between base and derived animation, timeline, trigger,
and curve instances are always validated.

Bug: 553449113
Change-Id: I06849cde7d144966b89ec2899eebef5f5a66fee0
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8308916
Reviewed-by: David A <awogbemila@chromium.org>
Commit-Queue: Robert Flack <flackr@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1688263}
---
diff --git a/cc/animation/animation_host.cc b/cc/animation/animation_host.cc
index 0a5e0d6..8514063 100644
--- a/cc/animation/animation_host.cc
+++ b/cc/animation/animation_host.cc
@@ -1091,9 +1091,7 @@
   for (const auto& kv : id_to_trigger_map_.Read(*this)) {
     AnimationTrigger* trigger = kv.second.get();
     // NOTE(crbug.com/451238244): Only timeline triggers are supported for now.
-    DCHECK(trigger->IsTimelineTrigger());
-    static_cast<TimelineTrigger*>(trigger)->Update(scroll_tree, events,
-                                                   monotonic_time);
+    ToTimelineTrigger(trigger)->Update(scroll_tree, events, monotonic_time);
   }
 }
 
diff --git a/cc/animation/filter_animation_curve.cc b/cc/animation/filter_animation_curve.cc
index 8a01901f..003a1f43a 100644
--- a/cc/animation/filter_animation_curve.cc
+++ b/cc/animation/filter_animation_curve.cc
@@ -4,6 +4,7 @@
 
 #include "cc/animation/filter_animation_curve.h"
 
+#include "base/check_op.h"
 #include "base/memory/ptr_util.h"
 #include "ui/gfx/animation/keyframe/keyframed_animation_curve-inl.h"
 
@@ -30,13 +31,13 @@
 
 const FilterAnimationCurve* FilterAnimationCurve::ToFilterAnimationCurve(
     const gfx::AnimationCurve* c) {
-  DCHECK_EQ(gfx::AnimationCurve::FILTER, c->Type());
+  CHECK_EQ(gfx::AnimationCurve::FILTER, c->Type());
   return static_cast<const FilterAnimationCurve*>(c);
 }
 
 FilterAnimationCurve* FilterAnimationCurve::ToFilterAnimationCurve(
     gfx::AnimationCurve* c) {
-  DCHECK_EQ(AnimationCurve::FILTER, c->Type());
+  CHECK_EQ(AnimationCurve::FILTER, c->Type());
   return static_cast<FilterAnimationCurve*>(c);
 }
 
diff --git a/cc/animation/scroll_offset_animation_curve.cc b/cc/animation/scroll_offset_animation_curve.cc
index f2d5475..3e57ae3 100644
--- a/cc/animation/scroll_offset_animation_curve.cc
+++ b/cc/animation/scroll_offset_animation_curve.cc
@@ -412,13 +412,13 @@
 const ScrollOffsetAnimationCurve*
 ScrollOffsetAnimationCurve::ToScrollOffsetAnimationCurve(
     const AnimationCurve* c) {
-  DCHECK_EQ(ScrollOffsetAnimationCurve::SCROLL_OFFSET, c->Type());
+  CHECK_EQ(ScrollOffsetAnimationCurve::SCROLL_OFFSET, c->Type());
   return static_cast<const ScrollOffsetAnimationCurve*>(c);
 }
 
 ScrollOffsetAnimationCurve*
 ScrollOffsetAnimationCurve::ToScrollOffsetAnimationCurve(AnimationCurve* c) {
-  DCHECK_EQ(ScrollOffsetAnimationCurve::SCROLL_OFFSET, c->Type());
+  CHECK_EQ(ScrollOffsetAnimationCurve::SCROLL_OFFSET, c->Type());
   return static_cast<ScrollOffsetAnimationCurve*>(c);
 }
 
diff --git a/cc/animation/scroll_timeline.h b/cc/animation/scroll_timeline.h
index 8d350fc..d6218ad 100644
--- a/cc/animation/scroll_timeline.h
+++ b/cc/animation/scroll_timeline.h
@@ -8,6 +8,7 @@
 #include <optional>
 #include <vector>
 
+#include "base/check.h"
 #include "base/time/time.h"
 #include "cc/animation/animation_export.h"
 #include "cc/animation/animation_timeline.h"
@@ -199,13 +200,13 @@
 };
 
 inline ScrollTimeline* ToScrollTimeline(AnimationTimeline* timeline) {
-  DCHECK(timeline->IsScrollTimeline());
+  CHECK(timeline->IsScrollTimeline());
   return static_cast<ScrollTimeline*>(timeline);
 }
 
 inline const ScrollTimeline* ToScrollTimeline(
     const AnimationTimeline* timeline) {
-  DCHECK(timeline->IsScrollTimeline());
+  CHECK(timeline->IsScrollTimeline());
   return static_cast<const ScrollTimeline*>(timeline);
 }
 
diff --git a/cc/animation/timeline_trigger.cc b/cc/animation/timeline_trigger.cc
index c1c915ba..bc770f2 100644
--- a/cc/animation/timeline_trigger.cc
+++ b/cc/animation/timeline_trigger.cc
@@ -68,8 +68,7 @@
                              AnimationEvents* events,
                              base::TimeTicks monotonic_time) {
   ScrollTimeline* scroll_timeline =
-      reinterpret_cast<ScrollTimeline*>(timeline_.Read(*this).get());
-  DCHECK(scroll_timeline);
+      ToScrollTimeline(timeline_.Read(*this).get());
 
   // Triggers only function based on the active tree.
   if (!scroll_timeline->IsActive(scroll_tree, /*is_active_tree=*/true)) {
diff --git a/cc/animation/timeline_trigger.h b/cc/animation/timeline_trigger.h
index 13a0203..5674704 100644
--- a/cc/animation/timeline_trigger.h
+++ b/cc/animation/timeline_trigger.h
@@ -5,6 +5,7 @@
 #ifndef CC_ANIMATION_TIMELINE_TRIGGER_H_
 #define CC_ANIMATION_TIMELINE_TRIGGER_H_
 
+#include "base/check.h"
 #include "cc/animation/animation_trigger.h"
 
 namespace cc {
@@ -71,6 +72,17 @@
   State state_ = State::kIdle;
 };
 
+inline TimelineTrigger* ToTimelineTrigger(AnimationTrigger* trigger) {
+  CHECK(trigger->IsTimelineTrigger());
+  return static_cast<TimelineTrigger*>(trigger);
+}
+
+inline const TimelineTrigger* ToTimelineTrigger(
+    const AnimationTrigger* trigger) {
+  CHECK(trigger->IsTimelineTrigger());
+  return static_cast<const TimelineTrigger*>(trigger);
+}
+
 }  // namespace cc
 
 #endif  // CC_ANIMATION_TIMELINE_TRIGGER_H_
diff --git a/cc/animation/worklet_animation.h b/cc/animation/worklet_animation.h
index e4323e7..1b4c5e4 100644
--- a/cc/animation/worklet_animation.h
+++ b/cc/animation/worklet_animation.h
@@ -9,6 +9,7 @@
 #include <optional>
 #include <string>
 
+#include "base/check.h"
 #include "base/time/time.h"
 #include "cc/animation/animation.h"
 #include "cc/animation/animation_export.h"
@@ -154,10 +155,15 @@
 };
 
 inline WorkletAnimation* ToWorkletAnimation(Animation* animation) {
-  DCHECK(animation->IsWorkletAnimation());
+  CHECK(animation->IsWorkletAnimation());
   return static_cast<WorkletAnimation*>(animation);
 }
 
+inline const WorkletAnimation* ToWorkletAnimation(const Animation* animation) {
+  CHECK(animation->IsWorkletAnimation());
+  return static_cast<const WorkletAnimation*>(animation);
+}
+
 }  // namespace cc
 
 #endif  // CC_ANIMATION_WORKLET_ANIMATION_H_
diff --git a/cc/animation/scroll_timeline_unittest.cc b/cc/animation/scroll_timeline_unittest.cc
index 9824011f..d06aad59 100644
--- a/cc/animation/scroll_timeline_unittest.cc
+++ b/cc/animation/scroll_timeline_unittest.cc
@@ -437,4 +437,22 @@
       active_timeline->IsActive(scroll_tree(), true /*is_active_tree*/));
 }
 
+TEST_F(ScrollTimelineTest, ToScrollTimeline) {
+  ScrollTimeline::ScrollOffsets scroll_offsets(0, 100);
+  scoped_refptr<ScrollTimeline> scroll_timeline = ScrollTimeline::Create(
+      scroller_id(), ScrollTimeline::ScrollDown, scroll_offsets);
+  scoped_refptr<AnimationTimeline> plain_timeline =
+      AnimationTimeline::Create(1);
+
+  EXPECT_EQ(ToScrollTimeline(scroll_timeline.get()), scroll_timeline.get());
+  const AnimationTimeline* const_scroll_timeline = scroll_timeline.get();
+  EXPECT_EQ(ToScrollTimeline(const_scroll_timeline), scroll_timeline.get());
+
+#if GTEST_HAS_DEATH_TEST
+  EXPECT_DEATH_IF_SUPPORTED(ToScrollTimeline(plain_timeline.get()), "");
+  const AnimationTimeline* const_plain_timeline = plain_timeline.get();
+  EXPECT_DEATH_IF_SUPPORTED(ToScrollTimeline(const_plain_timeline), "");
+#endif
+}
+
 }  // namespace cc
diff --git a/cc/animation/worklet_animation_unittest.cc b/cc/animation/worklet_animation_unittest.cc
index 61ec54e5..bdd8c16 100644
--- a/cc/animation/worklet_animation_unittest.cc
+++ b/cc/animation/worklet_animation_unittest.cc
@@ -548,6 +548,26 @@
   EXPECT_EQ(input->updated_animations.size(), 1u);
 }
 
+TEST_F(WorkletAnimationTest, ToWorkletAnimation) {
+  scoped_refptr<WorkletAnimation> worklet_animation =
+      WorkletAnimation::Create(worklet_animation_id_, "test_name", 1.0,
+                               /* options */ nullptr,
+                               /* effect_timings */ nullptr);
+  scoped_refptr<Animation> plain_animation = Animation::Create(1);
+
+  EXPECT_EQ(ToWorkletAnimation(worklet_animation.get()),
+            worklet_animation.get());
+  const Animation* const_worklet_animation = worklet_animation.get();
+  EXPECT_EQ(ToWorkletAnimation(const_worklet_animation),
+            worklet_animation.get());
+
+#if GTEST_HAS_DEATH_TEST
+  EXPECT_DEATH_IF_SUPPORTED(ToWorkletAnimation(plain_animation.get()), "");
+  const Animation* const_plain_animation = plain_animation.get();
+  EXPECT_DEATH_IF_SUPPORTED(ToWorkletAnimation(const_plain_animation), "");
+#endif
+}
+
 }  // namespace
 
 }  // namespace cc
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/cc/animation/scroll_timeline_unittest.cc b/cc/animation/scroll_timeline_unittest.cc
index 9824011f..d06aad59 100644
--- a/cc/animation/scroll_timeline_unittest.cc
+++ b/cc/animation/scroll_timeline_unittest.cc
@@ -437,4 +437,22 @@
       active_timeline->IsActive(scroll_tree(), true /*is_active_tree*/));
 }
 
+TEST_F(ScrollTimelineTest, ToScrollTimeline) {
+  ScrollTimeline::ScrollOffsets scroll_offsets(0, 100);
+  scoped_refptr<ScrollTimeline> scroll_timeline = ScrollTimeline::Create(
+      scroller_id(), ScrollTimeline::ScrollDown, scroll_offsets);
+  scoped_refptr<AnimationTimeline> plain_timeline =
+      AnimationTimeline::Create(1);
+
+  EXPECT_EQ(ToScrollTimeline(scroll_timeline.get()), scroll_timeline.get());
+  const AnimationTimeline* const_scroll_timeline = scroll_timeline.get();
+  EXPECT_EQ(ToScrollTimeline(const_scroll_timeline), scroll_timeline.get());
+
+#if GTEST_HAS_DEATH_TEST
+  EXPECT_DEATH_IF_SUPPORTED(ToScrollTimeline(plain_timeline.get()), "");
+  const AnimationTimeline* const_plain_timeline = plain_timeline.get();
+  EXPECT_DEATH_IF_SUPPORTED(ToScrollTimeline(const_plain_timeline), "");
+#endif
+}
+
 }  // namespace cc
diff --git a/cc/animation/worklet_animation_unittest.cc b/cc/animation/worklet_animation_unittest.cc
index 61ec54e5..bdd8c16 100644
--- a/cc/animation/worklet_animation_unittest.cc
+++ b/cc/animation/worklet_animation_unittest.cc
@@ -548,6 +548,26 @@
   EXPECT_EQ(input->updated_animations.size(), 1u);
 }
 
+TEST_F(WorkletAnimationTest, ToWorkletAnimation) {
+  scoped_refptr<WorkletAnimation> worklet_animation =
+      WorkletAnimation::Create(worklet_animation_id_, "test_name", 1.0,
+                               /* options */ nullptr,
+                               /* effect_timings */ nullptr);
+  scoped_refptr<Animation> plain_animation = Animation::Create(1);
+
+  EXPECT_EQ(ToWorkletAnimation(worklet_animation.get()),
+            worklet_animation.get());
+  const Animation* const_worklet_animation = worklet_animation.get();
+  EXPECT_EQ(ToWorkletAnimation(const_worklet_animation),
+            worklet_animation.get());
+
+#if GTEST_HAS_DEATH_TEST
+  EXPECT_DEATH_IF_SUPPORTED(ToWorkletAnimation(plain_animation.get()), "");
+  const Animation* const_plain_animation = plain_animation.get();
+  EXPECT_DEATH_IF_SUPPORTED(ToWorkletAnimation(const_plain_animation), "");
+#endif
+}
+
 }  // namespace
 
 }  // namespace cc
Loading diff…

Original Bug Report

reported by vm...@google.com

OOB heap write in ScrollTimeline::PushPropertiesTo via timeline ID wraparound

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A 32-bit integer wraparound in AnimationIdProvider::NextTimelineId() can lead to ID collisions between a main-thread cc::ScrollTimeline and a stale impl-thread cc::AnimationTimeline. In release builds, ScrollTimeline::PushPropertiesTo performs an unchecked downcast of the smaller base class instance, writing derived member fields past the bounds of the allocation. This leads to a potential out-of-bounds heap write into adjacent PartitionAlloc slots on the renderer compositor thread.

Affected files:

  • cc/animation/scroll_timeline.cc
  • cc/animation/scroll_timeline.h
  • cc/animation/animation_host.cc
  • cc/animation/animation_id_provider.cc

Estimated timestamp from git blame: 2020-01-21

Summary

A 32-bit integer wraparound in cc::AnimationIdProvider can result in type confusion between cc::ScrollTimeline and cc::AnimationTimeline during main-to-impl property synchronization, leading to an out-of-bounds (OOB) heap write in the renderer process.

Root Cause Analysis

In cc/animation/animation_id_provider.cc:12, 26-28, timeline IDs are generated from a process-global 32-bit atomic counter without wraparound protection:

base::AtomicSequenceNumber g_next_timeline_id;
int AnimationIdProvider::NextTimelineId() {
  return g_next_timeline_id.GetNext() + 1;
}

Because base::AtomicSequenceNumber wraps on 32-bit integer overflow (base/atomic_sequence_num.h:23-26), repeated increments can cause timeline IDs to repeat.

In cc/animation/animation_host.cc, synchronization between the main thread and compositor (impl) thread relies entirely on matching integer IDs without validating timeline types:

  1. PushTimelinesToImplThread (cc/animation/animation_host.cc:458-468) skips instantiating a new impl instance if any timeline with the matching ID already exists on host_impl:
const AnimationTimeline* timeline_impl = host_impl->GetTimelineById(timeline->id());
if (timeline_impl) continue;
  1. RemoveTimelinesFromImplThread (cc/animation/animation_host.cc:483-501) preserves existing impl instances as long as the ID exists in the main-thread map:
if (timeline_impl->is_impl_only() || GetTimelineById(timeline_impl->id())) {
  ++it;
}
  1. PushPropertiesToImplThread (cc/animation/animation_host.cc:516-528) retrieves timeline_impl solely by ID and uses virtual dispatch on the main thread object:
if (AnimationTimeline* timeline_impl = host_impl->GetTimelineById(timeline->id())) {
  timeline->PushPropertiesTo(timeline_impl);
}

When a main-thread cc::ScrollTimeline collides with a stale plain cc::AnimationTimeline on the impl thread, ScrollTimeline::PushPropertiesTo is called (cc/animation/scroll_timeline.cc:152-161):

void ScrollTimeline::PushPropertiesTo(AnimationTimeline* impl_timeline) {
  AnimationTimeline::PushPropertiesTo(impl_timeline);
  DCHECK(impl_timeline);
  ScrollTimeline* scroll_timeline = ToScrollTimeline(impl_timeline);
  scroll_timeline->pending_id_.Write(*this) = pending_id_.Read(*this);
  scroll_timeline->pending_direction_.Write(*this) = pending_direction_.Read(*this);
  scroll_timeline->pending_offsets_.Write(*this) = pending_offsets_.Read(*this);
  scroll_timeline->last_tick_time_.Write(*scroll_timeline) = std::nullopt;
}

In release builds, ToScrollTimeline (cc/animation/scroll_timeline.h:201-204) executes only a DCHECK(timeline->IsScrollTimeline()), which is stripped. The subsequent static_cast treats the ~80-byte cc::AnimationTimeline allocation as a ~192-byte cc::ScrollTimeline. The four member assignments (pending_id_, pending_direction_, pending_offsets_, and last_tick_time_) write to offsets ~96–192, causing an out-of-bounds write past the base object’s PartitionAlloc slot into adjacent heap memory.

Potential Attack / Reproduction Scenario

Note: These are suggested/potential steps derived from source code analysis; our automated tooling does not currently have the capability to execute live code.

  1. Plant Plain Impl Timelines: The attacker page loads same-origin iframes running composited animations (e.g. opacity animation on a will-change: transform element). This causes Blink to construct DocumentTimeline instances (third_party/blink/renderer/core/animation/document_timeline.cc:221-228) that register plain cc::AnimationTimeline instances (IDs $N \dots N+k$) onto the shared cc::AnimationHost. A commit pushes these into host_impl as plain cc::AnimationTimeline allocations (~80–96 bytes).
  2. Pump Sequence Counter: The attacker runs a loop calling new DocumentTimeline() in JavaScript. Each call invokes EnsureCompositorTimeline() and consumes one ID from AnimationIdProvider::NextTimelineId(). Because unreferenced DocumentTimeline objects are only weakly referenced in DocumentAnimations::timelines_ (third_party/blink/renderer/core/animation/document_animations.h:168), they are reclaimed by Oilpan GC with minimal memory overhead until the 32-bit counter wraps around back to range $[N, N+k]$.
  3. Induce Collision: In a single JavaScript task, the attacker removes the planted iframes (removing IDs $N \dots N+k$ from the main-thread id_to_timeline_map_ via Document::Shutdown) and creates CSS scroll-driven animations (animation-timeline: scroll()). Each scroll timeline creates a cc::ScrollTimeline assigned a colliding ID in $[N, N+k]$.
  4. Trigger OOB Write: On the next commit, AnimationHost::PushPropertiesTo runs on the compositor thread. PushTimelinesToImplThread skips recreating the impl timeline due to the ID match, RemoveTimelinesFromImplThread retains the stale plain timeline, and PushPropertiesToImplThread invokes ScrollTimeline::PushPropertiesTo on the stale plain AnimationTimeline*. This writes derived member fields out of bounds into adjacent PartitionAlloc slots.

Suggested Fix

  1. Use 64-bit IDs: Change g_next_timeline_id and timeline IDs from 32-bit signed integers to 64-bit types (e.g., uint64_t or base::IdType64<...>) to make ID wraparound practically impossible.
  2. Validate Subtype on Sync: In AnimationHost::PushTimelinesToImplThread and AnimationHost::RemoveTimelinesFromImplThread, check whether timeline->IsScrollTimeline() == timeline_impl->IsScrollTimeline(). If a type mismatch occurs for the same ID, recreate the impl instance.
  3. Harden Downcast: Replace DCHECK(timeline->IsScrollTimeline()) in ToScrollTimeline (cc/animation/scroll_timeline.h:202, 208) with a production CHECK or use dynamic type querying.

Evaluated with Chrome root at commit: c6718ebbf2c677c6f63691e28c7801c563f6b19c


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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.

View on issue tracker