Medium patch-now ✔︎ verified CVSS 8.8 chrome Type Confusion 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactType confusion in Animation
DescriptionType confusion in Animation
ComponentAnimation
Bug ClassType Confusion
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: Mechanism matches diff. Commit's examples (clip rect vs auto lists, calc-size) hit the generic Scale/ScaleAndAdd path, not mainly the transform downcast. Omits the operand-order change. Diff does not show iterationComposite enabled by default.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker495021566
Fix commitbd9d522fbd8c (chromium/src) +108/-29
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
Creditedochko
Reported2026-03-22 · bounty $8,000 (per vendor advisory)
Fix landed2026-07-06 (public git)
Disclosed2026-08-25
Patch gap50 days — fix public in source before the advisory

Background

`InvalidatableInterpolation::ApplyIterationAccumulation`
the Blink routine that implements Web Animations Level 2 iteration composition (iterationComposite: 'accumulate'), folding the final keyframe value onto the current iteration’s value current_iteration_ times.
`InterpolationType` vs. `InterpolableValue`
InterpolationType is the coarse property-level kind of an interpolation, while the concrete InterpolableValue subclass (InterpolableList, InterpolableTransformList, InterpolableLength, etc.) is the actual runtime object whose structure must match for arithmetic to be valid.
`InterpolationType::MaybeMergeSingles`
a helper that deeply verifies two endpoint values are pairwise-composable and, if so, returns a PairwiseInterpolationValue with coerced/aligned interpolable values; it returns a falsey value when the two cannot be merged.
`To<T>` cast
Blink’s checked downcast; applying it (or Scale/ScaleAndAdd) to an InterpolableValue whose real subclass differs from T is a type-confusion sink.

Root Cause Analysis

Before the patch, ApplyIterationAccumulation relied on the fact that the start and end keyframes shared the same InterpolationType and then performed accumulation arithmetic directly on the raw result_value/end_value InterpolableValue objects, with only a few narrow ad-hoc guards (a filter-list type comparison and an InterpolableLength::CanMergeValues length check). This missed the general case where two values of the same InterpolationType are nonetheless structurally incompatible — e.g. a clip rect(...) list versus an auto list, or incompatible calc-size bases — so the subsequent To<InterpolableTransformList>(*end_value) downcast and the Scale/ScaleAndAdd accumulation operated on mismatched concrete structures, i.e. a type confusion.

The fix inserts a single upfront type->MaybeMergeSingles(start, end) call that deeply validates and coerces both endpoints; if the merge fails it returns early and simply uses the current iteration value (per Web Animations Level 2), and if it succeeds the accumulation now runs against the coerced merged.start_interpolable_value/merged.end_interpolable_value instead of the raw values. This works because every arithmetic/downcast path downstream now sees values that MaybeMergeSingles has already proven mutually compatible, replacing the incomplete per-type special-casing with one authoritative compatibility gate.

Key insight
The code trusted a shared InterpolationType as proof that two values could be safely composed, when only the concrete InterpolableValue structures determine that; routing both endpoints through MaybeMergeSingles before any cast or arithmetic closes the gap.

Attack Path

  1. Create a mismatched keyframe pair Author a CSS/Web Animation on a property (e.g. clip, a transform list, or a registered <length-percentage> custom property) whose two keyframes share an InterpolationType but resolve to structurally different concrete InterpolableValue objects (e.g. rect(10px,...) vs rect(auto,...)).
  2. Enable iteration accumulation Set iterationComposite: 'accumulate' with iterations > 1 so that ApplyIterationAccumulation runs on a later iteration.
  3. Advance past the first iteration Drive currentTime into a subsequent iteration so accumulation arithmetic is performed on the incompatible endpoints.
  4. Trigger the type confusion The pre-patch code downcasts/scales one concrete structure as if it were the other (To<InterpolableTransformList>/Scale/ScaleAndAdd over mismatched lists), producing memory corruption or an invalid object state (inferred as to any concrete read/write primitive; the patch shows only the confusion site).

Impact Assessment

An attacker who can run script (or supply CSS) in a renderer can drive animation iteration accumulation over incompatible values to cause a type confusion in Blink’s animation engine, running in the renderer process. The advisory classifies this as a type confusion in Animation; any escalation to a controlled read/write or code execution is (inferred) and not demonstrated by the diff. No exploited-in-the-wild status is stated in the provided material.

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/animation/invalidatable_interpolation.cc
modified

Files Changed

  • third_party/blink/web_tests/external/wpt/web-animations/animation-model/keyframe-effects/effect-value-iteration-composite-operation.html
  • third_party/blink/renderer/core/animation/invalidatable_interpolation.cc

Audit Directions

  • Type identity vs. structural compatibility
    Audit interpolation/composition code that gates arithmetic on GetType()/InterpolationType equality alone; verify the concrete InterpolableValue subclasses and list shapes are also confirmed (ideally via MaybeMergeSingles/CanMergeValues) before any To<> cast, Scale, or ScaleAndAdd.
  • Ad-hoc per-type guards
    Look for accumulation/interpolation paths that special-case only a subset of types (e.g. filters, lengths) for compatibility; the un-enumerated types (transforms, clip rects, calc-size) are the likely gaps.
  • Downcasts on animation values
    Trace every To<InterpolableList>/To<InterpolableTransformList> in the animation module and confirm the source value’s structure is validated on the same code path before the cast executes.
From bd9d522fbd8ce911a9e723af3130dc0a56d5d455 Mon Sep 17 00:00:00 2001
From: Robert Flack <flackr@chromium.org>
Date: Mon, 06 Jul 2026 08:09:45 -0700
Subject: [PATCH] Fix type confusion in iteration accumulation and align order

InvalidatableInterpolation::ApplyIterationAccumulation previously
checked only that the start and end keyframes shared the same
InterpolationType. However, even within the same InterpolationType, the
underlying concrete InterpolableValue subclasses or structures can be
incompatible (e.g., clip rect lists vs. empty auto lists, or
incompatible calc-size bases).

This patch makes the following fixes and improvements:
1. Uses MaybeMergeSingles inside ApplyIterationAccumulation to deeply
   verify and coerce value compatibility before accumulating.
   Per Web Animations Level 2, if values cannot be merged or
   accumulated, accumulation is skipped and the current iteration
   value is used.
2. Computes V_final * N + V_current, aligning the left-to-right AST
   operand order for complex math expressions with other
   browser implementations.

Bug: 495021566
Change-Id: I272fa8ac7a7b6b627d18ad7d43553ccb306436eb
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8040362
Reviewed-by: Kevin Ellis <kevers@chromium.org>
Commit-Queue: Robert Flack <flackr@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1657198}
---
diff --git a/third_party/blink/renderer/core/animation/invalidatable_interpolation.cc b/third_party/blink/renderer/core/animation/invalidatable_interpolation.cc
index 31e73f2..1b8b4d6e 100644
--- a/third_party/blink/renderer/core/animation/invalidatable_interpolation.cc
+++ b/third_party/blink/renderer/core/animation/invalidatable_interpolation.cc
@@ -272,6 +272,18 @@
   const InterpolableValue* end_value =
       cached_end_value_->Value().interpolable_value.Get();
 
+  // Iteration accumulation skips incompatible values.
+  const InterpolationType* type = cached_value_->GetType();
+  InterpolationValue start(result_value->Clone(),
+                           cached_value_->GetNonInterpolableValue());
+  InterpolationValue end(end_value->Clone(),
+                         cached_end_value_->GetNonInterpolableValue());
+  PairwiseInterpolationValue merged =
+      type->MaybeMergeSingles(std::move(start), std::move(end));
+  if (!merged) {
+    return;
+  }
+
   // Transform accumulation is not linear so transform lists cannot simply use
   // Scale() and ScaleAndAdd(). Instead, we accumulate the final keyframe
   // value (from cached_end_value_) onto both interval endpoints using
@@ -288,45 +300,23 @@
       auto* accumulated_end =
           To<InterpolableTransformList>(interval_end->Clone());
       const auto& accumulation_delta =
-          To<InterpolableTransformList>(*end_value);
+          To<InterpolableTransformList>(*merged.end_interpolable_value);
 
       accumulated_start->AccumulateN(accumulation_delta, current_iteration_);
       accumulated_end->AccumulateN(accumulation_delta, current_iteration_);
       accumulated_start->Interpolate(*accumulated_end, current_fraction_,
-                                     *result_value);
+                                     *merged.start_interpolable_value);
+      cached_value_->MutableValue().interpolable_value =
+          merged.start_interpolable_value;
     }
     return;
   }
 
-  // For filter lists, skip accumulation if their types don't match. Same logic
-  // as CSSFilterListInterpolationType::PerformAccumulativeComposition.
-  if (result_value->IsList() && end_value->IsList()) {
-    const auto& result_list = To<InterpolableList>(*result_value);
-    const auto& end_list = To<InterpolableList>(*end_value);
-    for (wtf_size_t i = 0; i < result_list.length() && i < end_list.length();
-         i++) {
-      const auto* result_filter =
-          DynamicTo<InterpolableFilter>(result_list.Get(i));
-      const auto* end_filter = DynamicTo<InterpolableFilter>(end_list.Get(i));
-      if (result_filter && end_filter &&
-          result_filter->GetType() != end_filter->GetType()) {
-        return;
-      }
-    }
-  }
-
-  // Iteration accumulation skips incompatible (IACVT) length values.
-  if (result_value->IsLength() && end_value->IsLength()) {
-    if (!InterpolableLength::CanMergeValues(result_value, end_value)) {
-      return;
-    }
-  }
-
   // Iteration accumulation (Web Animations Level 2). Accumulate the final
   // keyframe value with the current value, |current_iteration| times.
-  Member<InterpolableValue> scaled_end = end_value->Clone();
-  scaled_end->Scale(current_iteration_);
-  result_value->ScaleAndAdd(1.0, *scaled_end);
+  Member<InterpolableValue> scaled_end = merged.end_interpolable_value->Clone();
+  scaled_end->ScaleAndAdd(current_iteration_, *merged.start_interpolable_value);
+  cached_value_->MutableValue().interpolable_value = scaled_end;
 }
 
 void InvalidatableInterpolation::ApplyStack(
diff --git a/third_party/blink/web_tests/external/wpt/web-animations/animation-model/keyframe-effects/effect-value-iteration-composite-operation.html b/third_party/blink/web_tests/external/wpt/web-animations/animation-model/keyframe-effects/effect-value-iteration-composite-operation.html
index 6408e0180..95b7200a 100644
--- a/third_party/blink/web_tests/external/wpt/web-animations/animation-model/keyframe-effects/effect-value-iteration-composite-operation.html
+++ b/third_party/blink/web_tests/external/wpt/web-animations/animation-model/keyframe-effects/effect-value-iteration-composite-operation.html
@@ -918,4 +918,93 @@
   'Animated line-height style at 50s of the third iteration');
 }, 'iteration composition of animation non-additive units');
 
+test(t => {
+  const div = createDiv(t);
+  div.style.position = 'absolute';
+  const anim =
+    div.animate({ clip: ['rect(10px, 50px, 50px, 10px)',
+                         'rect(auto, 50px, 50px, 10px)'] },
+                { duration: 100 * MS_PER_SEC,
+                  easing: 'linear',
+                  iterations: 10,
+                  iterationComposite: 'accumulate' });
+  anim.pause();
+
+  anim.currentTime = anim.effect.getComputedTiming().duration / 2;
+  // At 50%, it should flip to the end value because they are not pairwise interpolable.
+  assert_equals(getComputedStyle(div).clip, 'rect(auto, 50px, 50px, 10px)',
+    'Animated clip style at 50s of the first iteration');
+
+  anim.currentTime = anim.effect.getComputedTiming().duration * 2;
+  // At 3rd iteration 0%, it should NOT accumulate because they are incompatible.
+  // So it should be the same as 1st iteration 0% (start value).
+  assert_equals(getComputedStyle(div).clip, 'rect(10px, 50px, 50px, 10px)',
+    'Animated clip style at 0s of the third iteration');
+
+  anim.currentTime += anim.effect.getComputedTiming().duration / 2;
+  // At 3rd iteration 50%, the first component (auto) is compatible with the end value (auto),
+  // so accumulation is allowed for all components.
+  assert_equals(getComputedStyle(div).clip, 'rect(auto, 150px, 150px, 30px)',
+    'Animated clip style at 50s of the third iteration (accumulated)');
+}, 'iteration composition of incompatible clip rects (accumulation skipped at 0%, allowed at 50%)');
+
+test(t => {
+  const div = createDiv(t);
+  const anim =
+    div.animate({ filter: ['sepia(1) contrast(2)',
+                           'sepia(0)'] },
+                { duration: 100 * MS_PER_SEC,
+                  easing: 'linear',
+                  iterations: 10,
+                  iterationComposite: 'accumulate' });
+  anim.pause();
+
+  anim.currentTime = anim.effect.getComputedTiming().duration / 2;
+  // Interpolates between sepia(1) contrast(2) and sepia(0) contrast(1) (extended)
+  // At 50% it should be sepia(0.5) contrast(1.5)
+  assert_equals(getComputedStyle(div).filter, 'sepia(0.5) contrast(1.5)',
+    'Animated filter list at 50s of the first iteration');
+
+  anim.currentTime = anim.effect.getComputedTiming().duration * 2;
+  // At 3rd iteration 0%: start + 2 * end
+  // sepia(1) contrast(2) + 2 * sepia(0) = sepia(1) contrast(2)
+  assert_equals(getComputedStyle(div).filter, 'sepia(1) contrast(2)',
+    'Animated filter list at 0s of the third iteration');
+
+  anim.currentTime += anim.effect.getComputedTiming().duration / 2;
+  // At 3rd iteration 50%: interp(50%) + 2 * end
+  // sepia(0.5) contrast(1.5) + 2 * sepia(0) = sepia(0.5) contrast(1.5)
+  assert_equals(getComputedStyle(div).filter, 'sepia(0.5) contrast(1.5)',
+    'Animated filter list at 50s of the third iteration');
+}, 'iteration composition of long-to-short filter list');
+
+test(t => {
+  CSS.registerProperty({
+    name: '--length-percentage',
+    syntax: '<length-percentage>',
+    initialValue: '0px',
+    inherits: false,
+  });
+
+  const div = createDiv(t);
+  const anim =
+    div.animate({ '--length-percentage': ['min(10px, 5%)', 'max(20px, 15%)'] },
+                { duration: 100 * MS_PER_SEC,
+                  easing: 'linear',
+                  iterations: 10,
+                  iterationComposite: 'accumulate' });
+  anim.pause();
+
+  anim.currentTime = anim.effect.getComputedTiming().duration * 2;
+  // At 3rd iteration 0%: start + 2 * end
+  // The left-to-right AST order of operands in calc() serialization is observable
+  // for complex terms that cannot be simplified or reordered by unit type.
+  // We verify that the accumulated end term (2 * max(20px, 15%)) appears before
+  // the current iteration start term (min(10px, 5%)) rather than requiring an
+  // exact string match, as different browsers may include identity terms (e.g. 0 * ...).
+  assert_regexp_match(getComputedStyle(div).getPropertyValue('--length-percentage'),
+    /2 \* max\(20px, 15%\).*min\(10px, 5%\)/,
+    'Expected accumulated term (2 * max(...)) before current term (min(...))');
+}, 'iteration composition of complex math functions preserves AST left-to-right operand order');
+
 </script>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/web-animations/animation-model/keyframe-effects/effect-value-iteration-composite-operation.html b/third_party/blink/web_tests/external/wpt/web-animations/animation-model/keyframe-effects/effect-value-iteration-composite-operation.html
index 6408e0180..95b7200a 100644
--- a/third_party/blink/web_tests/external/wpt/web-animations/animation-model/keyframe-effects/effect-value-iteration-composite-operation.html
+++ b/third_party/blink/web_tests/external/wpt/web-animations/animation-model/keyframe-effects/effect-value-iteration-composite-operation.html
@@ -918,4 +918,93 @@
   'Animated line-height style at 50s of the third iteration');
 }, 'iteration composition of animation non-additive units');
 
+test(t => {
+  const div = createDiv(t);
+  div.style.position = 'absolute';
+  const anim =
+    div.animate({ clip: ['rect(10px, 50px, 50px, 10px)',
+                         'rect(auto, 50px, 50px, 10px)'] },
+                { duration: 100 * MS_PER_SEC,
+                  easing: 'linear',
+                  iterations: 10,
+                  iterationComposite: 'accumulate' });
+  anim.pause();
+
+  anim.currentTime = anim.effect.getComputedTiming().duration / 2;
+  // At 50%, it should flip to the end value because they are not pairwise interpolable.
+  assert_equals(getComputedStyle(div).clip, 'rect(auto, 50px, 50px, 10px)',
+    'Animated clip style at 50s of the first iteration');
+
+  anim.currentTime = anim.effect.getComputedTiming().duration * 2;
+  // At 3rd iteration 0%, it should NOT accumulate because they are incompatible.
+  // So it should be the same as 1st iteration 0% (start value).
+  assert_equals(getComputedStyle(div).clip, 'rect(10px, 50px, 50px, 10px)',
+    'Animated clip style at 0s of the third iteration');
+
+  anim.currentTime += anim.effect.getComputedTiming().duration / 2;
+  // At 3rd iteration 50%, the first component (auto) is compatible with the end value (auto),
+  // so accumulation is allowed for all components.
+  assert_equals(getComputedStyle(div).clip, 'rect(auto, 150px, 150px, 30px)',
+    'Animated clip style at 50s of the third iteration (accumulated)');
+}, 'iteration composition of incompatible clip rects (accumulation skipped at 0%, allowed at 50%)');
+
+test(t => {
+  const div = createDiv(t);
+  const anim =
+    div.animate({ filter: ['sepia(1) contrast(2)',
+                           'sepia(0)'] },
+                { duration: 100 * MS_PER_SEC,
+                  easing: 'linear',
+                  iterations: 10,
+                  iterationComposite: 'accumulate' });
+  anim.pause();
+
+  anim.currentTime = anim.effect.getComputedTiming().duration / 2;
+  // Interpolates between sepia(1) contrast(2) and sepia(0) contrast(1) (extended)
+  // At 50% it should be sepia(0.5) contrast(1.5)
+  assert_equals(getComputedStyle(div).filter, 'sepia(0.5) contrast(1.5)',
+    'Animated filter list at 50s of the first iteration');
+
+  anim.currentTime = anim.effect.getComputedTiming().duration * 2;
+  // At 3rd iteration 0%: start + 2 * end
+  // sepia(1) contrast(2) + 2 * sepia(0) = sepia(1) contrast(2)
+  assert_equals(getComputedStyle(div).filter, 'sepia(1) contrast(2)',
+    'Animated filter list at 0s of the third iteration');
+
+  anim.currentTime += anim.effect.getComputedTiming().duration / 2;
+  // At 3rd iteration 50%: interp(50%) + 2 * end
+  // sepia(0.5) contrast(1.5) + 2 * sepia(0) = sepia(0.5) contrast(1.5)
+  assert_equals(getComputedStyle(div).filter, 'sepia(0.5) contrast(1.5)',
+    'Animated filter list at 50s of the third iteration');
+}, 'iteration composition of long-to-short filter list');
+
+test(t => {
+  CSS.registerProperty({
+    name: '--length-percentage',
+    syntax: '<length-percentage>',
+    initialValue: '0px',
+    inherits: false,
+  });
+
+  const div = createDiv(t);
+  const anim =
+    div.animate({ '--length-percentage': ['min(10px, 5%)', 'max(20px, 15%)'] },
+                { duration: 100 * MS_PER_SEC,
+                  easing: 'linear',
+                  iterations: 10,
+                  iterationComposite: 'accumulate' });
+  anim.pause();
+
+  anim.currentTime = anim.effect.getComputedTiming().duration * 2;
+  // At 3rd iteration 0%: start + 2 * end
+  // The left-to-right AST order of operands in calc() serialization is observable
+  // for complex terms that cannot be simplified or reordered by unit type.
+  // We verify that the accumulated end term (2 * max(20px, 15%)) appears before
+  // the current iteration start term (min(10px, 5%)) rather than requiring an
+  // exact string match, as different browsers may include identity terms (e.g. 0 * ...).
+  assert_regexp_match(getComputedStyle(div).getPropertyValue('--length-percentage'),
+    /2 \* max\(20px, 15%\).*min\(10px, 5%\)/,
+    'Expected accumulated term (2 * max(...)) before current term (min(...))');
+}, 'iteration composition of complex math functions preserves AST left-to-right operand order');
+
 </script>
Loading diff…

Original Bug Report

reported by oc...@gmail.com

Renderer crash via type confusion in ApplyIterationAccumulation with iterationComposite: 'accumulate'


Report description

Renderer crash via type confusion in ApplyIterationAccumulation with iterationComposite: ‘accumulate’


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules

Which URL (or repository) have you found the vulnerability in?

https://github.com/chromium/chromium


The problem

Please describe the technical details of the vulnerability

Summary

A type confusion in InvalidatableInterpolation::ApplyIterationAccumulation() causes a renderer process crash (CHECK failure) when animating CSS properties between incompatible value types using iterationComposite: 'accumulate'. 26 crash variants across 22 CSS properties were confirmed, including an out-of-bounds heap read via the clip property.

Affected Component

Blink>Animation

Severity

High — Renderer process crash from web content. The clip property variant causes an OOB heap read in release builds (DCHECK compiled out).

Prerequisites

Requires CSSAnimationIterationComposite experimental feature (enabled via chrome://flags/#enable-experimental-web-platform-features or --enable-blink-features=CSSAnimationIterationComposite). This feature is currently "experimental" but planned for stable shipping.

Reproduction

Minimal PoC — margin (CHECK crash)

<!DOCTYPE html>
<html><body>
<div id="t" style="width:200px;height:200px;background:red"></di
<script>
document.getElementById('t').animate(
  [{margin: 'calc(10px + 5%)'}, {margin: 'auto'}],
  {duration: 100, iterationComposite: 'accumulate', iterationStart: 3, fill: 'forwards'}
);
</script>
</body></html>

Minimal PoC — clip (OOB heap read in release builds)

<!DOCTYPE html>
<html><body>
<div id="t" style="width:200px;height:200px;background:red;position:absolute"></div>
<script>
document.getElementById('t').animate(
  [{clip: 'rect(10px, 50px, 50px, 10px)'}, {clip: 'auto'}],
  {duration: 100, iterationComposite: 'accumulate', iterationStart: 3, fill: 'forwards'}
);
</script>
</body></html>

Minimal PoC — aspect-ratio (CHECK crash)

<!DOCTYPE html>
<html><body>
<div id="t" style="width:200px;height:200px;background:red"></div>
<script>
document.getElementById('t').animate(
  [{aspectRatio: 'auto'}, {aspectRatio: '16/9'}],
  {duration: 100, iterationComposite: 'accumulate', iterationStart: 3, fill: 'forwards'}
);
</script>
</body></html>

Steps to reproduce

  1. Save any PoC above as an HTML file
  2. Launch Chrome with:
    chrome --enable-blink-features=CSSAnimationIterationComposite
    
  3. Navigate to the HTML file
  4. Renderer process crashes immediately

Crash output

FATAL:third_party/blink/renderer/platform/wtf/casting.h:127
Check failed: IsA<Derived>(from).
  blink_core!blink::InterpolableLength::ScaleAndAdd

All 22 confirmed crashing properties

Type 1: InterpolableLength::ScaleAndAdd CHECK (17 properties)

Property From To
margin-top calc(10px + 5%) auto
margin-left calc(10px + 5%) auto
margin-right calc(10px + 5%) auto
margin-bottom calc(10px + 5%) auto
top calc(10px + 5%) auto
left calc(10px + 5%) auto
right calc(10px + 5%) auto
bottom calc(10px + 5%) auto
perspective 500px none
column-width 100px auto
column-gap calc(10px + 1%) normal
row-gap calc(10px + 1%) normal
vertical-align 10px baseline
contain-intrinsic-width 100px auto
text-underline-offset 5px auto
line-height calc(20px + 0.5em) normal
word-spacing calc(5px + 0.2em) normal

Type 2: InterpolableNumber::Add CHECK (5 properties)

Property From To
z-index 5 auto
column-count 3 auto
font-size-adjust 0.5 none
text-size-adjust 120% auto
line-height 1.5 (number) normal

Type 3: InterpolableNumber type confusion — number vs length (1 property)

Property From To
tab-size 4 (number) 20px (length)

Type 4: InterpolableGridTrackList::Add CHECK (2 properties)

Property From To
grid-template-columns 100px 200px none
grid-template-rows 50px none

Type 5: InterpolableList size mismatch — OOB read (1 property)

Property From To
clip rect(10px, 50px, 50px, 10px) auto

The clip variant is the most dangerous: rect() creates an InterpolableList of size 4, while auto creates size 0. In dcheck_always_on builds this hits a DCHECK. In release builds the DCHECK is compiled out and the code reads 4 elements beyond the empty list’s allocation — an out-of-bounds heap read.

Root Cause

InvalidatableInterpolation::ApplyIterationAccumulation() at third_party/blink/renderer/core/animation/invalidatable_interpolation.cc:341-343:

InterpolableValue* result_value = cached_value_->MutableValue().Clone();
result_value->Scale(current_iteration_);
result_value->ScaleAndAdd(1.0, *scaled_end);

This calls ScaleAndAdd without verifying that result_value and *scaled_end have the same concrete InterpolableValue subclass type. When the animation transitions between incompatible value types (e.g., calc() → auto, number → length, rect() → auto), the To<> downcast inside ScaleAndAdd fails the IsA<> check.

The existing fix for crbug.com/467366440 (lines 332-337) added a guard only for the length-vs-length IACVT incompatibility case. It completely missed:

  • Length vs non-length (keyword) mismatches
  • Number vs length mismatches
  • List vs non-list mismatches
  • List size mismatches (clip: rect vs auto)
  • Grid track list mismatches

Suggested Fix

Add a type compatibility check before line 341:

if (!result_value->IsMergeTo(*scaled_end)) {
  return;  // Types are incompatible, skip accumulation
}

Or more conservatively, check IsA<> before each ScaleAndAdd call to ensure both operands have the same concrete type.

Environment

  • Chromium: Built from source (trunk, March 2026)
  • OS: Windows 11 Pro 10.0.26200
  • Build: is_debug=false dcheck_always_on=true is_component_build=true symbol_level=0

Impact analysis

  • Any website with chrome://flags/#enable-experimental-web-platform-features enabled can crash the tab instantly with 3 lines of JavaScript
  • The clip variant reads uninitialized heap memory in production Chrome (DCHECK compiled out) - could leak sensitive data from other sites in the same renderer process
  • When this feature ships to stable (it’s planned), every Chrome user becomes vulnerable

The cause

What version of Chrome have you found the security issue in?

Built from trunk (March 2026) + dev/canary with –enable-blink-features=CSSAnimationIterationComposite

Yes, it is related to a crash.

Choose the type of vulnerability

Other

How would you like to be publicly acknowledged for your report?

ochko

View on issue tracker