d7d097b65c58373535c1964fd8e1813b45538a00 Fix Float-cast-overflow in FETurbulence
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.
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
- Setup — A page loads SVG containing an filter and sets a very large filter region / primitive subregion (width/height) via SVG attributes and units.
- Subregion computed — Blink computes FilterPrimitiveSubregion() as a gfx::RectF whose width()/height() exceed INT32_MAX (test: 3e9).
- Overflowing narrow — FETurbulence::CreateImageFilter calls SkISize::Make(width, height); the float->int32 conversion is out of range (undefined) and yields a garbage/negative int.
- 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.
- 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
- 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)
- 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.
- 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.