← Chrome — 2026-W03

d7d097b65c58373535c1964fd8e1813b45538a00  Fix Float-cast-overflow in FETurbulence

severity low class IntegerOverflow CVSS 3.1 confidence 0.7 signal score 6.22 AI review PLAUSIBLE (downgraded)
Primitive: SVG feTurbulence filter subregion exceeding INT_MAX float-casts to a garbage/overflowed SkISize passed to Skia's TurbulencePaintFilter.
Triage note: FETurbulence::CreateImageFilter built SkISize::Make() directly from FilterPrimitiveSubregion().width()/height() (floats); a subregion > 2^31 (settable via SVG filter region attributes) makes the float->int32 conversion undefined (float-cast-overflow), yielding a garbage/negative tile size handed to Skia turbulence generation. Fix clamps with base::saturated_cast<int32_t>; regression test drives a 3e9 subregion. Web-reachable via SVG, memory-safety-relevant.
Structural signals: clamp_saturate (+2.92), msg_medium (+2.0), surgical (+1.3)

The bug at a glance

This is a web-reachable memory-safety bug in Blink’s SVG filter pipeline: any page can declare an filter and drive its filter-primitive subregion past the 32-bit integer range, at which point a float-to-int conversion overflows and a garbage tile size is handed to Skia’s turbulence generator. It is reachable with no privileges over an ordinary page load (SVG is content), which argues for a solid medium-to-high score; it is not rated higher because the observable end state from the diff is an overflowed/garbage SkISize whose downstream effect in Skia (an over- or under-sized allocation, an out-of-bounds access, or a controlled crash) is not proven by the patch itself. CVSS ~6.5 reflects network vector, low complexity, no privileges, and a memory-safety impact of uncertain exploitability. (inferred: precise downstream Skia consequence.)

The tell is a one-line change wrapped in base::saturated_cast<int32_t> plus a brand-new regression test that shoves a 3,000,000,000-wide subregion through the filter and asserts the result clamps to 2147483647. The subregion dimensions are floats (gfx::RectF), but SkISize::Make wants int32 — and nobody had bounded the float before the narrowing conversion. Filter primitive subregions look small in every normal page, so the width/height were quietly assumed to fit an int; SVG lets an author blow that assumption wide open through the filter region and units, and casting a 3e9 float to int32 is undefined behavior that yields whatever the compiler’s conversion leaves behind (commonly INT_MIN or 0). What makes it elegant is how cheap the trigger is: a handful of SVG attributes turns a routine size computation into an overflowed dimension flowing straight into a native image filter.

Root cause

FETurbulence::CreateImageFilter constructs the Skia turbulence paint filter and must give it a tile size as an SkISize (a pair of int32). Before the fix it built that size directly from the filter primitive subregion: SkISize::Make(FilterPrimitiveSubregion().width(), FilterPrimitiveSubregion().height()). FilterPrimitiveSubregion() is a gfx::RectF, so width() and height() are floats, and SkISize::Make takes ints — the arguments undergo an implicit float-to-int32 narrowing conversion.

The filter primitive subregion is attacker-influenced: for an SVG , it derives from the filter region and primitive x/y/width/height together with filterUnits/primitiveUnits and the applied scale. An author can therefore make the subregion width/height arbitrarily large — the regression test uses 3e9, which is greater than INT32_MAX (2147483647). Converting a float whose value exceeds the range of int32 is undefined behavior in C++ (the sanitizer name is float-cast-overflow); in practice it produces an unspecified int (frequently INT_MIN, 0, or a wrapped value) rather than a clamped one.

That overflowed, unvalidated int becomes the SkISize tile size passed into Skia’s TurbulencePaintFilter. A garbage or negative tile dimension driving native turbulence generation is a size the rest of the pipeline never sanity-checks, so it can flow into Skia allocation/indexing math with an attacker-chosen, out-of-range value — the unsafety is a missing bound between a float subregion the web controls and the int32 size a native filter consumes. The fix clamps each dimension with base::saturated_cast<int32_t> so an out-of-range subregion saturates to INT32_MAX instead of invoking undefined conversion, and the new test asserts exactly that clamp. (inferred: whether Skia then over-allocates, reads/writes out of bounds, or merely fails depends on Skia internals not in this diff.)

Key code

FETurbulence::CreateImageFilter: bound the float subregion before narrowing it to SkISize’s int32.

// before — implicit float->int32 narrowing of an attacker-influenced subregion:
// const SkISize size = SkISize::Make(FilterPrimitiveSubregion().width(),
//                                    FilterPrimitiveSubregion().height());

// after — saturate to INT32_MAX instead of overflowing:
const SkISize size = SkISize::Make(
    base::saturated_cast<int32_t>(FilterPrimitiveSubregion().width()),
    base::saturated_cast<int32_t>(FilterPrimitiveSubregion().height()));

Patch walkthrough

  • third_party/blink/renderer/platform/graphics/filters/fe_turbulence.cc — The core fix. Adds #include “base/numerics/safe_conversions.h” and wraps both SkISize::Make arguments in base::saturated_cast<int32_t>, so a FilterPrimitiveSubregion width/height beyond the int32 range saturates to INT32_MAX rather than invoking an undefined float-to-int conversion.
  • third_party/blink/renderer/platform/graphics/filters/fe_turbulence_test.cc — New regression test LargeFilterPrimitiveSubregion: sets the subregion to 3e9 x 3e9, builds the image filter, and asserts the resulting TurbulencePaintFilter tile_size clamps to 2147483647 x 2147483647 — i.e. that the overflow no longer occurs.
  • third_party/blink/renderer/platform/BUILD.gn — Registers the new fe_turbulence_test.cc in the platform test target.

Background

feTurbulence / FETurbulence — The SVG filter primitive that generates Perlin turbulence/fractal noise. FETurbulence::CreateImageFilter maps it to Skia’s TurbulencePaintFilter, which needs a tile size.

Filter primitive subregion — The region a filter primitive operates on, derived from the SVG filter region and the primitive’s x/y/width/height plus filterUnits/primitiveUnits and scale. It is a gfx::RectF (float) and is author-controllable.

SkISize / SkISize::Make — Skia’s integer size type; Make takes two int32. Passing floats narrows them via an implicit conversion with no range check.

float-cast-overflow — Converting a floating-point value to an integer type it cannot represent is undefined behavior in C++ (UBSan’s ‘float-cast-overflow’); the produced integer is unspecified, not clamped.

base::saturated_cast — A Chromium numeric cast that clamps out-of-range inputs to the target type’s min/max instead of overflowing — the safe replacement used by the fix.

Vulnerability window

  1. Setup — A page loads SVG containing an filter and sets a very large filter region / primitive subregion (width/height) via SVG attributes and units.
  2. Subregion computed — Blink computes FilterPrimitiveSubregion() as a gfx::RectF whose width()/height() exceed INT32_MAX (test: 3e9).
  3. Overflowing narrow — FETurbulence::CreateImageFilter calls SkISize::Make(width, height); the float->int32 conversion is out of range (undefined) and yields a garbage/negative int.
  4. Garbage size to Skia — The overflowed SkISize is handed to TurbulencePaintFilter as the tile size, entering Skia’s turbulence generation with an attacker-influenced, out-of-range dimension.
  5. Post-fix — base::saturated_cast<int32_t> clamps each dimension to INT32_MAX before SkISize::Make, so the size stays in range and the overflow cannot occur; the regression test asserts the clamp.

Proof of concept

Reconstructed from the regression test the commit ships (fe_turbulence_test.cc), plus the web-reachable SVG analogue. The C++ test drives a 3e9 x 3e9 filter primitive subregion and asserts the tile size clamps post-fix (pre-fix the float->int32 narrowing was undefined). The in-page trigger is an SVG whose filter region / primitiveUnits push the primitive subregion past 2^31 device units on a filtered element. Both are triggers/oracles (UBSan float-cast-overflow pre-fix), not a memory-corruption exploit: they prove the overflow, not a controlled OOB.

// Grounded: the regression test added in this commit (blink unit test).
auto* filter = MakeGarbageCollected<Filter>(gfx::RectF(0,0,100,100),
    gfx::RectF(0,0,100,100), 1.0f, Filter::UnitScaling::kUserSpace);
auto* effect = MakeGarbageCollected<FETurbulence>(
    filter, FETURBULENCE_TYPE_TURBULENCE, 0.1f, 0.1f, 1, 0, true);
effect->SetFilterPrimitiveSubregion(gfx::RectF(0, 0, 3e9f, 3e9f)); // > INT32_MAX
auto image_filter = effect->CreateImageFilterWithoutValidation();
auto* t = static_cast<cc::TurbulencePaintFilter*>(image_filter.get());
// pre-fix: SkISize::Make(3e9f) is UB (float-cast-overflow, garbage int);
// post-fix: t->tile_size() saturates to 2147483647.

<!-- (inferred) web-reachable analogue: an SVG feTurbulence with a filter
     region driven past the int32 range applied to a filtered element. -->

Exploitation

  1. Attacker-controlled size — The subregion width/height are driven by SVG filter-region and units, so the attacker chooses a value beyond INT32_MAX and thus the exact (undefined) int the conversion produces on a given build. (inferred)
  2. Into native filter math — The overflowed SkISize becomes Skia’s turbulence tile size; a garbage or negative dimension feeding allocation/indexing in native code is the classic shape of an out-of-bounds or over-allocation primitive.
  3. Uncertain escalation — Whether this yields a controlled OOB write, an OOB read/info-leak, an over-allocation DoS, or a benign clamp inside Skia is not determinable from this diff; the honest read is a real memory-safety bug of medium severity whose exploitability needs Skia-side analysis. (inferred)

Detection & hunting

For defenders and detection engineers:

  • UBSan float-cast-overflow — A UBSan report of float-cast-overflow in FETurbulence::CreateImageFilter (or the SkISize::Make call site) on a page with a large SVG filter region is the direct signature.
  • ASan in Skia turbulence — Any ASan heap error whose stack runs through TurbulencePaintFilter / turbulence tile generation shortly after an SVG filter update is a candidate downstream effect.
  • Content heuristic — SVG combined with unusually large filter region / primitive width/height (near or above 2^31 device units) is rare in benign content and worth flagging for fuzzing corpora.

Audit directions

  • Other filter primitives — Audit every FE* CreateImageFilter that builds an SkISize/SkIRect from FilterPrimitiveSubregion() or filter region floats; the same unbounded float->int narrowing may exist (feImage, feFlood, feTile, feDisplacementMap, etc.).
  • float -> SkISize/SkIRect casts — Grep for SkISize::Make / SkIRect::Make and similar taking values derived from gfx::RectF/float geometry without saturated_cast; these are the same class of float-cast-overflow.
  • Attacker-controlled sizes into Skia — Trace where web-controlled dimensions (filters, canvas, images) reach native Skia size/allocation APIs and confirm each is range-checked or saturated before narrowing.
  • saturated_cast adoption — Use this fix as a template: prefer base::saturated_cast<int32_t> over implicit float->int narrowing anywhere a subregion or geometry value can be author-driven.

Before / after

Loading diff…