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
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:
FATAL:third_party/blink/renderer/platform/wtf/casting.h:127
Check failed: IsA<Derived>(from).
blink_core!blink::InterpolableLength::ScaleAndAddThe 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:
chrome://flags/#enable-experimental-web-platform-features, or--enable-blink-features=CSSAnimationIterationComposite
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 valueBoth 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):
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:
- length versus non-length keyword (calc(...) to auto, none, normal, baseline)
- number versus length (tab-size: 4 to 20px)
- list versus non-list (grid-template-columns tracks to none)
- list size mismatch (clip: rect(...) to auto)
- grid track list incompatibilities
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)
| 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 |
Class 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 |
Class 3: InterpolableNumber confusion, number versus length (1 property)
| Property | From | To |
|---|---|---|
| tab-size | 4 (number) | 20px (length) |
Class 4: InterpolableGridTrackList::Add check (2 properties)
| Property | From | To |
|---|---|---|
| grid-template-columns | 100px 200px | none |
| grid-template-rows | 50px | none |
Class 5: InterpolableList size mismatch, out-of-bounds read (1 property)
| Property | From | To |
|---|---|---|
| clip | rect(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:
- In
dcheck_always_onbuilds, aDCHECKon the list sizes fires first (a controlled abort). - In release builds the
DCHECKis compiled out, so the loop reads four elements beyond the empty list's 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.htmlmargin: 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
- Save a reproduction as an HTML file.
- Launch Chrome with
--enable-blink-features=CSSAnimationIterationComposite. - Open the file.
- The renderer process crashes immediately (
dcheck_always_on), or performs the out-of-bounds read (release,clipvariant).
Impact analysis
Denial of service
Any page can crash the current tab's renderer with roughly three lines of JavaScript, no interaction required, as soon as the feature is enabled.
Information disclosure (release, clip)
The DCHECK that catches the list size mismatch is compiled out in production, so the code reads uninitialized or adjacent heap memory. Site isolation reduces the value of a single-renderer read, but cross-site content can share a renderer in known cases, so this is treated as a potential leak of sensitive data rather than a pure crash.
Future exposure
Today this requires an experimental flag, which bounds the exposed population. The feature is on the path to stable; shipping it without this fix exposes the entire Chrome user base to the same code path with no flag.
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