← WebKit Silent-Fix Report — 2026-W34

5b391e6d3b14d549908dad2c6695999291313f30  Out-of-bounds write in PNGImageDecoder::frameComplete() ICC transform

severity high class OOB confidence 0.90 WebCore PNGImageDecoder exploitable-grade
Sabith Saheb Thu Aug 20 05:33:42 2026 -0700 full: 5b391e6d3b14d549908dad2c6695999291313f30 view on GitHub ↗
Primitive: Heap out-of-bounds write in PNGImageDecoder::frameComplete() ICC transform
Triage note: cmsDoTransform() was passed rect.maxX() (xOffset+width) as the pixel count instead of rect.width(); an animated PNG frame with a nonzero xOffset makes LCMS transform past the end of destinationRow, writing OOB. Fix uses rect.width().
Contents

The bug at a glance

The overflow is triggered by simply loading a crafted animated PNG that carries an embedded ICC (iCCP) profile and a frame with a nonzero xOffset, which any web page or image reference can supply, so the attack surface is fully web-reachable on GTK/WPE ports built with USE(LCMS). The result is a heap out-of-bounds write via LCMS transforming past the end of the frame buffer, corrupting adjacent heap and on the last row writing past the pixel allocation, a strong primitive for memory corruption; the rating is tempered from critical by being limited to LCMS-enabled ports, giving CVSS 8.1.

PNGImageDecoder composites an animated PNG frame row by row into a destination that starts at the frame’s x offset, then optionally runs an embedded ICC color transform over that row. The compositing loop correctly writes rect.width() pixels, but the ICC transform was handed rect.maxX() — xOffset plus width — as its pixel count. LCMS dutifully read-modify-writes that many 4-byte BGRA pixels starting from the row origin, so any frame with a nonzero xOffset walks the transform off the end of each row and, on the final row, past the entire pixel allocation. The one fcTL sanity check the decoder enforces (xOffset + width <= canvas width) does nothing to stop it.

Root cause

In PNGImageDecoder::frameComplete(), an animated PNG frame is composited into the shared canvas buffer. For each row y, destinationRow is obtained via pixelsStartingAt(rect.x(), y) — i.e. it already points at the frame’s horizontal offset within the row — and the compositing loop writes exactly rect.width() pixels into it.

After compositing, when an embedded ICC profile is present (m_iccTransform is non-null, only on ports built with USE(LCMS) such as GTK and WPE), the decoder applies the color transform in place with cmsDoTransform(m_iccTransform.get(), destinationRow.data(), destinationRow.data(), <count>). The transform is TYPE_BGRA_8, a 4-byte-per-pixel read-modify-write, and <count> tells LCMS how many pixels to process starting at destinationRow.data().

The bug: <count> was rect.maxX(), which equals rect.x() + rect.width() (xOffset + width), not the row-relative span rect.width(). Because destinationRow already begins at rect.x(), passing rect.maxX() makes LCMS process rect.x() extra pixels beyond the frame’s own row span. Once 2*rect.x() + rect.width() exceeds the canvas width, the transform runs past the end of the row into neighboring rows, and on the last composited row (yOffset + height == canvas height) it runs past the end of the entire pixel allocation, producing a heap out-of-bounds read-modify-write.

The only frame-geometry guard from the fcTL chunk is xOffset + width <= canvas width, i.e. rect.maxX() <= width. That guarantees the frame fits within the canvas but says nothing about rect.x() + rect.maxX(), so it does not prevent the over-count; a frame whose xOffset + width equals the canvas width (as in the regression PNG) maximizes the overrun.

The fix changes the count argument from rect.maxX() to rect.width(), matching the number of pixels actually written by the compositing loop and the row-relative span, consistent with the first-frame path in rowAvailable() which already passes rect.width().

Key code

cmsDoTransform pixel count corrected from rect.maxX() to rect.width()

            // destinationRow = pixelsStartingAt(rect.x(), y); loop writes rect.width() pixels
#if USE(LCMS)
            if (m_iccTransform)
-               cmsDoTransform(m_iccTransform.get(), destinationRow.data(), destinationRow.data(), rect.maxX());
+               cmsDoTransform(m_iccTransform.get(), destinationRow.data(), destinationRow.data(), rect.width());
#endif

Patch walkthrough

  • Source/WebCore/platform/image-decoders/png/PNGImageDecoder.cpp — In PNGImageDecoder::frameComplete(), inside the USE(LCMS) block guarded by if (m_iccTransform), the fourth argument to cmsDoTransform() is changed from rect.maxX() to rect.width(). Since destinationRow already starts at pixelsStartingAt(rect.x(), y), the pixel count must be the row-relative width, not the absolute right edge; rect.maxX() over-counted by rect.x() pixels and let LCMS write out of bounds. This aligns the transform length with the compositing loop’s rect.width() writes and with the first-frame path in rowAvailable().
  • LayoutTests/fast/images/animated-png-icc-transform-crash.html — A regression test that loads a crafted animated PNG (with an embedded ICC profile and a second frame whose xOffset + width equals the canvas width) via an img tag and reports PASS on load. It exercises frameComplete()’s ICC path so that on a vulnerable build LCMS would transform past the frame buffer and crash under ASAN.
  • LayoutTests/fast/images/resources/animated-png-icc-transform-crash.png — The binary test asset: an APNG with an iCCP (RGB ICC) chunk and a second fcTL frame positioned at a nonzero xOffset such that xOffset + width == canvas width, the geometry that maximizes the out-of-bounds transform length on the final row.

Background

APNG fcTL / xOffset — Animated PNG frames are described by fcTL chunks giving each frame’s width, height, xOffset, and yOffset within the shared canvas. WebKit validates xOffset + width <= canvas width (rect.maxX() <= width) but this only checks that the frame fits, not the correctness of later per-row pixel counts.

cmsDoTransform / LCMS — Little CMS applies an ICC color transform to a pixel buffer in place. Its fourth argument is the number of pixels to process; with a TYPE_BGRA_8 transform each pixel is a 4-byte read-modify-write, so an inflated count causes both out-of-bounds reads and writes.

USE(LCMS) ports — The ICC transform path is compiled only on ports that use Little CMS for color management (GTK and WPE). Apple ports use a different color pipeline, so this specific overflow is confined to the LCMS-enabled builds.

pixelsStartingAt(rect.x(), y) — Returns a pointer into the frame buffer at the frame’s horizontal offset for row y. Because the pointer is already offset by rect.x(), any per-row length used with it must be the row-relative rect.width(), not the absolute rect.maxX().

Vulnerability window

  1. Load — A page references a crafted animated PNG containing an iCCP (RGB ICC) chunk and a multi-frame animation.
  2. Decode — On a USE(LCMS) build the decoder builds m_iccTransform from the embedded profile and begins compositing frames.
  3. Frame geometry — A frame with a nonzero xOffset (and, in the test, xOffset + width == canvas width) passes the only fcTL guard, rect.maxX() <= width.
  4. Composite — frameComplete() writes rect.width() pixels per row into destinationRow starting at rect.x().
  5. ICC transform — cmsDoTransform is invoked with rect.maxX() as the count, processing rect.x() extra pixels per row and, on the last row, past the end of the pixel allocation.
  6. Corruption — LCMS read-modify-writes beyond the frame buffer, corrupting adjacent heap; ASAN reports a heap-buffer-overflow write.

Proof of concept

Reconstructed from the shipped regression test. The crash requires a specially crafted APNG asset — one iCCP chunk plus a second fcTL frame whose xOffset + width equals the canvas width — which the patch supplies as animated-png-icc-transform-crash.png. Merely loading it in an <img> on a USE(LCMS) port triggers the out-of-bounds transform. The precise chunk bytes are not reproduced here (a valid crafted PNG is required), so this shows the trigger vector rather than a byte-level generator.

<html><body>
<!-- On GTK/WPE (USE(LCMS)) builds, loading an APNG with an embedded RGB
     iCCP profile and a frame at nonzero xOffset (xOffset+width == canvas
     width) drives cmsDoTransform past the end of the frame buffer. -->
<img src="resources/animated-png-icc-transform-crash.png">
</body></html>

Exploitation

  1. Craft the image — Attacker builds an APNG with an embedded RGB ICC profile and an animation frame at a chosen xOffset, tuning xOffset and width to control how many pixels past the row/allocation LCMS overwrites.
  2. Groom the heap — By controlling image dimensions and frame count, and by allocating adjacent objects around the frame buffer, an attacker positions a target object immediately after the pixel allocation so the last-row overrun lands on it.
  3. Corrupt adjacent object — The TYPE_BGRA_8 read-modify-write transforms attacker-influenced source bytes into the overflowed region; while the written values are color-transformed rather than fully arbitrary, controlling adjacent metadata (e.g. a length or pointer field) can yield a further primitive on the LCMS-enabled port.

Detection & hunting

For defenders and SOC / detection engineers:

  • ASAN heap-buffer-overflow in cmsDoTransform
  • Animated PNG with iCCP and offset frames
  • Crash confined to LCMS ports

Audit directions

  • Other cmsDoTransform call sites
  • Offset-relative pixel loops
  • fcTL validation completeness

Before / after

Loading diff…