3f8ee96594aea46bbe015ea3cb9b69cdf59655e3 [non-cocoa][fuzz] OOB write in AVIF/WebP/JPEG XL
Triage note: ImageBackingStore::create now returns nullptr on allocation failure and its copy ctor copies m_frameRect (previously uninitialized, mismatching the pixel buffer); JPEGXL imageOut clamps numPixelsInRow to width-x and bounds-checks x/y; WEBP applyPostProcessing replaces security-implication ASSERTs with min() clamps and uses the decoder stride; AVIF propagates setSize failure and rejects dimension mismatch. Malicious images could otherwise drive OOB writes into the frame buffer.
The bug at a glance
Multiple WebCore image decoders wrote decoded pixels into a frame backing store using attacker-controlled dimensions/row widths without clamping to the allocated buffer: JPEGXL’s imageOut wrote numPixels pixels at (x,y) with no x/y or width clamp; WEBP’s applyPostProcessing relied on ASSERT_WITH_SECURITY_IMPLICATION (compiled out in release) for decodedWidth==frameRect.width and used frameRect.width as the source stride; AVIF ignored setSize failure and did not reject a per-frame dimension mismatch; and ImageBackingStore::create ignored allocation failure while its copy ctor left m_frameRect uninitialized, mismatching the pixel buffer. The patch makes create() return nullptr on OOM, copies m_frameRect, bounds-checks/clamps JPEGXL rows, replaces WEBP’s security ASSERTs with min() clamps and a real decoder stride, and propagates AVIF size failure/dimension mismatch. These are heap out-of-bounds writes in the image-decoding path (WebContent/GPU process), reachable by serving a malicious image via /CSS/fetch — a strong, attacker-controlled heap-write primitive. Severity high is fully justified; this is a memory-corruption (not merely read) bug.
Image decoders trusted the codec’s reported row width/height and the frame rect over the actually-allocated backing store, with the only guards being release-compiled-out security ASSERTs — so a frame whose decoder-reported width exceeds its backing rect writes past the pixel buffer.
Root cause
The invariant is that every pixel write must land within the ImageBackingStore’s allocated m_pixels buffer, whose geometry is m_size/m_frameRect; a decoder must clamp codec-reported coordinates and row lengths to that geometry and must fail closed on allocation or size mismatch. Several decoders violated this.
JPEGXL: imageOut(x, y, numPixels, pixels) did auto row = buffer.backingStore()->pixelsStartingAt(x, y); then wrote numPixels RGBA pixels into row with no validation of x/y against the canvas or of numPixels against width-x. Pre-patch chain: 1. libjxl invokes the imageOut callback with codec-driven x/y/numPixels. 2. pixelsStartingAt(x,y) computes a destination inside (or past) m_pixels. 3. the loop writes numPixels pixels, running past the row/buffer when x+numPixels exceeds width or y>=height. The fix renames numPixels->numPixelsInRow and adds if (x >= width || y >= height) return; and numPixelsInRow = std::min(numPixelsInRow, width - x); before the write (and uses numPixelsInRow for the color-space conversion).
WEBP: applyPostProcessing read decodedWidth/decodedRows from WebPIDecGetRGB and guarded only with ASSERT_WITH_SECURITY_IMPLICATION(decodedWidth == frameRect.width()) / (decodedHeight <= frameRect.height()) — assertions that are compiled out in release, so a mismatch proceeded to pixelAt(canvasX, canvasY) writes and indexed the source as rgba + (y*frameRect.width()+x)*4. The fix computes copyWidth = min(decodedWidth, frameRect.width()), copyRows = min(decodedRows, frameRect.height()), loops over those, and reads the source using the real decoderBuffer.u.RGBA.stride instead of frameRect.width().
AVIF: parseHeader did m_decoder->setSize(...); return true; ignoring setSize’s result; decodeFrame used m_decoder->size() without checking it matched the actual avif image dimensions. The fix returns setSize’s bool and adds if (IntSize(image->width, image->height) != imageSize) { setFailed(); return; } so a per-frame dimension change can’t write into a buffer sized for different dimensions. ImageBackingStore::create now returns nullptr when m_pixels failed to allocate, and the copy ctor initializes m_frameRect(other.m_frameRect) (previously uninitialized, so a copied store’s frameRect did not match its pixel buffer). Bugclass OOB-write matches the diff across all four decoders.
Key code
JPEGXL per-row clamp and WEBP min()-clamp + real stride (replacing security ASSERTs)
// JPEGXL imageOut: bounds-check origin and clamp row length
auto canvasSize = size();
if (x >= (size_t)canvasSize.width() || y >= (size_t)canvasSize.height())
return;
numPixelsInRow = std::min(numPixelsInRow, (size_t)canvasSize.width() - x);
// WEBP applyPostProcessing: clamp instead of ASSERT_WITH_SECURITY_IMPLICATION
const int copyWidth = std::min(decodedWidth, frameRect.width());
const int copyRows = std::min(decodedRows, frameRect.height());
// source now uses the decoder's real stride:
uint8_t* sourcePixels = decoderBuffer.u.RGBA.rgba + y * decoderBuffer.u.RGBA.stride + x * sizeof(uint32_t);
Fix approach
The patch makes the backing store fail closed and makes every decoder clamp to the real geometry. ImageBackingStore::create(size,…) returns nullptr if allocation (m_pixels) failed, create(const ImageBackingStore&) returns nullptr if the source has no pixels, and the copy ctor now copies m_frameRect so a duplicated store’s rect matches its buffer. JPEGXL imageOut rejects out-of-canvas origins and clamps the row length to width-x. WEBP applyPostProcessing drops the release-compiled-out ASSERT_WITH_SECURITY_IMPLICATION guards in favor of copyWidth/copyRows = min(decoded, frameRect) loops and reads the source with the decoder’s actual RGBA.stride rather than assuming frameRect.width(). AVIF propagates setSize failure out of parseHeader and fails the frame if the per-frame image dimensions differ from the decoder size. Net: codec-reported widths/heights can no longer drive writes past the allocated frame buffer, and OOM/size-mismatch now abort decoding.
Patch walkthrough
Source/WebCore/platform/graphics/ImageBackingStore.h— create(size) returns nullptr when m_pixels allocation failed; create(const ImageBackingStore&) returns nullptr if the source has no pixels; the copy ctor initializes m_frameRect(other.m_frameRect), fixing a previously uninitialized frame rect that mismatched the copied pixel buffer.Source/WebCore/Headers.cmake— Exports ImageBackingStore.h as a private framework header (needed now that callers consume the nullptr-returning create and frame-rect semantics).Source/WebCore/platform/image-decoders/jpegxl/JPEGXLImageDecoder.cpp— imageOut renames numPixels->numPixelsInRow, returns early if x/y are outside the canvas, and clamps numPixelsInRow to canvasWidth-x before writing the row and running color-space conversion; processInput now returns JXL_DEC_ERROR when buffer.initialize fails instead of silently continuing.Source/WebCore/platform/image-decoders/jpegxl/JPEGXLImageDecoder.h— Updates imageOut/imageOutCallback signatures to the clamped numPixelsInRow parameter name.Source/WebCore/platform/image-decoders/webp/WEBPImageDecoder.cpp— applyPostProcessing replaces ASSERT_WITH_SECURITY_IMPLICATION(width/height) with copyWidth/copyRows = min(decoded, frameRect) loop bounds and reads source pixels via decoderBuffer.u.RGBA.stride instead of frameRect.width(), preventing OOB writes and mis-strided reads on dimension mismatch.Source/WebCore/platform/image-decoders/avif/AVIFImageReader.cpp— parseHeader returns m_decoder->setSize(…) instead of ignoring it; decodeFrame fails the decode if the per-frame avif image dimensions differ from the decoder’s size before initializing/writing the buffer.
Background
ImageBackingStore — WebCore’s heap buffer (m_pixels) for a decoded image frame, described by m_size and m_frameRect; all decoder pixel writes must stay within it.
ScalableImageDecoder / ScalableImageDecoderFrame — The incremental image-decoding framework; each frame’s buffer is initialized to a size and then filled by the codec-specific decoder (JPEGXL/WEBP/AVIF).
ASSERT_WITH_SECURITY_IMPLICATION — A debug assertion that documents a security-relevant invariant but is compiled out in release builds, so relying on it for bounds safety leaves release builds unguarded.
WebP incremental decode stride — WebPIDecGetRGB returns decoded rows and a buffer with its own RGBA.stride; using frameRect.width() instead of that stride mis-indexes the source and the width mismatch can overrun the destination.
AVIF per-frame dimensions — An AVIF/animated image can report per-frame image width/height; if a frame’s dimensions differ from the decoder size the frame buffer was allocated for, writes sized to one geometry land in a buffer sized for another.
Vulnerability window
- Setup — Attacker crafts a JPEGXL/WEBP/AVIF image whose codec-reported row width or per-frame dimensions exceed the frame backing store (or triggers an allocation failure / frame-rect mismatch).
- Delivery — The image is loaded via , CSS background, canvas drawImage, or fetch+decode, invoking the corresponding ScalableImageDecoder.
- Trigger — The decoder writes decoded pixels using the codec’s width/height/stride without clamping to the allocated buffer (or after ignoring a size/OOM failure).
- Corruption — Pixel writes run past m_pixels, giving an attacker-influenced heap out-of-bounds write adjacent to the frame buffer.
Proof of concept
No regression test ships in the diff. Conceptual trigger: craft a JPEGXL image that drives the imageOut callback with x+numPixels beyond the canvas width (or y beyond height), or a WEBP whose incrementally-decoded width exceeds the frame rect, or an AVIF whose per-frame dimensions differ from the header size; load it with (or CSS background / createImageBitmap). Pre-patch the decoder writes past the ImageBackingStore buffer. Exact byte layout for controlled corruption depends on heap state (inferred).
Exploitation
- Reach — Image decoding is reachable from any web page via /CSS/canvas/fetch, running the codec decoders in the WebContent (or GPU/decoder) process.
- Trigger — Serve a malformed image whose codec-reported row width/height/stride or per-frame dimensions exceed the allocated frame backing store.
- Primitive — Heap out-of-bounds write with attacker-influenced length (clamped only by codec-reported sizes) and partially attacker-controlled pixel bytes adjacent to the ImageBackingStore allocation.
- Escalation (open) — Converting the linear heap overflow into control: groom an adjacent object with a corruptible length/pointer field next to the frame buffer; the degree of content control (pixel bytes) and overflow length control gates reaching a write primitive / RCE (inferred).
Detection & hunting
For defenders and detection engineers:
- ASan heap-buffer-overflow (write) in JPEGXLImageDecoder::imageOut / WEBPImageDecoder::applyPostProcessing / AVIF decode — Fuzz the JPEGXL/WEBP/AVIF decoders with mismatched declared vs actual dimensions and oversized rows under ASan.
- Decoder proceeding after setSize/initialize failure or on dimension mismatch — Instrument ScalableImageDecoder frame initialization to log cases where codec dimensions differ from the allocated buffer size.
Remaining risk in the patch
- Other decoders not in this diff — The patch hardens JPEGXL/WEBP/AVIF and the shared backing store, but other ScalableImageDecoder backends (PNG/GIF/JPEG/BMP/ICO) are not touched here; the same codec-dimension-vs-buffer pattern there is unverified (inferred).
- pixelsStartingAt/pixelAt internal checks — The JPEGXL/WEBP fixes clamp at the call site; the diff does not show ImageBackingStore::pixelsStartingAt/pixelAt themselves adding bounds checks, so other callers still rely on their own clamping.
Audit directions
- All ScalableImageDecoder backends — Audit PNG/GIF/JPEG/BMP/ICO/heif decoders for pixel writes using codec-reported width/height/stride without clamping to the backing store, and for ignored setSize/initialize return values.
- ASSERT_WITH_SECURITY_IMPLICATION as a bounds guard — Grep image/media code for ASSERT_WITH_SECURITY_IMPLICATION guarding buffer bounds and replace with real release-time checks/clamps.
- Per-frame/animation dimension changes — Review animated-image paths (APNG/animated WEBP/AVIF/GIF) for per-frame dimensions that differ from the initially allocated frame buffer.