56c0a88ac8c4b8b4a3da0bcd7a73d88c54420e07 [non-cocoa][fuzz] OOB read in APNG with hidden default image
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.
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
- Setup — Attacker hosts an APNG and causes a victim to decode it (, background-image, canvas drawImage, or fetch+createImageBitmap).
- Craft — An fcTL is built with m_xOffset <= cMaxPNGSize and m_width chosen so the 32-bit sum wraps to a value <= canvas width.
- 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.
- 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
- 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.
- Trigger — The wrapped fcTL bounds check admits an oversized frame rect; the backing store is sized to the canvas, not the frame.
- 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.
- 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.
- 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 —