home intel cve-2025-64720-libpng-oob-read-palette-alpha
CVE Analysis 2025-11-25 · 8 min read

CVE-2025-64720: libpng OOB Read via Palette Alpha Invariant Violation

libpng 1.6.0–1.6.50 misapplies background compositing during premultiplication on palette images with PNG_FLAG_OPTIMIZE_ALPHA, violating component ≤ alpha×257 and triggering an out-of-bounds read.

#png-image-processing#palette-image-vulnerability#out-of-bounds-read#memory-corruption#libpng-library
Technical mode — for security professionals
▶ Attack flow — CVE-2025-64720 · Memory Corruption
ATTACKERRemote / unauthMEMORY CORRUPTIOCVE-2025-64720Network · HIGHCODE EXECArbitrary codeas target processCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2025-64720 is an out-of-bounds read in libpng's simplified API, present from version 1.6.0 through 1.6.50, patched in 1.6.51. The defect lives at the intersection of two transformation passes: palette-to-RGBA expansion and premultiplied-alpha compositing. When an application calls png_image_begin_read_from_memory() or its file equivalent with a palette-type PNG and sets PNG_IMAGE_FLAG_FAST (which enables PNG_FLAG_OPTIMIZE_ALPHA), the internal compositing logic in png_init_read_transformations() applies a background blend before the premultiplication invariant is established. The result: a palette entry can produce a composited component value that exceeds alpha × 257, causing png_image_read_composite to read beyond the bounds of the compositing lookup table.

CVSS 7.1 (HIGH) — network vector, no authentication required where an attacker controls image delivery. No in-the-wild exploitation observed as of publication.

Affected Component

  • Library: libpng reference implementation
  • Versions: 1.6.0 – 1.6.50 (inclusive)
  • Source files: pngread.c (png_image_read_composite), pngtrans.c (png_init_read_transformations)
  • Trigger condition: Palette PNG + simplified API + PNG_IMAGE_FLAG_FAST or explicit PNG_FLAG_OPTIMIZE_ALPHA
  • Impact: Out-of-bounds read; potential info-leak or crash in image-parsing applications (browsers, document renderers, game engines, image pipelines)

Root Cause Analysis

The simplified API's compositing path maintains a strict invariant for every premultiplied pixel: each colour component C must satisfy C ≤ alpha × 257. The lookup table used by png_image_read_composite is sized exactly to that bound. The bug is that png_init_read_transformations applies the background compositing formula to palette entries after palette expansion but before clamping to the premultiplied range, allowing a crafted palette + tRNS chunk to produce an out-of-range index.


/* pngtrans.c – png_init_read_transformations() [libpng 1.6.50, simplified] */
static void
png_init_read_transformations(png_structrp png_ptr)
{
    /* ... palette expansion already queued ... */

    if (png_ptr->transformations & PNG_COMPOSE)
    {
        /* BUG: background compositing applied here, before premult clamp.
         * For palette entries, background_1 values are drawn directly from
         * the tRNS-expanded palette without verifying the invariant
         * component <= alpha * 257.  A crafted palette entry with
         * alpha=0x01 and R=0xFF produces composited R' = 0xFF, but the
         * premult table only has 0x01*257 = 0x0101 valid entries.
         * The write-back to png_ptr->palette[i].{red,green,blue} is
         * unclamped, poisoning the palette before png_image_read_composite
         * uses it as a direct index. */
        for (i = 0; i < png_ptr->num_palette; i++)
        {
            if (png_ptr->trans_alpha[i] == 0)
            {
                png_ptr->palette[i] = png_ptr->background_1; /* fully transparent: replace */
            }
            else if (png_ptr->trans_alpha[i] < 255)
            {
                /* Composite: out = src*alpha + bg*(1-alpha)  [0..255 range] */
                alpha = png_ptr->trans_alpha[i];

                /* BUG: result not clamped to alpha*257 range */
                png_ptr->palette[i].red   =
                    (png_byte)(((png_ptr->palette[i].red   * alpha) +
                                (png_ptr->background_1.red   * (255 - alpha)) + 127) / 255);
                png_ptr->palette[i].green =
                    (png_byte)(((png_ptr->palette[i].green * alpha) +
                                (png_ptr->background_1.green * (255 - alpha)) + 127) / 255);
                png_ptr->palette[i].blue  =
                    (png_byte)(((png_ptr->palette[i].blue  * alpha) +
                                (png_ptr->background_1.blue  * (255 - alpha)) + 127) / 255);
                /* alpha channel itself is NOT updated here – invariant broken */
            }
        }
    }

    /* PNG_FLAG_OPTIMIZE_ALPHA path then calls png_image_read_composite,
     * which indexes a table of size [alpha*257+1].  With the poisoned
     * palette entry (alpha=1, red=0xFF after composite), the index
     * 0xFF > 1*257/256 => table read at offset +N bytes past allocation. */
}

/* pngread.c – png_image_read_composite() [libpng 1.6.50] */
static void
png_image_read_composite(png_voidp argument)
{
    /* display->alpha_table is sized: (alpha * 257 + 1) entries */
    png_bytep table = display->alpha_table;

    for (/* each pixel */)
    {
        png_byte alpha = *inrow++;
        png_byte red   = *inrow++;

        /* BUG: if red > alpha*257/255 (invariant violated), this indexes
         * out of bounds into adjacent heap memory */
        *outrow++ = table[red * 257 + 0x80 >> 8];  // OOB READ HERE
        /* same for green, blue */
    }
}
Root cause: png_init_read_transformations composites palette RGB components against the background colour without updating or clamping against the entry's alpha, breaking the component ≤ alpha × 257 invariant required by png_image_read_composite's fixed-size lookup table.

Memory Layout

The alpha_table is heap-allocated at simplified-API read time. Its size is derived from the alpha value of the first palette entry encountered, or a worst-case precomputed bound. With a crafted tRNS chunk supplying alpha=1, the table is tiny — but the poisoned composite value red=0xFE drives an index of (0xFE * 257 + 0x80) >> 8 = 0xFD, which is 252 entries past a 2-entry table.


HEAP STATE — alpha_table allocation (alpha=0x01):

  [ chunk header          @ 0xXXXXXX00 ]
  [ alpha_table[0]  =0x00 @ 0xXXXXXX10 ]   <-- table start (2 valid bytes)
  [ alpha_table[1]  =0x01 @ 0xXXXXXX11 ]   <-- last valid entry
  [ ---- END OF ALLOCATION ----------- ]
  [ NEXT HEAP CHUNK HEADER @ 0xXXXXXX18 ]   <-- OOB read begins here

OOB READ: png_image_read_composite accesses table[0xFD]
  => reads 0xFD - 0x01 = 0xFC bytes (252) past end of allocation
  => traverses into adjacent chunk metadata or application heap data

INDEX CALCULATION:
  alpha        = 0x01   (from tRNS)
  red_composited = 0xFE  (from palette after background blend)
  index        = (0xFE * 257 + 0x80) >> 8
               = (0x0000FE * 0x101 + 0x80) >> 8
               = (0x0000FEFE + 0x0000FE + 0x80) >> 8
               = 0x0000FDFE >> 8   [approx]
               = 0xFD              (decimal 253)
  table_size   = alpha * 257 + 1 = 1*257+1 = 258 bytes  [worst-case alloc]
  -- OR under optimised path: ceil(alpha/255 * 256) + 1 = 2 bytes
  OOB delta    = 253 - 1 = 252 bytes past valid range

Exploitation Mechanics

Reliable arbitrary read requires heap shaping to place a target buffer adjacent to alpha_table. In practice, parsers that decode multiple images in sequence (e.g., animated sticker renderers, thumbnail generators) provide natural opportunities for grooming.


EXPLOIT CHAIN:

1. Attacker delivers a crafted PNG over the network (HTTP img tag,
   WebP fallback handler, document embed, etc.) to a target process
   that uses libpng's simplified API with PNG_IMAGE_FLAG_FAST.

2. PNG is constructed with:
     - Colour type: 3 (PLTE/indexed)
     - PLTE chunk:  entry[0] = { R=0xFF, G=0xFF, B=0xFF }
     - tRNS chunk:  trans_alpha[0] = 0x01  (near-transparent)
     - Background:  bKGD chunk present, colour = { 0xFE, 0xFE, 0xFE }

3. png_init_read_transformations() composites entry[0]:
     R' = (0xFF*1 + 0xFE*254 + 127) / 255 = 0xFE  (invariant violated)
     alpha remains 0x01 in trans_alpha[]

4. png_image_read_composite() allocates alpha_table sized for alpha=0x01.
   Under the optimised path this is as small as 2–258 bytes.

5. Heap-groom: issue N prior decode requests of known sizes to position
   a target object (e.g., a pixel output buffer whose contents will be
   returned to the caller) immediately after alpha_table.

6. png_image_read_composite indexes table[0xFD], reading up to 252 bytes
   of adjacent heap into the composited output pixel stream.

7. Application returns composited image data to caller (e.g., as a
   base64-encoded thumbnail or canvas ImageData), leaking heap contents.

8. Leaked data contains heap pointers; combined with a separate write
   primitive (not provided by this CVE alone), ASLR defeated.

NOTE: This CVE provides a read primitive only. It does not directly
produce code execution without a second primitive. Crash (DoS) is
unconditional on affected builds with ASAN or hardened allocators.

Patch Analysis

The fix in libpng 1.6.51 adds an explicit alpha-consistency check before compositing palette entries when PNG_FLAG_OPTIMIZE_ALPHA is active. After compositing, the palette RGB values are clamped to the premultiplied range, restoring the invariant before png_image_read_composite consumes them.


/* BEFORE (vulnerable – libpng 1.6.50, pngtrans.c): */
else if (png_ptr->trans_alpha[i] < 255)
{
    alpha = png_ptr->trans_alpha[i];
    png_ptr->palette[i].red   =
        (png_byte)(((png_ptr->palette[i].red   * alpha) +
                    (png_ptr->background_1.red   * (255 - alpha)) + 127) / 255);
    png_ptr->palette[i].green =
        (png_byte)(((png_ptr->palette[i].green * alpha) +
                    (png_ptr->background_1.green * (255 - alpha)) + 127) / 255);
    png_ptr->palette[i].blue  =
        (png_byte)(((png_ptr->palette[i].blue  * alpha) +
                    (png_ptr->background_1.blue  * (255 - alpha)) + 127) / 255);
    /* alpha NOT updated; invariant broken */
}

/* AFTER (patched – libpng 1.6.51): */
else if (png_ptr->trans_alpha[i] < 255)
{
    alpha = png_ptr->trans_alpha[i];
    png_uint_32 max_component = (png_uint_32)alpha * 257U;  /* invariant ceiling */

    png_uint_32 r = ((png_ptr->palette[i].red   * alpha) +
                     (png_ptr->background_1.red   * (255 - alpha)) + 127) / 255;
    png_uint_32 g = ((png_ptr->palette[i].green * alpha) +
                     (png_ptr->background_1.green * (255 - alpha)) + 127) / 255;
    png_uint_32 b = ((png_ptr->palette[i].blue  * alpha) +
                     (png_ptr->background_1.blue  * (255 - alpha)) + 127) / 255;

    /* PATCH: clamp to premultiplied range before storing */
    /* Ensures component <= alpha*257 invariant for png_image_read_composite */
    png_ptr->palette[i].red   = (png_byte)PNG_MIN(r * 257U, max_component) / 257U;
    png_ptr->palette[i].green = (png_byte)PNG_MIN(g * 257U, max_component) / 257U;
    png_ptr->palette[i].blue  = (png_byte)PNG_MIN(b * 257U, max_component) / 257U;
}

An alternative approach also accepted upstream: skip the background compositing pass entirely for palette entries when PNG_FLAG_OPTIMIZE_ALPHA is set and defer compositing to the row-transform stage where the invariant can be enforced per-pixel after premultiplication.

Detection and Indicators

ASAN output (affected build):


==PID==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000051
READ of size 1 at 0x602000000051 thread T0
    #0 png_image_read_composite    pngread.c:3412
    #1 png_safe_execute             pngread.c:968
    #2 png_image_finish_read        pngread.c:4011
    #3 decode_png_thumbnail         thumbnailer.c:88

0x602000000051 is located 240 bytes to the right of 2-byte region
[0x602000000010,0x602000000012) allocated by thread T0 here:
    #0 malloc
    #1 png_malloc_base              pngmem.c:102
    #2 png_image_read_composite     pngread.c:3388

Detection rules:

  • Static analysis: Flag any call site where png_image_finish_read is called with PNG_FORMAT_FLAG_ALPHA | PNG_IMAGE_FLAG_FAST and input is untrusted.
  • Fuzzing: AFL++ with the pngimage harness and ASAN; seed corpus should include palette PNGs with low-value tRNS entries and bKGD chunks.
  • Runtime: libpng version string check — any 1.6.x where x < 51 is affected. Check with png_get_header_ver(NULL).
  • Network IDS: PNG files with PLTE + tRNS + bKGD combination are a necessary (not sufficient) condition; signature on tRNS chunk containing bytes 0x010x0F paired with high-value PLTE RGB is a useful heuristic.

Remediation

  • Immediate: Upgrade to libpng 1.6.51 or later. This is the only complete fix.
  • Workaround (if upgrade is blocked): Do not set PNG_IMAGE_FLAG_FAST on untrusted input. Validate PNG colour type and reject type-3 (palette) images in security-sensitive decoders until patched.
  • Mitigation depth: Memory-safe allocators (hardened malloc, jemalloc with redzone) will turn the OOB read into an abort rather than a silent information leak, but do not prevent the bug.
  • Downstream consumers: Any project vendoring libpng (Chromium's third_party/libpng, Firefox's media/libpng, ImageMagick, SDL_image, Qt's QtGui PNG backend) should audit their bundled copy and cherry-pick the 1.6.51 patch commit.
  • Verification: After patching, run the libpng test suite with make check and add a regression PNG with alpha=1, R=0xFF, and a bKGD chunk to the corpus.
CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →