home intel cve-2025-65018-libpng-heap-overflow-interlaced-16bit
CVE Analysis 2025-11-25 · 9 min read

CVE-2025-65018: libpng Heap Overflow in 16-bit Interlaced PNG Processing

A heap buffer overflow in libpng's simplified API png_image_finish_read corrupts memory when downsampling 16-bit interlaced PNGs to 8-bit output, affecting versions 1.6.0–1.6.50.

#heap-buffer-overflow#png-processing#interlaced-image#memory-corruption#libpng
Technical mode — for security professionals
▶ Attack flow — CVE-2025-65018 · Buffer Overflow
ATTACKERRemote / unauthBUFFER OVERFLOWCVE-2025-65018Network · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2025-65018 is a heap buffer overflow in libpng versions 1.6.0 through 1.6.50 affecting the simplified API path. The vulnerability triggers when an application calls png_image_finish_read with an 8-bit output format flag (PNG_FORMAT_FLAG_LINEAR cleared) while processing a source PNG that is both 16-bit depth and interlaced. The interaction between the interlace expansion pass logic and the row-buffer size calculation produces a buffer allocated for fewer bytes than the transform pipeline writes, resulting in heap corruption past the allocation boundary.

CVSS 7.1 (HIGH) — Network vector, no authentication required. An attacker delivers a crafted PNG over any surface that feeds into a libpng simplified-API consumer: image viewers, document renderers, web browsers using system libpng, container runtimes processing OCI image layers, etc.

Affected Component

The simplified API was introduced in libpng 1.6.0 as a higher-level wrapper around the classic png_read_image path. Relevant source files:

  • pngread.cpng_image_finish_read, the public entry point
  • pngrtran.c — transform pipeline, including png_do_read_interlace and the 16→8 bit reduction transforms
  • pngpread.c — progressive/interlaced row reader (png_push_process_row)

The bug exists across all 1.6.x releases from 1.6.0 to 1.6.50 inclusive. It is absent in the 1.4.x and 1.5.x branches because the simplified API did not exist there. Fixed in 1.6.51.

Root Cause Analysis

The simplified API computes the output row-buffer size in png_image_finish_read based on the output format, not the intermediate transform format. For an 8-bit output request (PNG_FORMAT_FLAG_LINEAR not set), it allocates rows of width × channels × 1 byte. However, when the source is a 16-bit interlaced PNG, the interlace expand pass (png_do_read_interlace) runs before the 16→8 bit reduction transform in the pipeline ordering, and it writes 16-bit pixels — 2 bytes per channel — into the row buffer that was only allocated for 8-bit pixels.

/* pngread.c — png_image_finish_read (simplified, pre-1.6.51) */
int png_image_finish_read(png_imagep image, png_colorp background,
    void *buffer, png_int_32 row_stride, void *colormap)
{
    /* Output format determines allocation: 8-bit path */
    png_uint_32 format = image->format;
    png_uint_32 channels = PNG_IMAGE_PIXEL_CHANNELS(format);

    /* BUG: row_bytes computed from OUTPUT bit depth (always 1 byte/channel
     * for non-LINEAR formats), ignoring that the interlace expander will
     * write 2 bytes/channel into this buffer before the 16->8 reduction. */
    png_alloc_size_t row_bytes = image->width * channels; /* 8-bit assumed */

    png_bytep local_row = png_voidcast(png_bytep,
        png_malloc(png_ptr, row_bytes));  /* under-allocated */

    /* For each Adam7 pass: */
    for (pass = 0; pass < passes; ++pass) {
        for (y = 0; y < height; ++y) {
            /* png_read_row triggers transform pipeline:
             *   1. png_do_read_interlace  -> writes 16-bit pixels to local_row
             *   2. png_do_scale_16_to_8  -> reduces in-place (too late)
             * Step 1 already overflowed local_row before step 2 runs. */
            png_read_row(png_ptr, local_row, NULL); /* OOB WRITE HERE */

            /* ... copy local_row to output buffer ... */
        }
    }
    /* BUG: heap write of (width * channels) extra bytes past local_row end */
}
Root cause: png_image_finish_read allocates local_row sized for 8-bit output pixels, but the transform pipeline writes 16-bit (2-byte) pixels during interlace expansion before the depth-reduction transform executes, overflowing the buffer by width × channels bytes.

The pipeline ordering in png_rset_IHDR and png_read_transform_info is deterministic: interlace expansion is applied early (it must be, to produce full-width rows from sparse Adam7 passes), while the 16→8 conversion is applied afterward. There is no mechanism for the row-buffer allocator to account for this intermediate expansion width.

/* pngrtran.c — transform ordering (conceptual, simplified) */
void png_do_read_transformations(png_structrp png_ptr, png_row_infop row_info)
{
#ifdef PNG_READ_INTERLACING_SUPPORTED
    /* STEP 1: expand interlaced sparse row to full width, 16-bit pixels */
    if (png_ptr->transformations & PNG_INTERLACE)
        png_do_read_interlace(row_info, png_ptr->row_buf + 1, png_ptr);
        /* ^^ writes row_info->width * 2 bytes regardless of output depth */
#endif

#ifdef PNG_READ_SCALE_16_TO_8_SUPPORTED
    /* STEP 2: reduce 16-bit to 8-bit — but local_row is already overflowed */
    if (png_ptr->transformations & PNG_SCALE_16_TO_8)
        png_do_scale_16_to_8(row_info, png_ptr->row_buf + 1);
#endif
}

Memory Layout

HEAP STATE BEFORE png_read_row() — Adam7 pass 7, 16-bit interlaced RGBA PNG
(width=256, channels=4, 8-bit output requested)

Allocation: local_row
  Size:    256 * 4 * 1 = 1024 bytes  (8-bit output assumption)
  Address: 0x55a3c8002a00
  Content: [uninitialized, 1024 bytes]

Adjacent allocation (ptmalloc chunk):
  Address: 0x55a3c8002e10  (0x55a3c8002a00 + 0x400 + 0x10 header)
  Content: png_struct internal row_buf (or next malloc'd object)

──────────────────────────────────────────────────────────────────
HEAP STATE AFTER png_do_read_interlace() writes 16-bit pixels:

Write size: 256 * 4 * 2 = 2048 bytes  (16-bit intermediate)
Overflow:   2048 - 1024 = 1024 bytes past local_row end

0x55a3c8002a00  [====== local_row (1024 B, legitimate) ======]
0x55a3c8002e00  [OOB+0x000] 16-bit pixel data continues...
0x55a3c8002e08  [OOB+0x008] ...overwrites next chunk size field
0x55a3c8002e10  [OOB+0x010] CORRUPTED: next allocation header
                  prev_size = 0x??  <- pixel data
                  size/flags = 0x?? <- pixel data (clears PREV_INUSE)

OVERFLOW RANGE: 0x55a3c8002e00 – 0x55a3c8003200 (1024 bytes of OOB)
/* Relevant struct for adjacent heap chunk (glibc ptmalloc) */
struct malloc_chunk {
    /* -0x10 */ size_t        mchunk_prev_size; /* size of previous chunk (if free) */
    /* -0x08 */ size_t        mchunk_size;      /* size + flags (P/M/A bits)        */
    /* +0x00 */ uint8_t       data[];            /* user data — local_row starts here */
};

/* png_struct internal row buffer (adjacent victim) */
struct png_struct_def {
    /* ... */
    /* +0x00 */ png_bytep     row_buf;           /* current row being decoded        */
    /* +0x08 */ png_bytep     prev_row;          /* previous row for filter          */
    /* +0x10 */ png_uint_32   rowbytes;          /* bytes in a row                   */
    /* +0x18 */ png_uint_32   irowbytes;         /* interlaced row bytes             */
    /* ... */
};

Exploitation Mechanics

EXPLOIT CHAIN — Remote heap corruption via crafted PNG:

1. CRAFT: Generate a valid 16-bit RGBA interlaced PNG
     - Bit depth: 16 (PNG_BIT_DEPTH_16)
     - Color type: 6 (RGBA)
     - Interlace: 1 (PNG_INTERLACE_ADAM7)
     - Width: chosen to maximize overflow (e.g., 256px → 1024-byte overflow)
     - All IDAT data must be valid deflate to pass CRC checks

2. DELIVER: Feed PNG to any consumer using simplified API:
     png_image image = {0};
     image.version = PNG_IMAGE_VERSION;
     png_image_begin_read_from_file(&image, "attacker.png");
     image.format = PNG_FORMAT_RGBA;  /* 8-bit output — triggers bug */
     buffer = malloc(PNG_IMAGE_SIZE(image));
     png_image_finish_read(&image, NULL, buffer, 0, NULL);  /* BOOM */

3. OVERFLOW: During Adam7 pass 7 (full resolution pass),
   png_do_read_interlace writes (width * 4 * 2) bytes into a
   (width * 4 * 1)-byte buffer. Overflow = width * 4 bytes.

4. CORRUPT: OOB write hits the adjacent heap chunk containing
   png_struct internals or application allocations. With a
   width-controlled overflow length, an attacker can:
     a. Corrupt chunk size/flags → trigger unsafe unlink on free()
     b. Overwrite function pointer within adjacent png_struct
     c. Target application-specific data structures post-png_struct

5. ESCALATE: On free(local_row) at end of png_image_finish_read,
   corrupted heap metadata triggers unlink → write-what-where.
   Alternatively, corrupted png_struct->read_data_fn (function pointer)
   executes on next png_read callback.

6. EXECUTE: Redirect execution to attacker-controlled payload.
   In browser/renderer contexts: ASLR bypass via info leak in
   pixel data → JIT spray or ROP chain.

SEVERITY AMPLIFIER: Width is attacker-controlled via PNG IHDR chunk.
Overflow size = width × 4, bounded only by image dimensions (max 2^31-1).
Partial overwrites with specific pixel values allow precise metadata targeting.

Patch Analysis

The fix in libpng 1.6.51 corrects the local_row allocation to use the maximum intermediate row size — accounting for the 16-bit pre-reduction intermediate — rather than the final output size.

/* BEFORE (vulnerable — pngread.c, libpng 1.6.0 – 1.6.50): */
png_alloc_size_t row_bytes =
    PNG_IMAGE_PIXEL_CHANNELS(image->format) * image->width;
    /* ^^ uses output bit depth (1 byte/channel for 8-bit formats) */

png_bytep local_row = png_voidcast(png_bytep,
    png_malloc(png_ptr, row_bytes));


/* AFTER (patched — pngread.c, libpng 1.6.51): */
png_alloc_size_t row_bytes =
    PNG_IMAGE_PIXEL_CHANNELS(image->format) * image->width;

/* Account for 16-bit intermediate when source is 16-bit depth
 * and output is 8-bit: interlace expansion writes 2 bytes/channel
 * before the depth reduction transform executes. */
if ((png_ptr->bit_depth == 16) &&
    !(image->format & PNG_FORMAT_FLAG_LINEAR) &&
    (png_ptr->interlaced != PNG_INTERLACE_NONE))
{
    /* BUG FIX: allocate for 16-bit intermediate row, not 8-bit output */
    row_bytes *= 2;
}

png_bytep local_row = png_voidcast(png_bytep,
    png_malloc(png_ptr, row_bytes));
    /* ^^ now correctly sized for worst-case intermediate representation */

An alternative formulation used in the actual commit allocates based on png_ptr->rowbytes (which reflects the source bit depth after png_read_update_info) rather than recomputing from the output format, which is strictly safer as it handles any future intermediate transform that could exceed the output size:

/* AFTER (alternate — use internal rowbytes for intermediate sizing): */
/* png_ptr->rowbytes set by png_read_update_info to reflect source depth */
png_alloc_size_t row_bytes = png_ptr->rowbytes;
/* rowbytes = width * channels * (source_bit_depth/8)
 * For 16-bit source: width * 4 * 2 — correct intermediate size */

png_bytep local_row = png_voidcast(png_bytep,
    png_malloc(png_ptr, row_bytes));

Detection and Indicators

Runtime detection: Enable AddressSanitizer (-fsanitize=address) in test builds. The overflow is reliably caught as a heap-buffer-overflow on the first full-resolution Adam7 pass write. A crash signature looks like:

==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x...
WRITE of size 2048 at 0x55a3c8002e00 thread T0
    #0 png_do_read_interlace  pngrtran.c:3421
    #1 png_do_read_transformations  pngrtran.c:4812
    #2 png_read_row  pngread.c:519
    #3 png_image_finish_read  pngread.c:1764

0x55a3c8002e00 is located 0 bytes to the right of 1024-byte region
[0x55a3c8002a00, 0x55a3c8002e00)
allocated by thread T0 here:
    #0 malloc
    #1 png_malloc  pngmem.c:122
    #2 png_image_finish_read  pngread.c:1721

Static indicators in PNG files:

  • IHDR chunk: bit depth byte = 0x10 (16), interlace byte = 0x01 (Adam7)
  • Color type = 0x02 (RGB) or 0x06 (RGBA) — maximizes channel count and overflow size
  • Large width values in IHDR amplify overflow: width × channels bytes of OOB write

Network/file indicators: Anomalous PNG files with 16-bit depth + Adam7 interlacing targeting simplified-API consumers. Legitimate 16-bit interlaced PNGs are rare in production; flag and inspect any encountered in untrusted input pipelines.

Remediation

Immediate: Upgrade libpng to 1.6.51 or later. All distributions shipping 1.6.x packages should patch. Check your dependency tree — many applications statically link libpng and will require rebuilding.

Verify your version:

/* Runtime check in consumer code */
if (png_access_version_number() < 10651) {
    /* reject 16-bit interlaced input or abort */
    fprintf(stderr, "libpng < 1.6.51 detected; CVE-2025-65018 exposure\n");
    exit(EXIT_FAILURE);
}

Workaround (if patching is not immediately possible): Before calling png_image_finish_read, inspect the image metadata and reject 16-bit interlaced sources:

/* Workaround: reject vulnerable PNG configurations */
png_image image = {0};
image.version = PNG_IMAGE_VERSION;
png_image_begin_read_from_memory(&image, data, len);

/* CVE-2025-65018 workaround: block 16-bit interlaced + 8-bit output */
if ((image.flags & PNG_IMAGE_FLAG_16BIT_sRGB) &&
    !(desired_format & PNG_FORMAT_FLAG_LINEAR)) {
    /* Either request LINEAR (16-bit) output, or reject the file */
    png_image_free(&image);
    return ERROR_UNSAFE_PNG;
}

Defense in depth: Deploy seccomp filters restricting mmap/mprotect in image-processing workers. Enable FORTIFY_SOURCE=2 and full RELRO in all consumers of libpng. Consider sandboxing image decode into a separate process with no ambient capabilities.

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 →