← Chrome — 2026-W03

bfbac7d2e3fca9a17e9bddd740ab9241bfa47edb  Reject spline animations if the last value of keyTimes is not 1

severity low class logic-UB/NaN-DCHECK CVSS 5.0 confidence 0.5 signal score 3.8 third_party/blink/renderer AI review CONFIRMED
Primitive: For calcMode=spline with duplicated trailing keyTimes not ending at 1, CalculateKeyTimesIndex returned a bad index producing inf then NaN in Bezier math, tripping a DCHECK. Fix rejects such splines.
Triage note: SVG SMIL animation reachable from web content; produces NaN and a DCHECK assertion failure (debug crash / bad interpolation in release). No memory corruption. watch.

The bug at a glance

The defect is a missing precondition check on author-supplied SVG SMIL animation attributes. In a DCHECK-enabled build (dev, canary-with-DCHECKs, fuzzer configurations) an author-controlled keyTimes list that does not end at 1 while calcMode=spline drives CalculateKeyTimesIndex to return an out-of-range/degenerate index, the interpolation fraction becomes inf, the cubic Bezier evaluation turns that into NaN, and a DCHECK fires -> renderer abort. That is a reliable, remotely-triggerable renderer crash in DCHECK builds. In a shipping (NDEBUG) build the DCHECK is compiled out, so the NaN simply propagates into the animated motion transform, producing garbage geometry rather than memory corruption. There is no evidence of out-of-bounds memory access, type confusion, or a controllable write; the index feeds float arithmetic and bounds-checked WTF::Vector access, so the worst production outcome is visual corruption / NaN-poisoned layout values. That keeps it firmly in the low band. cvss ~5.0 reflects network-attackable, no privileges, no user interaction, availability-only impact confined to DCHECK builds; the vendor’s own 3.8 score reflects the same limited reach for release channels.

SVG SMIL is a large, historically bug-dense attack surface reachable by any HTML/SVG document, and the animation value pipeline (keyTimes / keyPoints / keySpline / calcMode) is a web-of-invariants where one path validated a precondition and a sibling path did not. The interesting angle here is that Blink already relied everywhere on the invariant ’the last keyTime equals 1’, enforced it for the paced/discrete/linear value paths, but never enforced it on the spline path – so a single malformed attribute string reaches float math that assumes normalized, monotonic, terminal-at-1 timing. This is the classic ‘invariant asserted in one place, assumed everywhere’ shape that produces NaN/inf and downstream DCHECKs.

Root cause

SVGAnimationElement::CalculateKeyTimesIndex() locates the keyTimes segment [index, index+1] containing the current animation percent and computes key_point_percent = (percent - from_percent) / (to_percent - from_percent). This code assumes keyTimes is a normalized, non-decreasing list whose final element is exactly 1, so that for any percent in [0,1] a valid segment with to_percent > from_percent exists. When calcMode=spline and the author supplies keyTimes such as ‘0;0.1;0.1’ (or the description’s ‘0;0.5;0.5’) – i.e. a list that does NOT end at 1 and whose trailing entries are duplicated – the segment lookup selects a bogus index. Either the search walks off the intended terminal segment or it lands on a segment where to_percent == from_percent (duplicate trailing values). The denominator (to_percent - from_percent) is then 0, so the division yields inf. That inf key_point_percent is subsequently fed into the cubic Bezier solver used by the spline keySplines evaluation, and inf-in produces NaN-out. The NaN reaches a DCHECK further down the animation-value computation, aborting the renderer in DCHECK builds. The patch does two things: (1) adds a defensive DCHECK_GT(to_percent, from_percent) documenting/asserting the monotonicity invariant at the division site, and (2) adds the real fix in the keySplines-validation branch of the attribute checker – rejecting the whole spline animation when KeyTimes().back() != 1, so a malformed spline never reaches the interpolation path at all.

Key code

Validation added in the calcMode=spline branch: reject when the final keyTime is not 1, plus an asserting guard at the division site.

   DCHECK_LT(index + 1, KeyTimes().size());
   float from_percent = KeyTimes()[index];
   float to_percent = KeyTimes()[index + 1];
+  DCHECK_GT(to_percent, from_percent);
   float to_key_point = key_points_[index + 1];
   float key_point_percent =
       (percent - from_percent) / (to_percent - from_percent);
...
     if (key_splines_.empty() ||
         (has_key_points && key_splines_.size() != key_points_.size() - 1) ||
-        (has_key_times && key_splines_.size() != KeyTimes().size() - 1))
+        (has_key_times && key_splines_.size() != KeyTimes().size() - 1) ||
+        (!KeyTimes().empty() && KeyTimes().back() != 1)) {
       return false;
+    }

Patch walkthrough

  • third_party/blink/renderer/core/svg/svg_animation_element.cc (CalculateKeyTimesIndex / spline percent) — Adds DCHECK_GT(to_percent, from_percent) immediately before the division that computes key_point_percent. This is a documenting/defensive assertion: it encodes the invariant that the selected keyTimes segment must be strictly increasing, so a zero denominator (duplicate trailing values) is caught explicitly rather than silently yielding inf. It does not itself prevent the bug in release builds – it makes the broken state loud in DCHECK builds and pins the invariant for future readers.
  • third_party/blink/renderer/core/svg/svg_animation_element.cc (keySplines validation branch) — The actual fix. In the block that validates keySplines against keyTimes/keyPoints for calcMode=spline, a new disjunct ‘(!KeyTimes().empty() && KeyTimes().back() != 1)’ is added and the branch is reformatted with braces. When keyTimes is non-empty and its last value is not exactly 1, the function returns false, meaning the spline animation is treated as invalid and is not applied. This enforces the same ’ends at 1’ precondition the interpolation code already assumed, so the inf/NaN path is unreachable for spline animations.
  • third_party/blink/web_tests/external/wpt/svg/animations/animateMotion-spline-invalid-keyTimes.html (new) — A new WPT regression test. It defines an animateMotion with calcMode=spline, keyTimes=‘0;0.1;0.1’ (does not end at 1, duplicated trailing value), keyPoints and keySplines, then samples the CTM at t=0, 1, 2s and asserts rect.getCTM().e == 0 – i.e. the motion path is NOT applied because the spline is rejected. This locks in the reject-not-crash behavior.

Background

SVG SMIL animation timing (keyTimes / keyPoints / keySplines / calcMode) — SMIL animations in SVG map a normalized progress percent in [0,1] onto animation output. keyTimes is a list of monotonically non-decreasing values in [0,1] whose first element is 0 and last element must be 1; each keyTime pairs with a value/keyPoint. calcMode=spline additionally supplies keySplines, one cubic Bezier control-point set per interval, that reshapes progress within each interval. The spec requires keyTimes to end at 1; Blink relied on that but only enforced it on some code paths.

CalculateKeyTimesIndex() — Helper on SVGAnimationElement that, given the current percent, finds the keyTimes interval [index, index+1] the percent falls into and returns index. Downstream code computes a fractional position within that interval via (percent - from) / (to - from), which is only well-defined when to > from and when the list terminates at 1 so every percent lands in a real interval.

DCHECK vs release behavior — DCHECK is Chromium’s debug-only assertion; it aborts the process in DCHECK-enabled builds (developer, some canary/fuzzer configs) and compiles to nothing in stable/NDEBUG. So a DCHECK-tripping bug is a crash in test/fuzz builds and merely undefined/garbage arithmetic in shipping builds – which is exactly why this lands as low/watch-tier rather than a shipping DoS.

inf/NaN propagation — IEEE-754 division by zero of a nonzero value gives inf; feeding inf into the cubic Bezier polynomial evaluation yields NaN. NaN compares false to everything and poisons any layout/transform value it flows into, which both trips value-sanity DCHECKs and, in release, produces nondeterministic rendering rather than a memory-safety fault.

Vulnerability window

  1. latent introduction — The keySplines validation branch historically checked keySplines count against keyTimes/keyPoints counts but never checked that keyTimes ends at 1, while the interpolation code assumed it did – the invariant gap existed as long as the spline path and CalculateKeyTimesIndex coexisted.
  2. discovery — Tracked as crbug 471141915 (Fixed: tag). The malformed-input shape (spline + keyTimes not ending at 1 + duplicated trailing values) is characteristic of a fuzzer or a DCHECK-build crash report rather than a reported exploit.
  3. fix — Change-Id Ideed8bd…, authored by yuyisi ([email protected]), reviewed by Fredrik Soderquist and Philip Rogers, landed 2026-01-14 at r1569431 with a WPT regression test.

Proof of concept

The commit shipped a WPT regression test, not an exploit. The trigger is purely declarative markup: an animateMotion (or any calcMode=spline SMIL animation) whose keyTimes attribute does not end at 1 and has duplicated trailing values, e.g. keyTimes=‘0;0.1;0.1’ with keyPoints=‘0;0.5;1’ and keySplines=‘0.5 0 0.5 1;0.5 0 0.5 1’. In a DCHECK-enabled build, sampling the animation drives (percent-from)/(to-from) with to==from -> inf -> NaN in the Bezier evaluation -> DCHECK abort of the renderer. In a shipping build this does not corrupt memory; it yields NaN-poisoned motion geometry. No memory-safety exploit is demonstrated or plausible from the diff. The reconstructed recipe below is the harness’s own markup; it verifies the fix (motion path not applied), it is not an exploit primitive.

<svg id="anim" width="400" height="400" xmlns="http://www.w3.org/2000/svg">
  <rect id="rect" x="0" y="0" width="40" height="40" fill="blue">
    <animateMotion path="M-10,-10 L-100,-10 L-100,-100 Z" keyTimes="0;0.1;0.1" keyPoints="0;0.5;1"
      keySplines="0.5 0 0.5 1;0.5 0 0.5 1" calcMode="spline" dur="2s" repeatCount="indefinite" />
  </rect>
</svg>
<script>
  // Before the fix: sampling drives (percent-from)/(to-from) with to==from -> inf -> NaN -> DCHECK.
  // After the fix: the spline is rejected, getCTM().e stays 0 (motion path not applied).
</script>

Exploitation

  1. trigger — Serve any HTML/SVG document containing a calcMode=spline SMIL animation with keyTimes not ending in 1 and duplicated trailing values. No interaction beyond loading and letting the animation clock advance.
  2. impact (DCHECK builds) — NaN reaches an animation-value DCHECK, aborting the renderer process – a denial-of-service limited to DCHECK-enabled builds (dev/canary/fuzzers).
  3. impact (release builds) — DCHECK is compiled out; NaN propagates into the motion transform, producing incorrect/garbage geometry. No memory corruption, no controllable primitive; not a viable path to code execution from this diff.

Detection & hunting

For defenders and detection engineers:

  • DCHECK/crash telemetry in SVGAnimationElement — Renderer aborts with DCHECK failures in svg_animation_element.cc around CalculateKeyTimesIndex or the animation-value sanity checks, correlated with NaN/inf, in DCHECK-enabled fleets and fuzzers.
  • Malformed SMIL attribute content — Static/DOM scans for calcMode=spline animations whose keyTimes attribute does not terminate at 1 or contains duplicate trailing values; such content is spec-invalid and, pre-fix, dangerous.
  • NaN in animated transform output — In release builds, watch for NaN/inf appearing in animated motion/transform matrices during layout as an indirect indicator of this or sibling timing bugs.

Audit directions

  • Sibling SMIL interpolation paths — Audit the discrete/linear/paced value paths and keyPoints handling in svg_animation_element.cc for the same ‘keyTimes ends at 1 / segments strictly increasing’ assumption; verify each entry point enforces the invariant rather than assuming it. Cross-check CalculateKeyTimesIndex callers for zero-denominator possibilities.
  • Author-controlled float pipelines feeding DCHECKs — Grep Blink SVG/animation/CSS math for divisions by (a - b) where a,b derive from parsed author attributes without a strict-ordering guarantee; these are candidate inf/NaN sources that trip DCHECKs and poison layout values.
  • keySplines / cubic Bezier evaluators — Review the Bezier solver used for keySplines for NaN/inf hardening and input-domain clamping, since inf key_point_percent was the amplifier that turned a bad index into a NaN.

Before / after

Loading diff…