← WebKit — 2026-W36

56c0a88ac8c4b8b4a3da0bcd7a73d88c54420e07  [non-cocoa][fuzz] OOB read in APNG with hidden default image

severity high class OOB-write confidence 0.7 signal score 6.59 Source/WebCore/platform patch-now AI review CONFIRMED
Primitive: Integer overflow in APNG fcTL bounds check lets frame rect exceed canvas
Triage note: readChunks now casts m_xOffset/m_yOffset to uint64_t before adding m_width/m_height, and rowAvailable clamps width to png_get_image_width; previously 32-bit m_xOffset + m_width could wrap and pass the >width check, letting a frame rect extend past the buffer and paintFrame write OOB. Attacker-controlled APNG input.
Structural signals: security_assert (+3.0), null_guard (+2.59), msg_strong (+3.0), large_churn (-2.0)

The bug at a glance

The APNG fcTL frame-rect bounds check in Source/WebCore/platform/image-decoders/png/PNGImageDecoder.cpp (WebContent image-decoder path) added the 32-bit frame offset and frame dimension in 32-bit arithmetic (m_xOffset + m_width), which can wrap around and pass the “must fit inside the canvas” check; the frame rect then extends past the frame-buffer backing store and paintFrame writes decoded pixels out of bounds. This is an attacker-controlled heap out-of-bounds write reachable by rendering a crafted APNG from any origin (, CSS, fetch+decode). Severity high: a controlled OOB heap write of pixel data is the strongest memory-corruption primitive in this batch; full RCE would require heap grooming and is not demonstrated by the diff (inferred).

A classic additive-overflow-before-bounds-check in animation metadata: the check static_cast<uint64_t>(m_xOffset) + m_width > width is the one-line fix, but the surrounding refactor shows the whole frame-rect clamp was being relied upon to catch what the wrapped check let through.

Root cause

An APNG fcTL chunk supplies a per-frame rectangle (m_xOffset, m_yOffset, m_width, m_height) that must lie inside the canvas (width x height). readChunks() validates this before the frame is painted. Pre-patch, the frame offset is bounded (m_xOffset > cMaxPNGSize is rejected) but the dimension is combined with it as m_xOffset + m_width > width in 32-bit unsigned arithmetic.

Pre-patch sequence: 1. attacker sends an fcTL with a modest m_xOffset (<= cMaxPNGSize) and an m_width near 2^32 chosen so m_xOffset + m_width wraps past UINT32_MAX to a small value <= width. 2. The wrapped sum passes the m_xOffset + m_width > width guard, and the pixelCount overflow guard uses the canvas width/height (not m_width), so it does not catch the frame dimension. 3. The frame is accepted and a frameRect(m_xOffset, m_yOffset, m_width, m_height) is built from the un-wrapped, oversized m_width/m_height. 4. The old frameComplete clamp (setWidth(size().width() - m_xOffset)) is itself unsafe once the rect is malformed. 5. paintFrame walks y in [rect.y(), rect.maxY()) and x in [rect.x(), rect.maxX()) calling buffer.backingStore()->setPixel/blendPixel at pixelsStartingAt(rect.x(), y) — writing past the frame buffer allocated for the real canvas size.

The fix widens the two additions to uint64_t so m_xOffset + m_width and m_yOffset + m_height cannot wrap, making the > width / > height rejections sound. The accompanying refactor hardens the paint path: rowAvailable/frameRowAvailable clamp width to std::min(size().width(), png_get_image_width(…)); updateFrameRect replaces the ad-hoc setWidth/setHeight clamp with frameRect.intersect(IntRect(IntPoint(), size())) guarded by an ASSERT that readChunks already rejected anything out of range; and paintFrame is split out and registered only on a frame’s own stream so canvas rows never reach it.

Key code

Widen the fcTL frame-rect bounds check to 64-bit so the offset+dimension addition cannot wrap

if (pixelCount.hasOverflowed() || pixelCount > cMaxDecodedPixels
    || m_xOffset > cMaxPNGSize || m_yOffset > cMaxPNGSize
    || static_cast<uint64_t>(m_xOffset) + m_width > width
    || static_cast<uint64_t>(m_yOffset) + m_height > height
    || m_dispose > 2 || m_blend > 1) {
    fallbackNotAnimated();
    return;
}

Fix approach

The core fix casts m_xOffset/m_yOffset to uint64_t before adding the (32-bit) m_width/m_height, so the comparison against the canvas width/height is done in arithmetic wide enough that it cannot overflow and the out-of-canvas frame is rejected (fallbackNotAnimated). Defense in depth is added on the paint side: width is clamped to the real libpng image width when copying rows, updateFrameRect intersects the frame rect with the canvas and asserts containment, and painting is moved into a dedicated paintFrame() invoked from a frameEnd callback bound only to a frame’s stream (so the incrementally-decoded canvas, painted a row at a time, never walks the frame rect). Net effect: a malformed fcTL is dropped at parse time, and even a rect that slipped through is intersected to the backing store before any pixel write.

Patch walkthrough

  • Source/WebCore/platform/image-decoders/png/PNGImageDecoder.cpp — readChunks casts offsets to uint64_t before the bounds addition (the security fix). The file is also refactored: new frameRowAvailable/frameEnd/paintFrame/beginFrame/updateFrameRect/currentFrameBuffer helpers separate canvas decoding from frame painting, width is clamped to png_get_image_width in rowAvailable, and updateFrameRect intersects the frame rect with the canvas under an ASSERT instead of the former subtractive clamp.

Background

APNG / fcTL chunk — Animated PNG carries per-frame control (fcTL) chunks giving each frame a sub-rectangle (x/y offset, width, height) within the canvas; these fields are fully attacker-controlled and must be validated against the canvas size.

Integer (wraparound) overflow — Adding two unsigned 32-bit values that exceed 2^32 wraps to a small result; a bounds check of the form a + b > limit then passes even though the true sum exceeds the limit.

ScalableImageDecoderFrame backing store — The per-frame pixel buffer allocated for the canvas size; setPixel/blendPixel/pixelsStartingAt index into it, and a frame rect larger than the canvas drives those writes past its end.

Progressive libpng callbacks — rowAvailable/frameRowAvailable/frameEnd are libpng progressive-read callbacks; the refactor routes canvas rows and animation-frame rows through different paths so frame painting is bounded to the frame’s own stream.

cMaxPNGSize / cMaxDecodedPixels — Decoder limits that cap the offset and total decoded pixels; notably they bound the offset and the canvas pixel count but not the raw fcTL m_width, which is what the pre-patch 32-bit addition failed to constrain.

Vulnerability window

  1. Setup — Attacker hosts an APNG and causes a victim to decode it (, background-image, canvas drawImage, or fetch+createImageBitmap).
  2. Craft — An fcTL is built with m_xOffset <= cMaxPNGSize and m_width chosen so the 32-bit sum wraps to a value <= canvas width.
  3. Trigger — readChunks accepts the frame because the wrapped addition passes the bounds check; a frameRect with the oversized dimension is set on the frame buffer.
  4. Corrupt — paintFrame iterates the oversized rect, writing decoded pixel bytes beyond the frame-buffer backing store — a heap OOB write.

Proof of concept

No test ships in the diff. Conceptual trigger: construct an APNG whose acTL declares animation and whose first fcTL sets width/x_offset so that (uint32_t)x_offset + width wraps below the canvas width (e.g. x_offset=16, width=0xFFFFFFF8), then provide fdAT frame data; loading it via on a vulnerable build drives an OOB write in PNGImageDecoder::paintFrame. Web reachability is direct (image decoding is unprivileged).

Exploitation

  1. Reach — Image decoding runs on any cross-origin image load, so the attacker needs only to get the victim to load a page/resource referencing the APNG.
  2. Trigger — The wrapped fcTL bounds check admits an oversized frame rect; the backing store is sized to the canvas, not the frame.
  3. Primary primitive — Linear heap OOB write of decoded RGBA pixel bytes past the frame-buffer allocation; attacker controls the pixel values (image data) and, via m_width/m_height/offset, the overflow extent.
  4. Secondary — With m_blend the write becomes a read-modify-write (blendPixel) of adjacent heap, and LCMS color transforms run over the rect, giving additional controlled processing of out-of-range memory.
  5. Open question — Turning the linear overwrite into control flow needs a groomable allocator neighbor (e.g. a vtable-bearing object) adjacent to the frame buffer; feasibility is not shown by the diff (inferred).

Detection & hunting

For defenders and detection engineers:

  • fcTL with offset+dimension near 2^32 —
  • ASAN/heap guard in paintFrame —

Remaining risk in the patch

  • m_width type and residual arithmetic — The cast widens only the offset operand; correctness assumes m_width/m_height individually fit in the uint64 result without their own prior truncation. If m_width is itself a narrow field assembled elsewhere, the guarantee depends on that assembly being range-checked, which is outside the supplied diff (inferred).
  • ASSERT-based invariants on the paint side — updateFrameRect/paintFrame now rely on ASSERTs (containment, rect == fcTL rect) rather than runtime rejection; in release builds the only real gate is readChunks, so any other path that sets a frame rect without going through readChunks would be unguarded.

Audit directions

  • Other image decoders’ per-frame rects —
  • Offset/dimension additions across WebCore —

Before / after

Loading diff…