High firefox OOB 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impacthigh
DescriptionInvalid pointer in the Graphics component
ComponentGraphics
Bug ClassOOB
Tracker2068400
Fix commit48440d86a6ba (firefox) +49/-7
CISA KEVNot listed
CreditedMozilla
Disclosed2026-10-27

Background

Swizzle/premultiply routines
gfx/2d/Swizzle.cpp implements 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 an IntSize (width × height in pixels).
Stride vs. gap
stride is 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·height pixels, avoiding per-row loop overhead. CollapseSize returns that collapsed IntSize(area, 1) when collapsing is safe, else the original size.
`CheckedInt32 area`
The collapse multiplies width·height with 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.

Key insight
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

  1. 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 SVG filter (feFlood+feTile+feColorMatrix) applied as ctx.filter, forcing a format-converting blit.
  2. Choose mismatched formats/strides Craft the source and destination so their pixel formats differ or the destination stride carries padding, while srcStride / 4 == width still holds for the source — satisfying the old collapse predicate without both rows actually being gap-free.
  3. Trigger collapse The old CollapseSize returns IntSize(width·height, 1), flattening the image to one row for the conversion loop.
  4. Out-of-bounds traversal The loop walks width·height pixels contiguously, stepping over the inter-row gaps/padding and running past the allocated buffer, reading and/or writing adjacent heap memory.
  5. 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

The affected code runs wherever 2D pixel swizzling/premultiplication happens — reachable from unprivileged web content through canvas 2D, SVG filters and image decoding in the content process. The primitive is an out-of-bounds read and/or write over heap-allocated pixel buffers; with attacker-chosen image dimensions and format mismatch, the overflow length and direction are influenceable, making it a stronger corruption primitive than a mere crash, though full exploitation (controlled write target) is not proven by the diff and is (inferred). Preconditions: steering a conversion where source and destination formats/strides differ in a way that satisfied the old stride check but leaves a real per-row gap.

Changed Functions

FunctionChangeNotes
if
gfx/2d/Swizzle.cpp
modified

Files Changed

  • gfx/2d/Swizzle.cpp
  • testing/web-platform/tests/css/filter-effects/feTile-01.html

Audit Directions

  • Other stride/`bytesPerPixel` arithmetic
    Scan gfx/2d/Swizzle.cpp and sibling blit/convert code for remaining / 4, & 3, or stride == width*4 assumptions that bake in a 4-byte pixel or equal src/dst formats.
  • Fast-path "treat as single row" optimizations
    Look 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. use
    Review helpers that compute sizes/offsets before the corresponding bounds/negativity checks run, a pattern this fix corrected by moving CollapseSize after the gap validation.
  • feTile / filter buffer sizing
    Given the WPT reproducer, audit SVG filter primitive buffers (feTile, feFlood) for stride/format assumptions feeding these routines.
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>
Loading diff…

Regression Test / PoC

shipped with the fix
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>
Loading diff…